Merge pull request #273 from github/repo-sync

repo sync
This commit is contained in:
Octomerger Bot 2020-10-08 09:40:39 -07:00 коммит произвёл GitHub
Родитель 3d05341a5a 7c0c493c35
Коммит f6b8362bc6
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
10 изменённых файлов: 841 добавлений и 313 удалений

Просмотреть файл

@ -120,32 +120,32 @@ publishing {
##### Example using Kotlin DSL for multiple packages in the same repository
```shell
plugins {
`maven-publish` apply false
}
subprojects {
apply(plugin = "maven-publish")
configure<PublishingExtension> {
repositories {
maven {
name = "GitHubPackages"
url = uri("https://{% if currentVersion == "free-pro-team@latest" %}maven.pkg.github.com{% else %}<em>REGISTRY-URL</em>{% endif %}/<em>OWNER</em>/<em>REPOSITORY</em>")
credentials {
username = project.findProperty("gpr.user") as String? ?: System.getenv("<em>USERNAME</em>")
password = project.findProperty("gpr.key") as String? ?: System.getenv("<em>TOKEN</em>")
}
}
}
publications {
register<MavenPublication>("gpr") {
from(components["java"])
}
}
}
}
```
```shell
plugins {
`maven-publish` apply false
}
subprojects {
apply(plugin = "maven-publish")
configure<PublishingExtension> {
repositories {
maven {
name = "GitHubPackages"
url = uri("https://{% if currentVersion == "free-pro-team@latest" %}maven.pkg.github.com{% else %}<em>REGISTRY-URL</em>{% endif %}/<em>OWNER</em>/<em>REPOSITORY</em>")
credentials {
username = project.findProperty("gpr.user") as String? ?: System.getenv("<em>USERNAME</em>")
password = project.findProperty("gpr.key") as String? ?: System.getenv("<em>TOKEN</em>")
}
}
}
publications {
register<MavenPublication>("gpr") {
from(components["java"])
}
}
}
}
```
#### Authenticating with the `GITHUB_TOKEN`
@ -176,13 +176,13 @@ You can install a package by adding the package as a dependency to your project.
Example using Grady Groovy:
```shell
dependencies {
implementation 'com.example:package'
implementation 'com.example:package'
}
```
Example using Kotlin DSL:
```shell
dependencies {
implementation("com.example:package")
implementation("com.example:package")
}
```
@ -191,13 +191,13 @@ You can install a package by adding the package as a dependency to your project.
Example using Grady Groovy:
```shell
plugins {
id 'maven'
id 'maven'
}
```
Example using Kotlin DSL:
```shell
plugins {
`maven`
`maven`
}
```

118
javascripts/events.js Normal file
Просмотреть файл

@ -0,0 +1,118 @@
/* eslint-disable camelcase */
import { v4 as uuidv4 } from 'uuid'
import Cookies from 'js-cookie'
import getCsrf from './get-csrf'
const COOKIE_NAME = '_docs-events'
let cookieValue
export function getUserEventsId () {
if (cookieValue) return cookieValue
cookieValue = Cookies.get(COOKIE_NAME)
if (cookieValue) return cookieValue
cookieValue = uuidv4()
Cookies.set(COOKIE_NAME, cookieValue, {
secure: true,
sameSite: 'strict',
expires: 365
})
return cookieValue
}
export async function sendEvent ({
type,
version = '1.0.0',
page_render_duration,
exit_page_id,
exit_first_paint,
exit_dom_interactive,
exit_dom_complete,
exit_visit_duration,
exit_scroll_length,
link_url,
search_query,
search_context,
navigate_label,
survey_vote,
survey_comment,
survey_email,
experiment_name,
experiment_variation,
experiment_success
}) {
const response = await fetch('/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'CSRF-Token': getCsrf()
},
body: JSON.stringify({
type, // One of page, exit, link, search, navigate, survey, experiment
context: {
// Primitives
event_id: uuidv4(),
user: getUserEventsId(),
version,
created: new Date().toISOString(),
// Content information
path: location.pathname,
referrer: document.referrer,
search: location.search,
href: location.href,
site_language: location.pathname.split('/')[1],
// Device information
// os:
// os_version:
// browser:
// browser_version:
viewport_width: document.documentElement.clientWidth,
viewport_height: document.documentElement.clientHeight,
// Location information
timezone: new Date().getTimezoneOffset() / -60,
user_language: navigator.language
},
// Page event
page_render_duration,
// Exit event
exit_page_id,
exit_first_paint,
exit_dom_interactive,
exit_dom_complete,
exit_visit_duration,
exit_scroll_length,
// Link event
link_url,
// Search event
search_query,
search_context,
// Navigate event
navigate_label,
// Survey event
survey_vote,
survey_comment,
survey_email,
// Experiment event
experiment_name,
experiment_variation,
experiment_success
})
})
const data = response.ok ? await response.json() : {}
return data
}
export default async function initializeEvents () {
await sendEvent({ type: 'page' })
}

Просмотреть файл

@ -14,8 +14,9 @@ import localization from './localization'
import helpfulness from './helpfulness'
import experiment from './experiment'
import { fillCsrf } from './get-csrf'
import initializeEvents from './events'
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
displayPlatformSpecificContent()
explorer()
search()
@ -27,7 +28,8 @@ document.addEventListener('DOMContentLoaded', () => {
wrapCodeTerms()
print()
localization()
fillCsrf()
await fillCsrf() // this must complete before any POST calls
helpfulness()
experiment()
initializeEvents()
})

Просмотреть файл

@ -1,10 +1,21 @@
const crypto = require('crypto')
const fetch = require('node-fetch')
const SCHEMAS = {
page: 'docs.v0.PageEvent',
exit: 'docs.v0.ExitEvent',
link: 'docs.v0.LinkEvent',
search: 'docs.v0.SearchEvent',
navigate: 'docs.v0.NavigateEvent',
survey: 'docs.v0.SurveyEvent',
experiment: 'docs.v0.ExperimentEvent'
}
module.exports = class Hydro {
constructor ({ secret, endpoint }) {
constructor ({ secret, endpoint } = {}) {
this.secret = secret || process.env.HYDRO_SECRET
this.endpoint = endpoint || process.env.HYDRO_ENDPOINT
this.schemas = SCHEMAS
}
/**
@ -32,7 +43,13 @@ module.exports = class Hydro {
* @param {[{ schema: string, value: any }]} events
*/
async publishMany (events) {
const body = JSON.stringify({ events })
const body = JSON.stringify({
events: events.map(({ schema, value }) => ({
schema,
value: JSON.stringify(value), // We must double-encode the value property
cluster: 'potomac' // We only have ability to publish externally to potomac cluster
}))
})
const token = this.generatePayloadHmac(body)
return fetch(this.endpoint, {
@ -41,7 +58,7 @@ module.exports = class Hydro {
headers: {
Authorization: `Hydro ${token}`,
'Content-Type': 'application/json',
'X-Hydro-App': 'docs'
'X-Hydro-App': 'docs-production'
}
})
}

Просмотреть файл

@ -1,13 +1,14 @@
const languages = require('./languages')
module.exports = {
const context = {
type: 'object',
additionalProperties: false,
required: [
'event_id',
'type',
'user',
'version',
'created'
'created',
'path'
],
properties: {
// Required of all events
@ -16,11 +17,6 @@ module.exports = {
description: 'The unique identifier of the event.',
format: 'uuid'
},
type: {
type: 'string',
description: 'The type of the event.',
enum: ['page', 'exit', 'link', 'search', 'navigate', 'survey', 'experiment']
},
user: {
type: 'string',
description: 'The unique identifier of the current user performing the event. Please use randomly generated values or hashed values; we don\'t want to be able to look up in a database.',
@ -29,18 +25,13 @@ module.exports = {
version: {
type: 'string',
description: 'The version of the event schema.',
pattern: /^\d+(\.\d+)?(\.\d+)?$/
pattern: '^\\d+(\\.\\d+)?(\\.\\d+)?$' // eslint-disable-line
},
created: {
type: 'string',
format: 'date-time',
description: 'The time we created the event; please reference UTC.'
},
token: {
type: 'string',
description: 'A honeypot.',
maxLength: 0
},
// Content information
path: {
@ -109,162 +100,214 @@ module.exports = {
type: 'string',
description: 'The browser value of `navigator.language`.'
}
},
oneOf: [{
// *** type: page ***
required: [
'path',
'href'
],
properties: {
type: {
type: 'string',
pattern: /^page$/
},
page_render_duration: {
type: {
type: 'number',
description: 'How long the server took to render the page content, in seconds.',
minimum: 0.001
}
}
}
}, {
// *** type: exit ***
required: [
'exit_page_id'
],
properties: {
type: {
type: 'string',
pattern: /^exit$/
},
exit_page_id: {
type: 'string',
format: 'uuid',
description: 'The value of the corresponding `page` event.'
// id of the "page" event
},
exit_first_paint: {
type: 'number',
minimum: 0.001,
description: 'The duration until `first-contentful-paint`, in seconds. Informs CSS performance.'
},
exit_dom_interactive: {
type: 'number',
minimum: 0.001,
description: 'The duration until `PerformanceNavigationTiming.domInteractive`, in seconds. Informs JavaScript loading performance.'
},
exit_dom_complete: {
type: 'number',
minimum: 0.001,
description: 'The duration until `PerformanceNavigationTiming.domComplete`, in seconds. Informs JavaScript execution performance.'
},
exit_visit_duration: {
type: 'number',
minimum: 0.001,
description: 'The duration of exit.timestamp - page.timestamp, in seconds. Informs bounce rate.'
},
exit_scroll_length: {
type: 'number',
minimum: 0,
maximum: 1,
description: 'The percentage of how far the user scrolled on the page.'
}
}
}, {
// *** type: link ***
required: [
'link_url'
],
properties: {
type: {
type: 'string',
pattern: /^link$/
},
link_url: {
type: 'string',
format: 'uri',
description: 'The href of the anchor tag the user clicked, or the page or object they directed their browser to.'
}
}
}, {
// *** type: search ***
required: [
'search_query'
],
properties: {
type: {
type: 'string',
pattern: /^search$/
},
search_query: {
type: 'string',
description: 'The actual text content of the query string the user sent to the service.'
},
search_context: {
type: 'string',
description: 'Any additional search context, such as component searched.'
}
}
}, {
// *** type: navigate ***
properties: {
type: {
type: 'string',
pattern: /^navigate$/
},
navigate_label: {
type: 'string',
description: 'An identifier for where the user is navigating.'
}
}
}, {
// *** type: survey ***
properties: {
type: {
type: 'string',
pattern: /^survey$/
},
survey_vote: {
type: 'boolean',
description: 'Whether the user found the page helpful.'
},
survey_comment: {
type: 'string',
description: 'Any textual comments the user wanted to provide.'
},
survey_email: {
type: 'string',
format: 'email',
description: 'The user\'s email address, if the user provided and consented.'
}
}
}, {
// *** type: experiment ***
required: [
'experiment_name',
'experiment_variation'
],
properties: {
type: {
type: 'string',
pattern: /^experiment$/
},
experiment_name: {
type: 'string',
description: 'The test that this event is part of.'
},
experiment_variation: {
type: 'string',
enum: ['control', 'treatment'],
description: 'The variation this user we bucketed in, such as control or treatment.'
},
experiment_success: {
type: 'boolean',
default: true,
description: 'Whether or not the user successfully performed the test goal.'
}
}
}]
}
}
const pageSchema = {
additionalProperties: false,
required: [
'type',
'context'
],
properties: {
context,
type: {
type: 'string',
pattern: '^page$'
},
page_render_duration: {
type: 'number',
description: 'How long the server took to render the page content, in seconds.',
minimum: 0.001
}
}
}
const exitSchema = {
additionalProperties: false,
required: [
'type',
'context',
'exit_page_id'
],
properties: {
context,
type: {
type: 'string',
pattern: '^exit$'
},
exit_page_id: {
type: 'string',
format: 'uuid',
description: 'The value of the corresponding `page` event.'
// id of the "page" event
},
exit_first_paint: {
type: 'number',
minimum: 0.001,
description: 'The duration until `first-contentful-paint`, in seconds. Informs CSS performance.'
},
exit_dom_interactive: {
type: 'number',
minimum: 0.001,
description: 'The duration until `PerformanceNavigationTiming.domInteractive`, in seconds. Informs JavaScript loading performance.'
},
exit_dom_complete: {
type: 'number',
minimum: 0.001,
description: 'The duration until `PerformanceNavigationTiming.domComplete`, in seconds. Informs JavaScript execution performance.'
},
exit_visit_duration: {
type: 'number',
minimum: 0.001,
description: 'The duration of exit.timestamp - page.timestamp, in seconds. Informs bounce rate.'
},
exit_scroll_length: {
type: 'number',
minimum: 0,
maximum: 1,
description: 'The percentage of how far the user scrolled on the page.'
}
}
}
const linkSchema = {
additionalProperties: false,
required: [
'type',
'context',
'link_url'
],
properties: {
context,
type: {
type: 'string',
pattern: '^link$'
},
link_url: {
type: 'string',
format: 'uri',
description: 'The href of the anchor tag the user clicked, or the page or object they directed their browser to.'
}
}
}
const searchSchema = {
additionalProperties: false,
required: [
'type',
'context',
'search_query'
],
properties: {
context,
type: {
type: 'string',
pattern: '^search$'
},
search_query: {
type: 'string',
description: 'The actual text content of the query string the user sent to the service.'
},
search_context: {
type: 'string',
description: 'Any additional search context, such as component searched.'
}
}
}
const navigateSchema = {
additionalProperties: false,
required: [
'type',
'context'
],
properties: {
context,
type: {
type: 'string',
pattern: '^navigate$'
},
navigate_label: {
type: 'string',
description: 'An identifier for where the user is navigating.'
}
}
}
const surveySchema = {
additionalProperties: false,
required: [
'type',
'context',
'survey_vote'
],
properties: {
context,
type: {
type: 'string',
pattern: '^survey$'
},
token: {
type: 'string',
description: 'A honeypot.',
maxLength: 0
},
survey_vote: {
type: 'boolean',
description: 'Whether the user found the page helpful.'
},
survey_comment: {
type: 'string',
description: 'Any textual comments the user wanted to provide.'
},
survey_email: {
type: 'string',
format: 'email',
description: 'The user\'s email address, if the user provided and consented.'
}
}
}
const experimentSchema = {
additionalProperties: false,
required: [
'type',
'context',
'experiment_name',
'experiment_variation'
],
properties: {
context,
type: {
type: 'string',
pattern: '^experiment$'
},
experiment_name: {
type: 'string',
description: 'The test that this event is part of.'
},
experiment_variation: {
type: 'string',
enum: ['control', 'treatment'],
description: 'The variation this user we bucketed in, such as control or treatment.'
},
experiment_success: {
type: 'boolean',
default: true,
description: 'Whether or not the user successfully performed the test goal.'
}
}
}
module.exports = {
oneOf: [
pageSchema,
exitSchema,
linkSchema,
searchSchema,
navigateSchema,
surveySchema,
experimentSchema
]
}

Просмотреть файл

@ -3,6 +3,7 @@ const Airtable = require('airtable')
const { omit } = require('lodash')
const Ajv = require('ajv')
const schema = require('../lib/schema-event')
const schemaHydro = require('../lib/schema-event-2')
const TABLE_NAMES = {
HELPFULNESS: 'Helpfulness Survey',
@ -15,7 +16,7 @@ const ajv = new Ajv()
const router = express.Router()
router.post('/', async (req, res, next) => {
async function airtablePost (req, res, next) {
const { AIRTABLE_API_KEY, AIRTABLE_BASE_KEY } = process.env
if (!AIRTABLE_API_KEY || !AIRTABLE_BASE_KEY) {
return res.status(501).send({})
@ -36,6 +37,27 @@ router.post('/', async (req, res, next) => {
console.error('unable to POST event', err)
return res.status(err.statusCode).send(err)
}
}
router.post('/', async (req, res, next) => {
// All-caps type is an "Airtable" event
if (req.body.type === 'HELPFULNESS' || req.body.type === 'EXPERIMENT') {
return airtablePost(req, res, next)
}
// Remove the condition above when we are no longer sending to Airtable
if (!ajv.validate(schemaHydro, req.body)) {
if (process.env.NODE_ENV === 'development') console.log(ajv.errorsText())
return res.status(400).json({})
}
const fields = omit(req.body, OMIT_FIELDS)
try {
const hydroRes = await req.hydro.publish(req.hydro.schemas[req.body.type], fields)
if (!hydroRes.ok) return res.status(500).json({})
return res.status(201).json(fields)
} catch (err) {
if (process.env.NODE_ENV === 'development') console.log(err)
return res.status(500).json({})
}
})
router.put('/:id', async (req, res, next) => {

Просмотреть файл

@ -24,6 +24,7 @@ module.exports = function (app) {
app.use(require('./cors'))
app.use(require('./csp'))
app.use(require('helmet')())
app.use(require('./req-utils'))
app.use(require('./robots'))
app.use(require('./cookie-parser'))
app.use(require('./csrf'))

6
middleware/req-utils.js Normal file
Просмотреть файл

@ -0,0 +1,6 @@
const Hydro = require('../lib/hydro')
module.exports = (req, res, next) => {
req.hydro = new Hydro()
return next()
}

Просмотреть файл

@ -1,5 +1,6 @@
const request = require('supertest')
const Airtable = require('airtable')
const nock = require('nock')
const app = require('../../server')
jest.mock('airtable')
@ -19,17 +20,33 @@ describe('POST /events', () => {
beforeEach(async () => {
process.env.AIRTABLE_API_KEY = '$AIRTABLE_API_KEY$'
process.env.AIRTABLE_BASE_KEY = '$AIRTABLE_BASE_KEY$'
process.env.HYDRO_SECRET = '$HYDRO_SECRET$'
process.env.HYDRO_ENDPOINT = 'http://example.com/hydro'
agent = request.agent(app)
const csrfRes = await agent.get('/csrf')
csrfToken = csrfRes.body.token
nock('http://example.com')
.post('/hydro')
.reply(200, {})
})
afterEach(() => {
delete process.env.AIRTABLE_API_KEY
delete process.env.AIRTABLE_BASE_KEY
delete process.env.HYDRO_SECRET
delete process.env.HYDRO_ENDPOINT
csrfToken = ''
})
async function checkEvent (data, code) {
return agent
.post('/events')
.send(data)
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(code)
}
describe('HELPFULNESS', () => {
const example = {
type: 'HELPFULNESS',
@ -41,94 +58,43 @@ describe('POST /events', () => {
}
it('should accept a valid object', () =>
agent
.post('/events')
.send(example)
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(201)
checkEvent(example, 201)
)
it('should reject extra properties', () =>
agent
.post('/events')
.send({ ...example, toothpaste: false })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(400)
checkEvent({ ...example, toothpaste: false }, 400)
)
it('should not accept if type is missing', () =>
agent
.post('/events')
.send({ ...example, type: undefined })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(400)
checkEvent({ ...example, type: undefined }, 400)
)
it('should not accept if url is missing', () =>
agent
.post('/events')
.send({ ...example, url: undefined })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(400)
checkEvent({ ...example, url: undefined }, 400)
)
it('should not accept if url is misformatted', () =>
agent
.post('/events')
.send({ ...example, url: 'examplecom' })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(400)
checkEvent({ ...example, url: 'examplecom' }, 400)
)
it('should not accept if vote is missing', () =>
agent
.post('/events')
.send({ ...example, vote: undefined })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(400)
checkEvent({ ...example, vote: undefined }, 400)
)
it('should not accept if vote is not boolean', () =>
agent
.post('/events')
.send({ ...example, vote: 'true' })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(400)
checkEvent({ ...example, vote: 'true' }, 400)
)
it('should not accept if email is misformatted', () =>
agent
.post('/events')
.send({ ...example, email: 'testexample.com' })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(400)
checkEvent({ ...example, email: 'testexample.com' }, 400)
)
it('should not accept if comment is not string', () =>
agent
.post('/events')
.send({ ...example, comment: [] })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(400)
checkEvent({ ...example, comment: [] }, 400)
)
it('should not accept if category is not an option', () =>
agent
.post('/events')
.send({ ...example, category: 'Fabulous' })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(400)
checkEvent({ ...example, category: 'Fabulous' }, 400)
)
})
@ -142,57 +108,401 @@ describe('POST /events', () => {
}
it('should accept a valid object', () =>
agent
.post('/events')
.send(example)
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(201)
checkEvent(example, 201)
)
it('should reject extra fields', () =>
agent
.post('/events')
.send({ ...example, toothpaste: false })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(400)
checkEvent({ ...example, toothpaste: false }, 400)
)
it('should require a long unique user-id', () =>
agent
.post('/events')
.send({ ...example, 'user-id': 'short' })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(400)
checkEvent({ ...example, 'user-id': 'short' }, 400)
)
it('should require a test', () =>
agent
.post('/events')
.send({ ...example, test: undefined })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(400)
checkEvent({ ...example, test: undefined }, 400)
)
it('should require a valid group', () =>
agent
.post('/events')
.send({ ...example, group: 'revolution' })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(400)
checkEvent({ ...example, group: 'revolution' }, 400)
)
it('should default the success field', () =>
agent
.post('/events')
.send({ ...example, success: undefined })
.set('Accept', 'application/json')
.set('csrf-token', csrfToken)
.expect(201)
checkEvent({ ...example, success: undefined }, 201)
)
})
const baseExample = {
context: {
// Primitives
event_id: 'a35d7f88-3f48-4f36-ad89-5e3c8ebc3df7',
user: '703d32a8-ed0f-45f9-8d78-a913d4dc6f19',
version: '1.0.0',
created: '2020-10-02T17:12:18.620Z',
// Content information
path: '/github/docs/issues',
referrer: 'https://github.com/github/docs',
search: '?q=is%3Aissue+is%3Aopen+example+',
href: 'https://github.com/github/docs/issues?q=is%3Aissue+is%3Aopen+example+',
site_language: 'en',
// Device information
os: 'linux',
os_version: '18.04',
browser: 'chrome',
browser_version: '85.0.4183.121',
viewport_width: 1418,
viewport_height: 501,
// Location information
timezone: -7,
user_language: 'en-US'
}
}
describe('page', () => {
const pageExample = { ...baseExample, type: 'page' }
it('should record a page event', () =>
checkEvent(pageExample, 201)
)
it('should require a type', () =>
checkEvent(baseExample, 400)
)
it('should require an event_id in uuid', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
event_id: 'asdfghjkl'
}
}, 400)
)
it('should require a user in uuid', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
user: 'asdfghjkl'
}
}, 400)
)
it('should require a version', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
version: undefined
}
}, 400)
)
it('should require created timestamp', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
timestamp: 1234
}
}, 400)
)
it('should not allow a honeypot token', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
token: 'zxcv'
}
}, 400)
)
it('should path be uri-reference', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
path: ' '
}
}, 400)
)
it('should referrer be uri-reference', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
referrer: ' '
}
}, 400)
)
it('should search a string', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
search: 1234
}
}, 400)
)
it('should href be uri', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
href: '/example'
}
}, 400)
)
it('should site_language is a valid option', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
site_language: 'nl'
}
}, 400)
)
it('should a valid os option', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
os: 'ubuntu'
}
}, 400)
)
it('should os_version a string', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
os_version: 25
}
}, 400)
)
it('should browser a valid option', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
browser: 'opera'
}
}, 400)
)
it('should browser_version a string', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
browser_version: 25
}
}, 400)
)
it('should viewport_width a number', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
viewport_width: -500
}
}, 400)
)
it('should viewport_height a number', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
viewport_height: '53px'
}
}, 400)
)
it('should timezone in number', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
timezone: 'GMT-0700'
}
}, 400)
)
it('should user_language is a string', () =>
checkEvent({
...pageExample,
context: {
...pageExample.context,
user_language: true
}
}, 400)
)
it('should page_render_duration is a positive number', () =>
checkEvent({
...pageExample,
page_render_duration: -0.5
}, 400)
)
})
describe('exit', () => {
const exitExample = {
...baseExample,
type: 'exit',
exit_page_id: 'c93c2d16-8e07-43d5-bc3c-eacc999c184d',
exit_first_paint: 0.1,
exit_dom_interactive: 0.2,
exit_dom_complete: 0.3,
exit_visit_duration: 5,
exit_scroll_length: 0.5
}
it('should record an exit event', () =>
checkEvent(exitExample, 201)
)
it('should require exit_page_id', () =>
checkEvent({ ...exitExample, exit_page_id: undefined }, 400)
)
it('should require exit_page_id is a uuid', () =>
checkEvent({ ...exitExample, exit_page_id: 'afjdskalj' }, 400)
)
it('exit_first_paint is a number', () =>
checkEvent({ ...exitExample, exit_first_paint: 'afjdkl' }, 400)
)
it('exit_dom_interactive is a number', () =>
checkEvent({ ...exitExample, exit_dom_interactive: '202' }, 400)
)
it('exit_visit_duration is a number', () =>
checkEvent({ ...exitExample, exit_visit_duration: '75' }, 400)
)
it('exit_scroll_length is a number between 0 and 1', () =>
checkEvent({ ...exitExample, exit_scroll_length: 1.1 }, 400)
)
})
describe('link', () => {
const linkExample = {
...baseExample,
type: 'link',
link_url: 'https://example.com'
}
it('should send a link event', () =>
checkEvent(linkExample, 201)
)
it('link_url is a required uri formatted string', () =>
checkEvent({ ...linkExample, link_url: 'foo' }, 400)
)
})
describe('search', () => {
const searchExample = {
...baseExample,
type: 'search',
search_query: 'github private instances',
search_context: 'private'
}
it('should record a search event', () =>
checkEvent(searchExample, 201)
)
it('search_query is required string', () =>
checkEvent({ ...searchExample, search_query: undefined }, 400)
)
it('search_context is optional string', () =>
checkEvent({ ...searchExample, search_context: undefined }, 201)
)
})
describe('navigate', () => {
const navigateExample = {
...baseExample,
type: 'navigate',
navigate_label: 'drop down'
}
it('should record a navigate event', () =>
checkEvent(navigateExample, 201)
)
it('navigate_label is optional string', () =>
checkEvent({ ...navigateExample, navigate_label: undefined }, 201)
)
})
describe('survey', () => {
const surveyExample = {
...baseExample,
type: 'survey',
survey_vote: true,
survey_comment: 'I love this site.',
survey_email: 'daisy@example.com'
}
it('should record a survey event', () =>
checkEvent(surveyExample, 201)
)
it('survey_vote is boolean', () =>
checkEvent({ ...surveyExample, survey_vote: undefined }, 400)
)
it('survey_comment is string', () => {
checkEvent({ ...surveyExample, survey_comment: 1234 }, 400)
})
it('survey_email is email', () => {
checkEvent({ ...surveyExample, survey_email: 'daisy' }, 400)
})
})
describe('experiment', () => {
const experimentExample = {
...baseExample,
type: 'experiment',
experiment_name: 'change-button-copy',
experiment_variation: 'treatment',
experiment_success: true
}
it('should record an experiment event', () =>
checkEvent(experimentExample, 201)
)
it('experiment_name is required string', () =>
checkEvent({ ...experimentExample, experiment_name: undefined }, 400)
)
it('experiment_variation is required string', () =>
checkEvent({ ...experimentExample, experiment_variation: undefined }, 400)
)
it('experiment_success is optional boolean', () =>
checkEvent({ ...experimentExample, experiment_success: undefined }, 201)
)
})
})

Просмотреть файл

@ -11,18 +11,22 @@ describe('hydro', () => {
reqheaders: {
Authorization: /^Hydro [\d\w]{64}$/,
'Content-Type': 'application/json',
'X-Hydro-App': 'docs'
'X-Hydro-App': 'docs-production'
}
})
// Respond with a 201 and store the body we sent
.post('/').reply(201, (_, body) => { params = body })
// Respond with a 200 and store the body we sent
.post('/').reply(200, (_, body) => { params = body })
})
describe('#publish', () => {
it('publishes a single event to Hydro', async () => {
await hydro.publish('event-name', { pizza: true })
expect(params).toEqual({
events: [{ schema: 'event-name', value: { pizza: true } }]
events: [{
schema: 'event-name',
value: JSON.stringify({ pizza: true }),
cluster: 'potomac'
}]
})
})
})
@ -35,10 +39,15 @@ describe('hydro', () => {
])
expect(params).toEqual({
events: [
{ schema: 'event-name', value: { pizza: true } },
{ schema: 'other-name', value: { salad: false } }
]
events: [{
schema: 'event-name',
value: JSON.stringify({ pizza: true }),
cluster: 'potomac'
}, {
schema: 'other-name',
value: JSON.stringify({ salad: false }),
cluster: 'potomac'
}]
})
})
})