feat: capture protocol delivery (#26421)

Co-authored-by: Matt Schile <mschile@cypress.io>
Co-authored-by: David Rowe <95636404+davidr-cy@users.noreply.github.com>
Co-authored-by: Ryan Manuel <ryanm@cypress.io>
This commit is contained in:
Tim Griesser
2023-05-15 15:06:27 -04:00
committed by GitHub
parent 3c6e53f039
commit d2ef2c1393
45 changed files with 1168 additions and 327 deletions

View File

@@ -1,3 +1,3 @@
# Bump this version to force CI to re-create the cache from scratch.
04-19-22
05-10-22

View File

@@ -31,6 +31,7 @@ mainBuildFilters: &mainBuildFilters
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- 'update-v8-snapshot-cache-on-develop'
- 'feat/protocol'
- 'tgriesser/feat/protocol-delivery'
# usually we don't build Mac app - it takes a long time
# but sometimes we want to really confirm we are doing the right thing
@@ -42,6 +43,7 @@ macWorkflowFilters: &darwin-workflow-filters
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'feat/protocol', << pipeline.git.branch >> ]
- equal: [ 'tgriesser/feat/protocol-delivery', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -53,6 +55,7 @@ linuxArm64WorkflowFilters: &linux-arm64-workflow-filters
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'feat/protocol', << pipeline.git.branch >> ]
- equal: [ 'tgriesser/feat/protocol-delivery', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -73,6 +76,7 @@ windowsWorkflowFilters: &windows-workflow-filters
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'feat/protocol', << pipeline.git.branch >> ]
- equal: [ 'tgriesser/feat/protocol-delivery', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -139,7 +143,7 @@ commands:
- run:
name: Check current branch to persist artifacts
command: |
if [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "update-v8-snapshot-cache-on-develop" && "$CIRCLE_BRANCH" != "feat/protocol" ]]; then
if [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "update-v8-snapshot-cache-on-develop" && "$CIRCLE_BRANCH" != "feat/protocol" && "$CIRCLE_BRANCH" != "tgriesser/feat/protocol-delivery" ]]; then
echo "Not uploading artifacts or posting install comment for this branch."
circleci-agent step halt
fi
@@ -201,6 +205,7 @@ commands:
command: |
source ./scripts/ensure-node.sh
yarn gulp buildProd
yarn gulp syncCloudValidations
- run:
name: Build packages
command: |

View File

@@ -40,6 +40,8 @@ module.exports = {
'cli/types/**',
// these fixtures are supposed to fail linting
'npm/eslint-plugin-dev/test/fixtures/**',
// Cloud generated
'system-tests/lib/validations/**',
],
overrides: [
{

3
.gitattributes vendored
View File

@@ -4,4 +4,5 @@
**/.eslintrc text eol=lf
packages/errors/__snapshot-html__/** linguist-generated=true
packages/errors/__snapshot-html__/** linguist-generated=true
system-tests/lib/validations/** linguist-generated=true

3
.gitignore vendored
View File

@@ -397,3 +397,6 @@ tooling/v8-snapshot/cache/dev-win32
tooling/v8-snapshot/cache/prod-darwin
tooling/v8-snapshot/cache/prod-linux
tooling/v8-snapshot/cache/prod-win32
# Cloud API validations
system-tests/lib/validations

View File

@@ -1,4 +1,12 @@
<!-- See the ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
## 12.13.0
_Released 05/23/2023 (PENDING)_
**Features:**
- Adds a new cloud api that confirms the uploads of artifacts. Addressed in [#26421](https://github.com/cypress-io/cypress/pull/26421).
## 12.12.0
_Released 05/09/2023_

View File

@@ -1,11 +1,21 @@
export const addCaptureProtocolListeners = (Cypress: Cypress.Cypress) => {
Cypress.on('log:added', (log) => {
// TODO: UNIFY-1318 - Race condition in unified runner - we should not need this null check
if (!Cypress.runner) {
return
}
const displayProps = Cypress.runner.getDisplayPropsForLog(log)
Cypress.backend('protocol:command:log:added', displayProps)
})
Cypress.on('log:changed', (log) => {
// TODO: UNIFY-1318 - Race condition in unified runner - we should not need this null check
if (!Cypress.runner) {
return
}
const displayProps = Cypress.runner.getDisplayPropsForLog(log)
Cypress.backend('protocol:command:log:changed', displayProps)

View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Courier+Prime&display=swap" rel="stylesheet">
<style>
body {
font-family: "Courier Prime", Courier, monospace;
padding: 0 1em;
line-height: 1.4;
color: #eee;
background-color: #111;
}
pre {
padding: 0 0;
margin: 0 0;
font-family: "Courier Prime", Courier, monospace;
}
body {
margin: 5px;
padding: 0;
overflow: hidden;
}
pre {
white-space: pre-wrap;
word-break: break-word;
-webkit-font-smoothing: antialiased;
}
</style>
</head>
<body><pre><span style="color:#e05561">Warning: We encountered an error while confirming the upload of artifacts.<span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561">These results will not display artifacts.<span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561">This error will not affect or change the exit code.<span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#de73ff">Error: fail whale<span style="color:#e05561"><span style="color:#e6e6e6"></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span></span>
</pre></body></html>

View File

@@ -36,7 +36,7 @@
</head>
<body><pre><span style="color:#e05561">Recording this run failed. The request was invalid.<span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e5e510">request should follow postRunRequest@2.0.0 schema<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e5e510">Request Validation Error<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e6e6e6">
<span style="color:#e05561">Errors:<span style="color:#e6e6e6">
<span style="color:#e05561"><span style="color:#e6e6e6">

View File

@@ -522,6 +522,16 @@ export const AllCypressErrors = {
${fmt.highlightSecondary(apiErr)}`
},
CLOUD_CANNOT_UPLOAD_ARTIFACTS: (apiErr: Error) => {
return errTemplate`\
Warning: We encountered an error while confirming the upload of artifacts.
These results will not display artifacts.
This error will not affect or change the exit code.
${fmt.highlightSecondary(apiErr)}`
},
CLOUD_CANNOT_CREATE_RUN_OR_INSTANCE: (apiErr: Error) => {
return errTemplate`\
Warning: We encountered an error communicating with our servers.

View File

@@ -431,6 +431,11 @@ describe('visual error templates', () => {
}],
}
},
CLOUD_CANNOT_UPLOAD_ARTIFACTS: () => {
return {
default: [makeErr()],
}
},
CLOUD_STALE_RUN: () => {
return {
default: [{
@@ -600,7 +605,7 @@ describe('visual error templates', () => {
CLOUD_INVALID_RUN_REQUEST: () => {
return {
default: [{
message: 'request should follow postRunRequest@2.0.0 schema',
message: 'Request Validation Error',
errors: [
'data.commit has additional properties',
'data.ci.buildNumber is required',

View File

@@ -1126,6 +1126,7 @@ enum ErrorTypeEnum {
CLOUD_CANNOT_CREATE_RUN_OR_INSTANCE
CLOUD_CANNOT_PROCEED_IN_PARALLEL
CLOUD_CANNOT_PROCEED_IN_SERIAL
CLOUD_CANNOT_UPLOAD_ARTIFACTS
CLOUD_CANNOT_UPLOAD_RESULTS
CLOUD_GRAPHQL_ERROR
CLOUD_INVALID_RUN_REQUEST

View File

@@ -3,7 +3,7 @@ import Debug from 'debug'
import { _connectAsync, _getDelayMsForRetry } from './protocol'
import * as errors from '../errors'
import { create, CriClient } from './cri-client'
import type { ProtocolManager } from '@packages/types'
import type { ProtocolManagerShape } from '@packages/types'
const debug = Debug('cypress:server:browsers:browser-cri-client')
@@ -92,7 +92,7 @@ const retryWithIncreasingDelay = async <T>(retryable: () => Promise<T>, browserN
export class BrowserCriClient {
currentlyAttachedTarget: CriClient | undefined
private constructor (private browserClient: CriClient, private versionInfo, public host: string, public port: number, private browserName: string, private onAsynchronousError: Function, private protocolManager?: ProtocolManager) {}
private constructor (private browserClient: CriClient, private versionInfo, public host: string, public port: number, private browserName: string, private onAsynchronousError: Function, private protocolManager?: ProtocolManagerShape) {}
/**
* Factory method for the browser cri client. Connects to the browser and then returns a chrome remote interface wrapper around the
@@ -104,7 +104,7 @@ export class BrowserCriClient {
* @param onAsynchronousError callback for any cdp fatal errors
* @returns a wrapper around the chrome remote interface that is connected to the browser target
*/
static async create (hosts: string[], port: number, browserName: string, onAsynchronousError: Function, onReconnect?: (client: CriClient) => void, protocolManager?: ProtocolManager): Promise<BrowserCriClient> {
static async create (hosts: string[], port: number, browserName: string, onAsynchronousError: Function, onReconnect?: (client: CriClient) => void, protocolManager?: ProtocolManagerShape): Promise<BrowserCriClient> {
const host = await ensureLiveBrowser(hosts, port, browserName)
return retryWithIncreasingDelay(async () => {

View File

@@ -20,7 +20,7 @@ import type { Browser, BrowserInstance } from './types'
import { BrowserCriClient } from './browser-cri-client'
import type { CriClient } from './cri-client'
import type { Automation } from '../automation'
import type { BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManager, RunModeVideoApi } from '@packages/types'
import type { BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, RunModeVideoApi } from '@packages/types'
import memory from './memory'
const debug = debugModule('cypress:server:browsers:chrome')
@@ -569,7 +569,7 @@ export = {
/**
* Clear instance state for the chrome instance, this is normally called in on kill or on exit.
*/
clearInstanceState (protocolManager?: ProtocolManager) {
clearInstanceState (protocolManager?: ProtocolManagerShape) {
debug('closing remote interface client')
// Do nothing on failure here since we're shutting down anyway
browserCriClient?.close().catch()

View File

@@ -11,7 +11,7 @@ import * as errors from '../errors'
import type { Browser, BrowserInstance } from './types'
import type { BrowserWindow } from 'electron'
import type { Automation } from '../automation'
import type { BrowserLaunchOpts, Preferences, ProtocolManager, RunModeVideoApi } from '@packages/types'
import type { BrowserLaunchOpts, Preferences, ProtocolManagerShape, RunModeVideoApi } from '@packages/types'
import memory from './memory'
import { BrowserCriClient } from './browser-cri-client'
import { getRemoteDebuggingPort } from '../util/electron-app'
@@ -232,7 +232,7 @@ export = {
return this._launch(win, url, automation, electronOptions)
},
async _launch (win: BrowserWindow, url: string, automation: Automation, options: ElectronOpts, videoApi?: RunModeVideoApi, protocolManager?: ProtocolManager) {
async _launch (win: BrowserWindow, url: string, automation: Automation, options: ElectronOpts, videoApi?: RunModeVideoApi, protocolManager?: ProtocolManagerShape) {
if (options.show) {
menu.set({ withInternalDevTools: true })
}

View File

@@ -1,6 +1,7 @@
const _ = require('lodash')
const os = require('os')
const debug = require('debug')('cypress:server:cloud:api')
const debugProtocol = require('debug')('cypress:server:protocol')
const request = require('@cypress/request-promise')
const humanInterval = require('human-interval')
@@ -18,11 +19,15 @@ import * as enc from './encryption'
import getEnvInformationForProjectRoot from './environment'
import type { OptionsWithUrl } from 'request-promise'
import type { ProtocolManager } from '@packages/types'
import type { ProtocolManagerShape } from '@packages/types'
import { fs } from '../util/fs'
const THIRTY_SECONDS = humanInterval('30 seconds')
const SIXTY_SECONDS = humanInterval('60 seconds')
const TWO_MINUTES = humanInterval('2 minutes')
const PUBLIC_KEY_VERSION = '1'
const DELAYS: number[] = process.env.API_RETRY_INTERVALS
? process.env.API_RETRY_INTERVALS.split(',').map(_.toNumber)
: [THIRTY_SECONDS, SIXTY_SECONDS, TWO_MINUTES]
@@ -30,6 +35,7 @@ const DELAYS: number[] = process.env.API_RETRY_INTERVALS
const runnerCapabilities = {
'dynamicSpecsInSerialMode': true,
'skipSpecAction': true,
'protocolMountVersion': 1,
}
let responseCache = {}
@@ -44,7 +50,7 @@ class DecryptionError extends Error {
}
export interface CypressRequestOptions extends OptionsWithUrl {
encrypt?: boolean | 'always'
encrypt?: boolean | 'always' | 'signed'
method: string
cacheable?: boolean
}
@@ -130,7 +136,7 @@ const rp = request.defaults((params: CypressRequestOptions, callback) => {
params.body = jwe
headers['x-cypress-encrypted'] = '1'
headers['x-cypress-encrypted'] = PUBLIC_KEY_VERSION
}
return request[method](params, callback).promise()
@@ -242,7 +248,45 @@ export type CreateRunOptions = {
tags: string[]
testingType: 'e2e' | 'component'
timeout?: number
protocolManager?: ProtocolManager
protocolManager?: ProtocolManagerShape
}
type CreateRunResponse = {
groupId: string
machineId: string
runId: string
tags: string[] | null
runUrl: string
warnings: (Record<string, unknown> & {
code: string
message: string
name: string
})[]
captureProtocolUrl?: string | undefined
}
type UpdateInstanceArtifactsOptions = {
runId: string
instanceId: string
timeout: number | undefined
protocol: {
url: string
success: boolean
fileSize?: number | undefined
error?: string | undefined
} | undefined
screenshots: {
url: string
success: boolean
fileSize?: number | undefined
error?: string | undefined
}[] | undefined
video: {
url: string
success: boolean
fileSize?: number | undefined
error?: string | undefined
} | undefined
}
let preflightResult = {
@@ -332,9 +376,28 @@ module.exports = {
})
})
})
.then(async (result) => {
// TODO(protocol): Get url for the protocol code and pass it down to download
await options.protocolManager?.setupProtocol()
.then(async (result: CreateRunResponse) => {
try {
if (result.captureProtocolUrl || process.env.CYPRESS_LOCAL_PROTOCOL_PATH) {
const script = await this.getCaptureProtocolScript(result.captureProtocolUrl || process.env.CYPRESS_LOCAL_PROTOCOL_PATH)
if (script) {
await options.protocolManager?.setupProtocol(script, result.runId)
}
}
} catch (e) {
options.protocolManager?.sendErrors([
{
args: [result.captureProtocolUrl],
captureMethod: 'getCaptureProtocolScript',
error: {
message: e.message,
stack: e.stack,
name: e.name,
},
},
])
}
return result
})
@@ -411,6 +474,28 @@ module.exports = {
})
},
updateInstanceArtifacts (options: UpdateInstanceArtifactsOptions) {
return retryWithBackoff((attemptIndex) => {
return rp.put({
url: recordRoutes.instanceArtifacts(options.instanceId),
json: true,
timeout: options.timeout ?? SIXTY_SECONDS,
body: {
protocol: options.protocol,
screenshots: options.screenshots,
video: options.video,
},
headers: {
'x-route-version': '1',
'x-cypress-run-id': options.runId,
'x-cypress-request-attempt': attemptIndex,
},
})
.catch(RequestErrors.StatusCodeError, formatResponseBody)
.catch(tagError)
})
},
postInstanceResults (options) {
return retryWithBackoff((attemptIndex) => {
return rp.post({
@@ -530,5 +615,40 @@ module.exports = {
})
},
getCaptureProtocolScript (url: string) {
// TODO(protocol): Ensure this is removed in production
if (process.env.CYPRESS_LOCAL_PROTOCOL_PATH) {
debugProtocol(`Loading protocol via script at local path %s`, process.env.CYPRESS_LOCAL_PROTOCOL_PATH)
return fs.promises.readFile(process.env.CYPRESS_LOCAL_PROTOCOL_PATH, 'utf8')
}
return retryWithBackoff(async (attemptIndex) => {
return rp.get({
url,
headers: {
'x-route-version': '1',
'x-cypress-request-attempt': attemptIndex,
'x-cypress-signature': PUBLIC_KEY_VERSION,
},
agent,
encrypt: 'signed',
resolveWithFullResponse: true,
})
}).then((res) => {
const verified = enc.verifySignature(res.body, res.headers['x-cypress-signature'])
if (!verified) {
debugProtocol(`Unable to verify protocol signature %s`, url)
return null
}
debugProtocol(`Loading protocol via url %s`, url)
return res.body
})
},
retryWithBackoff,
}

View File

@@ -36,6 +36,14 @@ export interface EncryptRequestData {
secretKey: crypto.KeyObject
}
export function verifySignature (body: string, signature: string, publicKey?: crypto.KeyObject) {
const verify = crypto.createVerify('SHA256')
verify.update(body)
return verify.verify(publicKey || getPublicKey(), Buffer.from(signature, 'base64'))
}
// Implements the https://www.rfc-editor.org/rfc/rfc7516 spec
// Functionally equivalent to the behavior for AES-256-GCM encryption
// in the jose library (https://github.com/panva/jose/blob/main/src/jwe/general/encrypt.ts),

View File

@@ -1,71 +1,116 @@
import fs from 'fs-extra'
import { NodeVM } from 'vm2'
import Debug from 'debug'
import type { ProtocolManager, AppCaptureProtocolInterface } from '@packages/types'
import type { ProtocolManagerShape, AppCaptureProtocolInterface, CDPClient, ProtocolError } from '@packages/types'
import Database from 'better-sqlite3'
import path from 'path'
import os from 'os'
import { createGzip } from 'zlib'
import fetch from 'cross-fetch'
import { performance } from 'perf_hooks'
const routes = require('./routes')
const pkg = require('@packages/root')
const { agent } = require('@packages/network')
const debug = Debug('cypress:server:protocol')
const debugVerbose = Debug('cypress-verbose:server:protocol')
const setupProtocol = async (url?: string): Promise<AppCaptureProtocolInterface | undefined> => {
let script: string | undefined
const CAPTURE_ERRORS = !process.env.CYPRESS_LOCAL_PROTOCOL_PATH
const DELETE_DB = !process.env.CYPRESS_LOCAL_PROTOCOL_PATH
// TODO(protocol): We will need to remove this option in production
if (process.env.CYPRESS_LOCAL_PROTOCOL_PATH) {
script = await fs.readFile(process.env.CYPRESS_LOCAL_PROTOCOL_PATH, 'utf8')
} else if (url) {
// TODO(protocol): Download the protocol script from the cloud
export class ProtocolManager implements ProtocolManagerShape {
private _runId?: string
private _instanceId?: string
private _db?: Database.Database
private _dbPath?: string
private _errors: ProtocolError[] = []
private _protocol: AppCaptureProtocolInterface | undefined
get protocolEnabled (): boolean {
return !!this._protocol
}
if (script) {
const cypressProtocolDirectory = path.join(os.tmpdir(), 'cypress', 'protocol')
async setupProtocol (script: string, runId: string) {
debug('setting up protocol via script')
try {
this._runId = runId
if (script) {
const cypressProtocolDirectory = path.join(os.tmpdir(), 'cypress', 'protocol')
// TODO(protocol): Handle any errors here appropriately. Likely, we will want to handle all errors in the initialization process similarly (e.g. downloading, file permissions, etc.)
await fs.ensureDir(cypressProtocolDirectory)
const vm = new NodeVM({
console: 'inherit',
sandbox: {
Debug,
performance: {
now: performance.now,
timeOrigin: performance.timeOrigin,
},
await fs.ensureDir(cypressProtocolDirectory)
const vm = new NodeVM({
console: 'inherit',
sandbox: {
Debug,
performance: {
now: performance.now,
timeOrigin: performance.timeOrigin,
},
},
})
const { AppCaptureProtocol } = vm.run(script)
this._protocol = new AppCaptureProtocol()
}
} catch (error) {
if (CAPTURE_ERRORS) {
this._errors.push({
error,
args: [script],
captureMethod: 'setupProtocol',
})
} else {
throw error
}
}
}
async connectToBrowser (cdpClient: CDPClient) {
// Wrap the cdp client listeners so that we can be notified of any errors that may occur
const newCdpClient: CDPClient = {
...cdpClient,
on: (event, listener) => {
cdpClient.on(event, async (message) => {
try {
await listener(message)
} catch (error) {
if (CAPTURE_ERRORS) {
this._errors.push({ captureMethod: 'cdpClient.on', error, args: [event, message] })
} else {
throw error
}
}
})
},
})
}
const { AppCaptureProtocol } = vm.run(script)
return new AppCaptureProtocol()
}
return
}
class ProtocolManagerImpl implements ProtocolManager {
private protocol: AppCaptureProtocolInterface | undefined
async setupProtocol (url?: string) {
debug('setting up protocol via url %s', url)
this.protocol = await setupProtocol(url)
}
async connectToBrowser (cdpClient) {
await this.protocol?.connectToBrowser(cdpClient)
await this.invokeAsync('connectToBrowser', newCdpClient)
}
addRunnables (runnables) {
this.protocol?.addRunnables(runnables)
this.invokeSync('addRunnables', runnables)
}
beforeSpec (spec: { instanceId: string }) {
if (!this.protocol) {
if (!this._protocol) {
return
}
try {
this._beforeSpec(spec)
} catch (error) {
if (CAPTURE_ERRORS) {
this._errors.push({ captureMethod: 'beforeSpec', error, args: [spec] })
} else {
throw error
}
}
}
private _beforeSpec (spec: { instanceId: string }) {
this._instanceId = spec.instanceId
const cypressProtocolDirectory = path.join(os.tmpdir(), 'cypress', 'protocol')
const dbPath = path.join(cypressProtocolDirectory, `${spec.instanceId}.db`)
@@ -76,40 +121,223 @@ class ProtocolManagerImpl implements ProtocolManager {
verbose: debugVerbose,
})
this.protocol?.beforeSpec(db)
this._db = db
this._dbPath = dbPath
this.invokeSync('beforeSpec', db)
}
afterSpec () {
if (!this.protocol) {
return Promise.resolve()
}
return this.protocol.afterSpec()
async afterSpec () {
await this.invokeAsync('afterSpec')
}
beforeTest (test) {
this.protocol?.beforeTest(test)
beforeTest (test: Record<string, any>) {
this.invokeSync('beforeTest', test)
}
afterTest (test) {
this.protocol?.afterTest(test)
afterTest (test: Record<string, any>) {
this.invokeSync('afterTest', test)
}
commandLogAdded (log: any) {
this.protocol?.commandLogAdded(log)
this.invokeSync('commandLogAdded', log)
}
commandLogChanged (log: any): void {
this.protocol?.commandLogChanged(log)
this.invokeSync('commandLogChanged', log)
}
viewportChanged (input: any): void {
this.protocol?.viewportChanged(input)
this.invokeSync('viewportChanged', input)
}
urlChanged (input: any): void {
this.protocol?.urlChanged(input)
this.invokeSync('urlChanged', input)
}
async uploadCaptureArtifact (uploadUrl: string) {
const dbPath = this._dbPath
if (!this._protocol || !dbPath || !this._db) {
if (this._errors.length) {
await this.sendErrors()
}
return
}
debug(`uploading %s to %s`, dbPath, uploadUrl)
let zippedFileSize = 0
try {
const body = await new Promise((resolve, reject) => {
const gzip = createGzip()
const buffers: Buffer[] = []
gzip.on('data', (args) => {
zippedFileSize += args.length
buffers.push(args)
})
gzip.on('end', () => {
resolve(Buffer.concat(buffers))
})
gzip.on('error', reject)
fs.createReadStream(dbPath).pipe(gzip, { end: true })
})
const res = await fetch(uploadUrl, {
agent,
method: 'PUT',
// @ts-expect-error - this is supported
body,
headers: {
'Content-Encoding': 'gzip',
'Content-Type': 'binary/octet-stream',
'Content-Length': `${zippedFileSize}`,
},
})
if (res.ok) {
return {
fileSize: zippedFileSize,
success: true,
}
}
const err = await res.text()
debug(`error response text: %s`, err)
return {
fileSize: zippedFileSize,
success: false,
error: err,
}
} catch (e) {
if (CAPTURE_ERRORS) {
this._errors.push({
error: e,
captureMethod: 'uploadCaptureArtifact',
})
} else {
throw e
}
return {
fileSize: zippedFileSize,
success: false,
error: e,
}
} finally {
await Promise.all([
this.sendErrors(),
DELETE_DB ? fs.unlink(dbPath).catch((e) => {
debug(`Error unlinking db %o`, e)
}) : Promise.resolve(),
])
// Reset errors after they have been sent
this._errors = []
}
}
async sendErrors (protocolErrors: ProtocolError[] = this._errors) {
if (protocolErrors.length === 0) {
return
}
try {
const body = JSON.stringify({
runId: this._runId,
instanceId: this._instanceId,
errors: protocolErrors.map((e) => {
return {
name: e.error.name ?? `Unknown name`,
stack: e.error.stack ?? `Unknown stack`,
message: e.error.message ?? `Unknown message`,
captureMethod: e.captureMethod,
args: e.args ? this.stringify(e.args) : undefined,
}
}),
})
await fetch(routes.apiRoutes.captureProtocolErrors() as string, {
// @ts-expect-error - this is supported
agent,
method: 'POST',
body,
headers: {
'Content-Type': 'application/json',
'x-cypress-version': pkg.version,
'x-os-name': os.platform(),
'x-arch': os.arch(),
},
})
} catch (e) {
debug(`Error calling ProtocolManager.sendErrors: %o, original errors %o`, e, protocolErrors)
}
}
/**
* Abstracts invoking a synchronous method on the AppCaptureProtocol instance, so we can handle
* errors in a uniform way
*/
private invokeSync<K extends ProtocolSyncMethods> (method: K, ...args: Parameters<AppCaptureProtocolInterface[K]>) {
if (!this._protocol) {
return
}
try {
// @ts-expect-error - TS not associating the method & args properly, even though we know it's correct
this._protocol[method].apply(this._protocol, args)
} catch (error) {
if (CAPTURE_ERRORS) {
this._errors.push({ captureMethod: method, error, args })
} else {
throw error
}
}
}
/**
* Abstracts invoking a synchronous method on the AppCaptureProtocol instance, so we can handle
* errors in a uniform way
*/
private async invokeAsync <K extends ProtocolAsyncMethods> (method: K, ...args: Parameters<AppCaptureProtocolInterface[K]>) {
if (!this._protocol) {
return
}
try {
// @ts-expect-error - TS not associating the method & args properly, even though we know it's correct
await this._protocol[method].apply(this._protocol, args)
} catch (error) {
if (CAPTURE_ERRORS) {
this._errors.push({ captureMethod: method, error, args })
} else {
throw error
}
}
}
private stringify (val: any) {
try {
return JSON.stringify(val)
} catch (e) {
return `Unserializable ${typeof val}`
}
}
}
export default ProtocolManagerImpl
// Helper types for invokeSync / invokeAsync
type ProtocolSyncMethods = {
[K in keyof AppCaptureProtocolInterface]: ReturnType<AppCaptureProtocolInterface[K]> extends void ? K : never
}[keyof AppCaptureProtocolInterface]
type ProtocolAsyncMethods = {
[K in keyof AppCaptureProtocolInterface]: ReturnType<AppCaptureProtocolInterface[K]> extends Promise<any> ? K : never
}[keyof AppCaptureProtocolInterface]
export default ProtocolManager

View File

@@ -13,6 +13,8 @@ const CLOUD_ENDPOINTS = {
instanceTests: 'instances/:id/tests',
instanceResults: 'instances/:id/results',
instanceStdout: 'instances/:id/stdout',
instanceArtifacts: 'instances/:id/artifacts',
captureProtocolErrors: 'capture-protocol/errors',
exceptions: 'exceptions',
telemetry: 'telemetry',
} as const

View File

@@ -23,6 +23,7 @@ const terminal = require('../util/terminal')
const ciProvider = require('../util/ci_provider')
const testsUtils = require('../util/tests_utils')
const specWriter = require('../util/spec_writer')
const { fs } = require('../util/fs')
// dont yell about any errors either
const runningInternalTests = () => {
@@ -108,9 +109,37 @@ const getSpecRelativePath = (spec) => {
}
const uploadArtifacts = (options = {}) => {
const { video, screenshots, videoUploadUrl, shouldUploadVideo, screenshotUploadUrls, quiet } = options
const { protocolManager, video, screenshots, videoUploadUrl, captureUploadUrl, shouldUploadVideo, screenshotUploadUrls, quiet } = options
const uploads = []
const uploadReport = {
protocol: undefined,
screenshots: [],
video: undefined,
}
const attachMetadataToUploadReport = async (key, pathToFile, statFile, initialUploadMetadata) => {
const uploadMetadata = {
...initialUploadMetadata,
}
if (statFile) {
try {
const { size } = await fs.statAsync(pathToFile)
uploadMetadata.fileSize = size
} catch (err) {
debug('failed to get stats for upload artifact %o', {
file: pathToFile,
stack: err.stack,
})
}
}
uploadReport[key] = Array.isArray(uploadReport[key]) ?
[...uploadReport[key], uploadMetadata] : uploadMetadata
}
let count = 0
const nums = () => {
@@ -119,17 +148,36 @@ const uploadArtifacts = (options = {}) => {
return chalk.gray(`(${count}/${uploads.length})`)
}
const send = (pathToFile, url) => {
const success = () => {
const success = (pathToFile, url, uploadReportOptions) => {
const { statFile, key } = uploadReportOptions
return async (res) => {
await attachMetadataToUploadReport(key, pathToFile, statFile, {
success: true,
url,
...res,
})
if (!quiet) {
// eslint-disable-next-line no-console
return console.log(` - Done Uploading ${nums()}`, chalk.blue(pathToFile))
}
}
}
const fail = (pathToFile, url, uploadReportOptions) => {
const { statFile, key } = uploadReportOptions
return async (err) => {
await attachMetadataToUploadReport(key, pathToFile, statFile, {
success: false,
url,
error: err.message,
})
const fail = (err) => {
debug('failed to upload artifact %o', {
file: pathToFile,
url,
stack: err.stack,
})
@@ -138,26 +186,36 @@ const uploadArtifacts = (options = {}) => {
return console.log(` - Failed Uploading ${nums()}`, chalk.red(pathToFile))
}
}
}
const send = (pathToFile, url, reportKey) => {
return uploads.push(
upload.send(pathToFile, url)
.then(success)
.catch(fail),
.then(success(pathToFile, url, { key: reportKey, statFile: true }))
.catch(fail(pathToFile, url, { key: reportKey, statFile: true })),
)
}
if (videoUploadUrl && shouldUploadVideo) {
send(video, videoUploadUrl)
send(video, videoUploadUrl, 'video')
}
if (screenshotUploadUrls) {
screenshotUploadUrls.forEach((obj) => {
const screenshot = _.find(screenshots, { screenshotId: obj.screenshotId })
return send(screenshot.path, obj.uploadUrl)
return send(screenshot.path, obj.uploadUrl, 'screenshots')
})
}
if (captureUploadUrl && protocolManager) {
uploads.push(
protocolManager.uploadCaptureArtifact(captureUploadUrl)
.then(success('Test Replay', captureUploadUrl, { key: 'protocol', statFile: false }))
.catch(fail('Test Replay', captureUploadUrl, { key: 'protocol', statFile: false })),
)
}
if (!uploads.length && !quiet) {
// eslint-disable-next-line no-console
console.log(' - Nothing to Upload')
@@ -170,6 +228,25 @@ const uploadArtifacts = (options = {}) => {
return exception.create(err)
})
.finally(() => {
api.updateInstanceArtifacts({
runId: options.runId,
instanceId: options.instanceId,
...uploadReport,
})
.catch((err) => {
debug('failed updating artifact status %o', {
stack: err.stack,
})
errors.warning('CLOUD_CANNOT_UPLOAD_ARTIFACTS', err)
// don't log exceptions if we have a 503 status code
if (err.statusCode !== 503) {
return exception.create(err)
}
})
})
}
const updateInstanceStdout = (options = {}) => {
@@ -720,12 +797,16 @@ const createRunAndRecordSpecs = (options = {}) => {
}
const { video, shouldUploadVideo, screenshots } = results
const { videoUploadUrl, screenshotUploadUrls } = resp
const { videoUploadUrl, captureUploadUrl, screenshotUploadUrls } = resp
return uploadArtifacts({
runId,
instanceId,
video,
screenshots,
videoUploadUrl,
captureUploadUrl,
protocolManager,
shouldUploadVideo,
screenshotUploadUrls,
quiet,

View File

@@ -25,7 +25,7 @@ import type { SpecWithRelativeRoot, SpecFile, TestingType, OpenProjectLaunchOpts
import type { Cfg } from '../project-base'
import type { Browser } from '../browsers/types'
import * as printResults from '../util/print-run'
import ProtocolManager from '../cloud/protocol'
import { ProtocolManager } from '../cloud/protocol'
import { telemetry } from '@packages/telemetry'
type SetScreenshotMetadata = (data: TakeScreenshotProps) => void

View File

@@ -20,7 +20,7 @@ import { SocketE2E } from './socket-e2e'
import { ensureProp } from './util/class-helpers'
import system from './util/system'
import type { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType, VideoRecording, ProtocolManager } from '@packages/types'
import type { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType, VideoRecording, ProtocolManagerShape } from '@packages/types'
import { DataContext, getCtx } from '@packages/data-context'
import { createHmac } from 'crypto'
@@ -143,7 +143,7 @@ export class ProjectBase<TServer extends Server> extends EE {
: new ServerCt() as TServer
}
async open (protocolManager?: ProtocolManager) {
async open (protocolManager?: ProtocolManagerShape) {
debug('opening project instance %s', this.projectRoot)
debug('project open options %o', this.options)

View File

@@ -29,7 +29,7 @@ import type { Browser } from '@packages/server/lib/browsers/types'
import { InitializeRoutes, createCommonRoutes } from './routes'
import { createRoutesE2E } from './routes-e2e'
import { createRoutesCT } from './routes-ct'
import type { FoundSpec, ProtocolManager } from '@packages/types'
import type { FoundSpec, ProtocolManagerShape } from '@packages/types'
import type { Server as WebSocketServer } from 'ws'
import { RemoteStates } from './remote_states'
import { cookieJar, SerializableAutomationCookie } from './util/cookies'
@@ -109,7 +109,7 @@ export interface OpenServerOptions {
getCurrentBrowser: () => Browser
getSpec: () => FoundSpec | null
shouldCorrelatePreRequests: () => boolean
protocolManager?: ProtocolManager
protocolManager?: ProtocolManagerShape
}
export abstract class ServerBase<TSocket extends SocketE2E | SocketCt> {

View File

@@ -28,7 +28,7 @@ import { telemetry } from '@packages/telemetry'
// eslint-disable-next-line no-duplicate-imports
import type { Socket } from '@packages/socket'
import type { RunState, CachedTestState, ProtocolManager } from '@packages/types'
import type { RunState, CachedTestState, ProtocolManagerShape } from '@packages/types'
import { cors } from '@packages/network'
import memory from './browsers/memory'
@@ -52,10 +52,10 @@ export class SocketBase {
protected supportsRunEvents: boolean
protected ended: boolean
protected _io?: socketIo.SocketIOServer
protected protocolManager?: ProtocolManager
protected protocolManager?: ProtocolManagerShape
localBus: EventEmitter
constructor (config: Record<string, any>, protocolManager?: ProtocolManager) {
constructor (config: Record<string, any>, protocolManager?: ProtocolManagerShape) {
this.inRunMode = config.isTextTerminal
this.supportsRunEvents = config.isTextTerminal || config.experimentalInteractiveRunEvents
this.ended = false

View File

@@ -5,14 +5,14 @@ import dfd from 'p-defer'
import type { Socket } from '@packages/socket'
import type { DestroyableHttpServer } from '@packages/server/lib/util/server_destroy'
import assert from 'assert'
import type { ProtocolManager } from '@packages/types'
import type { ProtocolManagerShape } from '@packages/types'
const debug = Debug('cypress:server:socket-ct')
export class SocketCt extends SocketBase {
#destroyAutPromise?: dfd.DeferredPromise<void>
constructor (config: Record<string, any>, protocolManager?: ProtocolManager) {
constructor (config: Record<string, any>, protocolManager?: ProtocolManagerShape) {
super(config, protocolManager)
// should we use this option at all for component testing 😕?

View File

@@ -4,7 +4,7 @@ import { SocketBase } from './socket-base'
import { fs } from './util/fs'
import type { DestroyableHttpServer } from './util/server_destroy'
import * as studio from './studio'
import type { FoundSpec, ProtocolManager } from '@packages/types'
import type { FoundSpec, ProtocolManagerShape } from '@packages/types'
const debug = Debug('cypress:server:socket-e2e')
@@ -15,7 +15,7 @@ const isSpecialSpec = (name) => {
export class SocketE2E extends SocketBase {
private testFilePath: string | null
constructor (config: Record<string, any>, protocolManager?: ProtocolManager) {
constructor (config: Record<string, any>, protocolManager?: ProtocolManagerShape) {
super(config, protocolManager)
this.testFilePath = null

View File

@@ -4,7 +4,6 @@
"private": true,
"main": "index.js",
"scripts": {
"build": "electron-rebuild -o better-sqlite3",
"build-prod": "tsc || echo 'built, with type errors'",
"check-ts": "tsc --noEmit && yarn -s tslint",
"clean-deps": "rimraf node_modules",
@@ -13,6 +12,7 @@
"docker": "cd ../.. && WORKING_DIR=/packages/server ./scripts/run-docker-local.sh",
"tslint": "tslint --config ../ts/tslint.json --project .",
"postinstall": "patch-package",
"rebuild-better-sqlite3": "electron-rebuild -o better-sqlite3",
"repl": "node repl.js",
"start": "node ../../scripts/cypress open --dev --global",
"test": "node ./test/scripts/run.js",
@@ -138,7 +138,6 @@
"@babel/core": "7.9.0",
"@babel/preset-env": "7.9.0",
"@cypress/debugging-proxy": "2.0.1",
"@cypress/json-schemas": "5.39.0",
"@cypress/sinon-chai": "2.9.1",
"@cypress/webpack-dev-server": "0.0.0-development",
"@electron/rebuild": "3.2.10",
@@ -166,6 +165,7 @@
"@types/http-proxy": "1.17.4",
"@types/mime": "3.0.1",
"@types/node": "14.14.31",
"@types/request-promise": "^4.1.48",
"babel-loader": "8.1.0",
"chai": "1.10.0",
"chai-as-promised": "7.1.1",
@@ -175,6 +175,7 @@
"cross-env": "6.0.3",
"devtools-protocol": "0.0.1124027",
"eol": "0.9.1",
"esbuild": "^0.15.3",
"eventsource": "2.0.2",
"https-proxy-agent": "3.0.1",
"mocha": "7.1.0",
@@ -223,4 +224,4 @@
"fsevents": "^2",
"registry-js": "1.15.0"
}
}
}

View File

@@ -1,33 +0,0 @@
/* global Debug */
const AppCaptureProtocol = class {
constructor () {
this.Debug = Debug
this.connectToBrowser = this.connectToBrowser.bind(this)
this.addRunnables = this.addRunnables.bind(this)
this.beforeSpec = this.beforeSpec.bind(this)
this.afterSpec = this.afterSpec.bind(this)
this.beforeTest = this.beforeTest.bind(this)
this.commandLogAdded = this.commandLogAdded.bind(this)
this.commandLogChanged = this.commandLogChanged.bind(this)
this.viewportChanged = this.viewportChanged.bind(this)
this.urlChanged = this.urlChanged.bind(this)
}
connectToBrowser (cdpClient) {
return Promise.resolve()
}
addRunnables (runnables) {}
beforeSpec (spec) {}
afterSpec (spec) {}
beforeTest (test) {}
commandLogAdded (log) {}
commandLogChanged (log) {}
viewportChanged (input) {}
urlChanged (input) {}
}
module.exports = {
AppCaptureProtocol,
}

View File

@@ -0,0 +1,41 @@
import type { ProtocolManagerShape } from '@packages/types'
declare const Debug: (namespace) => import('debug').IDebugger
declare const performance: {
now(): number
timeOrigin: number
}
export class AppCaptureProtocol implements ProtocolManagerShape {
private Debug: typeof Debug
private performance: typeof performance
constructor () {
this.Debug = Debug
this.performance = performance
}
setupProtocol = (script, runId) => {
return Promise.resolve()
}
connectToBrowser = (cdpClient) => {
return Promise.resolve()
}
addRunnables = (runnables) => {}
beforeSpec = (spec) => {}
afterSpec = () => {
return Promise.resolve()
}
beforeTest = (test) => {}
commandLogAdded = (log) => {}
commandLogChanged = (log) => {}
viewportChanged = (input) => {}
urlChanged = (input) => {}
sendErrors (errors) {
return Promise.resolve()
}
uploadCaptureArtifact (uploadUrl) {
return Promise.resolve()
}
afterTest (test): void {}
}

View File

@@ -4,7 +4,7 @@ import { expect, proxyquire, sinon } from '../../spec_helper'
import * as protocol from '../../../lib/browsers/protocol'
import { stripAnsi } from '@packages/errors'
import net from 'net'
import { ProtocolManager } from '@packages/types'
import { ProtocolManagerShape } from '@packages/types'
const HOST = '127.0.0.1'
const PORT = 50505
@@ -23,7 +23,7 @@ describe('lib/browsers/cri-client', function () {
Version: sinon.SinonStub
}
let onError: sinon.SinonStub
let getClient: (protocolManager?: ProtocolManager) => ReturnType<typeof BrowserCriClient.create>
let getClient: (protocolManager?: ProtocolManagerShape) => ReturnType<typeof BrowserCriClient.create>
beforeEach(function () {
sinon.stub(protocol, '_connectAsync')

View File

@@ -28,6 +28,11 @@ const AUTH_URLS = {
'dashboardLogoutUrl': 'http://localhost:3000/logout',
}
const {
CYPRESS_LOCAL_PROTOCOL_STUB,
CYPRESS_LOCAL_PROTOCOL_STUB_SIGN,
} = require('@tooling/system-tests/lib/protocolStubResponse')
const makeError = (details = {}) => {
return _.extend(new Error(details.message || 'Some error'), details)
}
@@ -527,7 +532,8 @@ describe('lib/cloud/api', () => {
beforeEach(function () {
this.protocolManager = {
setupProtocol: sinon.stub(),
},
}
this.buildProps = {
group: null,
parallel: null,
@@ -550,6 +556,7 @@ describe('lib/cloud/api', () => {
},
specs: ['foo.js', 'bar.js'],
runnerCapabilities: {
'protocolMountVersion': 1,
'dynamicSpecsInSerialMode': true,
'skipSpecAction': true,
},
@@ -557,6 +564,12 @@ describe('lib/cloud/api', () => {
})
it('POST /runs + returns runId', function () {
nock(API_BASEURL)
.get('/capture-protocol/script/protocolStub.js')
.reply(200, CYPRESS_LOCAL_PROTOCOL_STUB, {
'x-cypress-signature': CYPRESS_LOCAL_PROTOCOL_STUB_SIGN,
})
nock(API_BASEURL)
.matchHeader('x-route-version', '4')
.matchHeader('x-os-name', 'linux')
@@ -564,6 +577,7 @@ describe('lib/cloud/api', () => {
.post('/runs', this.buildProps)
.reply(200, {
runId: 'new-run-id-123',
captureProtocolUrl: 'http://localhost:1234/capture-protocol/script/protocolStub.js',
})
return api.createRun({
@@ -571,7 +585,11 @@ describe('lib/cloud/api', () => {
protocolManager: this.protocolManager,
})
.then((ret) => {
expect(ret).to.deep.eq({ runId: 'new-run-id-123' })
expect(ret).to.deep.eq({
runId: 'new-run-id-123',
captureProtocolUrl: 'http://localhost:1234/capture-protocol/script/protocolStub.js',
})
expect(this.protocolManager.setupProtocol).to.be.called
})
})
@@ -581,6 +599,12 @@ describe('lib/cloud/api', () => {
sinon.restore()
sinon.stub(os, 'platform').returns('linux')
nock(API_BASEURL)
.get('/capture-protocol/script/protocolStub.js')
.reply(200, CYPRESS_LOCAL_PROTOCOL_STUB, {
'x-cypress-signature': CYPRESS_LOCAL_PROTOCOL_STUB_SIGN,
})
preflightNock(API_BASEURL)
.reply(200, decryptReqBodyAndRespond({
resBody: {
@@ -598,6 +622,7 @@ describe('lib/cloud/api', () => {
reqBody: this.buildProps,
resBody: {
runId: 'new-run-id-123',
captureProtocolUrl: 'http://localhost:1234/capture-protocol/script/protocolStub.js',
},
}))
}))
@@ -607,11 +632,46 @@ describe('lib/cloud/api', () => {
protocolManager: this.protocolManager,
})
.then((ret) => {
expect(ret).to.deep.eq({ runId: 'new-run-id-123' })
expect(ret).to.deep.eq({
runId: 'new-run-id-123',
captureProtocolUrl: 'http://localhost:1234/capture-protocol/script/protocolStub.js',
})
expect(this.protocolManager.setupProtocol).to.be.called
})
})
it('POST /runs does not call setupProtocol with invalid signature', function () {
nock(API_BASEURL)
.get('/capture-protocol/script/protocolStub.js')
.reply(200, CYPRESS_LOCAL_PROTOCOL_STUB, {
'x-cypress-signature': 'invalid',
})
nock(API_BASEURL)
.matchHeader('x-route-version', '4')
.matchHeader('x-os-name', 'linux')
.matchHeader('x-cypress-version', pkg.version)
.post('/runs', this.buildProps)
.reply(200, {
runId: 'new-run-id-123',
captureProtocolUrl: 'http://localhost:1234/capture-protocol/script/protocolStub.js',
})
return api.createRun({
...this.buildProps,
protocolManager: this.protocolManager,
})
.then((ret) => {
expect(ret).to.deep.eq({
runId: 'new-run-id-123',
captureProtocolUrl: 'http://localhost:1234/capture-protocol/script/protocolStub.js',
})
expect(this.protocolManager.setupProtocol).not.to.be.called
})
})
it('POST /runs failure formatting', function () {
nock(API_BASEURL)
.matchHeader('x-route-version', '4')
@@ -1400,4 +1460,43 @@ describe('lib/cloud/api', () => {
})
})
})
context('.updateInstanceArtifacts', () => {
beforeEach(function () {
this.artifactProps = {
runId: 'run-id-123',
instanceId: 'instance-id-123',
screenshots: [{
url: `http://localhost:1234/screenshots/upload/instance-id-123/a877e957-f90e-4ba4-9fa8-569812f148c4.png`,
uploadSize: 100,
}],
video: {
url: `http://localhost:1234/video/upload/instance-id-123/f17754c4-581d-4e08-a922-1fa402f9c6de.mp4`,
uploadSize: 122,
},
protocol: {
url: `http://localhost:1234/protocol/upload/instance-id-123/2ed89c81-e7eb-4b97-8a6e-185c410471df.db`,
uploadSize: 123,
},
}
// TODO: add schema validation
})
it('PUTs/instances/:id/artifacts', function () {
nock(API_BASEURL)
.matchHeader('x-route-version', '1')
.matchHeader('x-cypress-run-id', this.artifactProps.runId)
.matchHeader('x-cypress-request-attempt', '0')
.matchHeader('x-os-name', 'linux')
.matchHeader('x-cypress-version', pkg.version)
.put('/instances/instance-id-123/artifacts', {
protocol: this.artifactProps.protocol,
screenshots: this.artifactProps.screenshots,
video: this.artifactProps.video,
})
.reply(200)
return api.updateInstanceArtifacts(this.artifactProps)
})
})
})

View File

@@ -1,46 +1,85 @@
import { proxyquire } from '../../spec_helper'
import path from 'path'
import os from 'os'
import type { AppCaptureProtocolInterface, ProtocolManager as ProtocolManagerInterface } from '@packages/types'
import type { AppCaptureProtocolInterface, ProtocolManagerShape } from '@packages/types'
import { expect } from 'chai'
import { EventEmitter } from 'stream'
import esbuild from 'esbuild'
class TestClient extends EventEmitter {
send: sinon.SinonStub = sinon.stub()
}
const mockDb = sinon.stub()
const mockDatabase = sinon.stub().returns(mockDb)
const { default: ProtocolManager } = proxyquire('../lib/cloud/protocol', {
const { ProtocolManager } = proxyquire('../lib/cloud/protocol', {
'better-sqlite3': mockDatabase,
}) as typeof import('@packages/server/lib/cloud/protocol')
const { outputFiles: [{ contents: stubProtocolRaw }] } = esbuild.buildSync({
entryPoints: [path.join(__dirname, '..', '..', 'support', 'fixtures', 'cloud', 'protocol', 'test-protocol.ts')],
bundle: true,
format: 'cjs',
write: false,
})
const stubProtocol = new TextDecoder('utf-8').decode(stubProtocolRaw)
describe('lib/cloud/protocol', () => {
let protocolManager: ProtocolManagerInterface
let protocolManager: ProtocolManagerShape
let protocol: AppCaptureProtocolInterface
beforeEach(async () => {
process.env.CYPRESS_LOCAL_PROTOCOL_PATH = path.join(__dirname, '..', '..', 'support', 'fixtures', 'cloud', 'protocol', 'test-protocol.js')
protocolManager = new ProtocolManager()
await protocolManager.setupProtocol()
await protocolManager.setupProtocol(stubProtocol, '1')
protocol = (protocolManager as any).protocol
})
afterEach(() => {
delete process.env.CYPRESS_LOCAL_PROTOCOL_PATH
protocol = (protocolManager as any)._protocol
})
it('should be able to setup the protocol', () => {
expect(protocol).not.to.be.undefined
expect((protocol as any).Debug).not.to.be.undefined
expect((protocol as any).performance).not.to.be.undefined
expect((protocol as any).performance.now).not.to.be.undefined
expect((protocol as any).performance.timeOrigin).not.to.be.undefined
})
it('should be able to connect to the browser', async () => {
const mockCdpClient = sinon.stub()
const mockCdpClient = new TestClient()
sinon.stub(protocol, 'connectToBrowser').resolves()
const connectToBrowserStub = sinon.stub(protocol, 'connectToBrowser').resolves()
await protocolManager.connectToBrowser(mockCdpClient as any)
expect(protocol.connectToBrowser).to.be.calledWith(mockCdpClient)
const newCdpClient = connectToBrowserStub.getCall(0).args[0]
newCdpClient.send('Page.enable')
expect(mockCdpClient.send).to.be.calledWith('Page.enable')
const mockSuccess = sinon.stub()
newCdpClient.on('Page.loadEventFired', mockSuccess)
const mockThrows = sinon.stub().throws()
newCdpClient.on('Page.backForwardCacheNotUsed', mockThrows)
mockCdpClient.emit('Page.loadEventFired')
expect(mockSuccess).to.be.called
expect((protocolManager as any)._errors).to.be.empty
mockCdpClient.emit('Page.backForwardCacheNotUsed', { test: 'test1' })
expect(mockThrows).to.be.called
expect((protocolManager as any)._errors).to.have.length(1)
expect((protocolManager as any)._errors[0].captureMethod).to.equal('cdpClient.on')
expect((protocolManager as any)._errors[0].args).to.deep.equal([
'Page.backForwardCacheNotUsed',
{
test: 'test1',
},
])
})
it('should be able to initialize a new spec', () => {

View File

@@ -13,29 +13,31 @@ export interface CDPClient {
// TODO(protocol): This is basic for now but will evolve as we progress with the protocol work
export interface AppCaptureProtocolInterface {
export interface AppCaptureProtocolCommon {
addRunnables (runnables: any): void
connectToBrowser (cdpClient: CDPClient): Promise<void>
beforeSpec (db: Database): void
afterSpec (): Promise<void>
beforeTest(test: Record<string, any>): void
afterTest(test: Record<string, any>): void
commandLogAdded (log: any): void
commandLogChanged (log: any): void
viewportChanged (input: any): void
urlChanged (input: any): void
beforeTest(test: Record<string, any>): void
afterTest(test: Record<string, any>): void
afterSpec (): Promise<void>
connectToBrowser (cdpClient: CDPClient): Promise<void>
}
export interface ProtocolManager {
setupProtocol(url?: string): Promise<void>
addRunnables (runnables: any): void
connectToBrowser (cdpClient: CDPClient): Promise<void>
beforeSpec (spec: { instanceId: string}): void
afterSpec (): Promise<void>
beforeTest(test: Record<string, any>): void
afterTest(test: Record<string, any>): void
commandLogAdded (log: any): void
commandLogChanged (log: any): void
viewportChanged (input: any): void
urlChanged (input: any): void
export interface AppCaptureProtocolInterface extends AppCaptureProtocolCommon {
beforeSpec (db: Database): void
}
export interface ProtocolError {
args?: any
error: Error
captureMethod: keyof AppCaptureProtocolInterface | 'setupProtocol' | 'uploadCaptureArtifact' | 'getCaptureProtocolScript' | 'cdpClient.on'
}
export interface ProtocolManagerShape extends AppCaptureProtocolCommon {
setupProtocol(script: string, runId: string): Promise<void>
beforeSpec (spec: { instanceId: string}): void
sendErrors (errors: ProtocolError[]): Promise<void>
uploadCaptureArtifact(uploadUrl: string): Promise<{ fileSize: number, success: boolean, error?: string } | void>
}

View File

@@ -2,7 +2,7 @@ import type { FoundBrowser } from './browser'
import type { ReceivedCypressOptions } from './config'
import type { PlatformName } from './platform'
import type { RunModeVideoApi } from './video'
import type { ProtocolManager } from './protocol'
import type { ProtocolManagerShape } from './protocol'
export type OpenProjectLaunchOpts = {
projectRoot: string
@@ -11,7 +11,7 @@ export type OpenProjectLaunchOpts = {
videoApi?: RunModeVideoApi
onWarning: (err: Error) => void
onError: (err: Error) => void
protocolManager?: ProtocolManager
protocolManager?: ProtocolManagerShape
}
export type BrowserLaunchOpts = {
@@ -23,7 +23,7 @@ export type BrowserLaunchOpts = {
onBrowserClose?: (...args: unknown[]) => void
onBrowserOpen?: (...args: unknown[]) => void
relaunchBrowser?: () => Promise<any>
protocolManager?: ProtocolManager
protocolManager?: ProtocolManagerShape
} & Partial<OpenProjectLaunchOpts> // TODO: remove the `Partial` here by making it impossible for openProject.launch to be called w/o OpenProjectLaunchOpts
& Pick<ReceivedCypressOptions, 'userAgent' | 'proxyUrl' | 'socketIoRoute' | 'chromeWebSecurity' | 'downloadsFolder' | 'experimentalModifyObstructiveThirdPartyCode' | 'experimentalWebKitSupport'>
@@ -94,7 +94,7 @@ export interface OpenProjectLaunchOptions {
onError?: (err: Error) => void
// Manager used to communicate with the Cloud protocol
protocolManager?: ProtocolManager
protocolManager?: ProtocolManagerShape
[key: string]: any
}

View File

@@ -122,7 +122,7 @@ export async function buildCypressApp (options: BuildCypressAppOpts) {
...packageJsonContents,
scripts: {
// After the `yarn --production` install, we need to patch packages and trigger a server build to rebuild native bindings for `better-sqlite3`
postinstall: 'patch-package && yarn workspace @packages/server build',
postinstall: 'patch-package && yarn workspace @packages/server rebuild-better-sqlite3',
},
}, { spaces: 2 })

View File

@@ -20,6 +20,7 @@ import { execSync } from 'child_process'
import { webpackReporter, webpackRunner } from './tasks/gulpWebpack'
import { e2eTestScaffold, e2eTestScaffoldWatch } from './tasks/gulpE2ETestScaffold'
import dedent from 'dedent'
import { ensureCloudValidations, syncCloudValidations } from './tasks/gulpSyncValidations'
if (process.env.CYPRESS_INTERNAL_VITE_DEV) {
process.env.CYPRESS_INTERNAL_VITE_APP_PORT ??= '3333'
@@ -64,6 +65,7 @@ gulp.task(
makePathMap,
// Before dev, fetch the latest "remote" schema from Cypress Cloud
syncRemoteGraphQL,
syncCloudValidations,
gulp.parallel(
viteClean,
e2eTestScaffoldWatch,
@@ -264,6 +266,8 @@ gulp.task(makePackage)
* here for debugging, e.g. `yarn gulp syncRemoteGraphQL`
*------------------------------------------------------------------------**/
gulp.task(ensureCloudValidations)
gulp.task(syncCloudValidations)
gulp.task(syncRemoteGraphQL)
gulp.task(generateFrontendSchema)
gulp.task(makePathMap)

View File

@@ -0,0 +1,40 @@
import fs from 'fs-extra'
import path from 'path'
import crossFetch from 'cross-fetch'
const INTERNAL_CLOUD_ENV = process.env.CYPRESS_INTERNAL_ENV || 'production'
const CY_CLOUD_VALIDATION_BASE = {
test: 'https://api.cypress.io',
production: 'https://api.cypress.io',
staging: 'https://api-staging.cypress.io',
development: 'http://localhost:1234',
}
const VALIDATION_BASE = CY_CLOUD_VALIDATION_BASE[INTERNAL_CLOUD_ENV]
const OUTPUT_FOLDER = path.join(__dirname, '../../../system-tests/lib/validations')
export async function syncCloudValidations () {
const [validationsResponse, typesResponse] = await Promise.all([
crossFetch(`${VALIDATION_BASE}/cypress-app/validations`),
crossFetch(`${VALIDATION_BASE}/cypress-app/validations/types`),
])
const [validations, validationsTypes] = await Promise.all([
validationsResponse.text(),
typesResponse.text(),
])
await fs.ensureDir(OUTPUT_FOLDER)
await Promise.all([
fs.promises.writeFile(path.join(OUTPUT_FOLDER, 'cloudValidations.js'), validations),
fs.promises.writeFile(path.join(OUTPUT_FOLDER, 'cloudValidations.d.ts'), validationsTypes),
])
}
export async function ensureCloudValidations () {
if (!fs.existsSync(path.join(OUTPUT_FOLDER, 'cloudValidations.js')) || !fs.existsSync(path.join(OUTPUT_FOLDER, 'cloudValidations.d.ts'))) {
await syncCloudValidations()
}
}

View File

@@ -3,8 +3,8 @@ const { execSync } = require('child_process')
const executionEnv = process.env.CI ? 'ci' : 'local'
const postInstallCommands = {
local: 'patch-package && yarn-deduplicate --strategy=highest && yarn clean && gulp postinstall && yarn build && yarn build-v8-snapshot-dev',
ci: 'patch-package && yarn clean && gulp postinstall',
local: 'patch-package && yarn-deduplicate --strategy=highest && yarn clean && gulp postinstall && yarn workspace @packages/server rebuild-better-sqlite3 && yarn build && yarn build-v8-snapshot-dev',
ci: 'patch-package && yarn clean && gulp postinstall && yarn workspace @packages/server rebuild-better-sqlite3',
}
execSync(postInstallCommands[executionEnv], {

View File

@@ -1073,8 +1073,8 @@ Details:
{
"code": "OUT_OF_TIME",
"name": "OutOfTime",
"hadTime": 1000,
"name": "OutOfTime",
"spentTime": 999
}
@@ -2258,13 +2258,14 @@ exports['e2e record quiet mode respects quiet mode 1'] = `
exports['e2e record api interaction errors create run 412 errors and exits when request schema is invalid 1'] = `
Recording this run failed. The request was invalid.
request should follow postRunRequest@2.0.0 schema
Request Validation Error
Errors:
[
"data has additional properties: group, parallel, ciBuildId, tags, testingType, runnerCapabilities",
"data.platform is the wrong type"
"ci is the wrong type, saw null, expected object",
"commit is the wrong type, saw null, expected object",
"platform is the wrong type, saw null, expected object"
]
Request Sent:
@@ -2280,7 +2281,7 @@ Request Sent:
"parallel": null,
"ciBuildId": null,
"projectId": "pid123",
"recordKey": "f858a2bc-b469-4e48-be67-0876339ee7e1",
"recordKey": "f85...7e1",
"specPattern": "cypress/e2e/record_pass*",
"tags": [
""
@@ -2288,7 +2289,8 @@ Request Sent:
"testingType": "e2e",
"runnerCapabilities": {
"dynamicSpecsInSerialMode": true,
"skipSpecAction": true
"skipSpecAction": true,
"protocolMountVersion": 1
}
}
@@ -2773,3 +2775,78 @@ If you are trying to parallelize this run, then also pass the --parallel flag, e
https://on.cypress.io/run-group-name-not-unique
`
exports['e2e record capture-protocol passing retrieves the capture protocol 1'] = `
====================================================================================================
(Run Starting)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Cypress: 1.2.3 │
│ Browser: FooBrowser 88 │
│ Specs: 1 found (record_pass.cy.js) │
│ Searched: cypress/e2e/record_pass* │
│ Params: Tag: false, Group: false, Parallel: false │
│ Run URL: https://dashboard.cypress.io/projects/cjvoj7/runs/12 │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: record_pass.cy.js (1 of 1)
Estimated: X second(s)
record pass
✓ passes
- is pending
1 passing
1 pending
(Results)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Tests: 2 │
│ Passing: 1 │
│ Failing: 0 │
│ Pending: 1 │
│ Skipped: 0 │
│ Screenshots: 1 │
│ Video: false │
│ Duration: X seconds │
│ Estimated: X second(s) │
│ Spec Ran: record_pass.cy.js │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
(Screenshots)
- /XXX/XXX/XXX/cypress/screenshots/record_pass.cy.js/yay it passes.png (400x1022)
(Uploading Results)
- Done Uploading (1/1) /foo/bar/.projects/e2e/cypress/screenshots/record_pass.cy.js/yay it passes.png
====================================================================================================
(Run Finished)
Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✔ record_pass.cy.js XX:XX 2 1 - 1 - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
✔ All specs passed! XX:XX 2 1 - 1 -
───────────────────────────────────────────────────────────────────────────────────────────────────────
Recorded Run: https://dashboard.cypress.io/projects/cjvoj7/runs/12
`

View File

@@ -0,0 +1,41 @@
import type { ProtocolManagerShape } from '@packages/types'
declare const Debug: (namespace) => import('debug').IDebugger
declare const performance: {
now(): number
timeOrigin: number
}
export class AppCaptureProtocol implements ProtocolManagerShape {
private Debug: typeof Debug
private performance: typeof performance
constructor () {
this.Debug = Debug
this.performance = performance
}
setupProtocol = (script, runId) => {
return Promise.resolve()
}
connectToBrowser = (cdpClient) => {
return Promise.resolve()
}
addRunnables = (runnables) => {}
beforeSpec = (spec) => {}
afterSpec = () => {
return Promise.resolve()
}
beforeTest = (test) => {}
commandLogAdded = (log) => {}
commandLogChanged = (log) => {}
viewportChanged = (input) => {}
urlChanged = (input) => {}
sendErrors (errors) {
return Promise.resolve()
}
uploadCaptureArtifact (uploadUrl) {
return Promise.resolve()
}
afterTest (test): void {}
}

View File

@@ -0,0 +1,24 @@
import path from 'path'
import { gzipSync } from 'zlib'
import crypto from 'crypto'
import base64Url from 'base64url'
import esbuild from 'esbuild'
export const SYSTEM_TESTS_PRIVATE = 'LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQ3VBa1docWZSTTB3dFUKZ0toNXE5Z2hTU1BsdG5kM1UxUWk1VHhuZm1pR3lvQVZlL25HRkFidkxXQjNMaTRoVTBkVGlSTjg4TUIwam5hMQpXbHIwK2F1YzBmeVYwMTNiaW5ONFRxWUhFNjdaUUlKYkJNWDNMOEE5K1BybDJ6WkVqZlFZNkYraklKbXNIQ29RCnl5NXU3WGxSS09VOU9rQ0ZsQmp0L3FXbnd6b3RvM0lnY3JmcmJUejkxbk9LVHdKSXBtWGFRRGd3TEhLVm84aFgKbFJWMmI0UjIvWnErWWF6K2dMbE5aUkR2MVFsdXZUMTdPYUY1cldyL2xYT2lQaWp6MGtrS0ROQnF0aWo5UDdsaQpUSnUyQ0YzZkRxdzRuUUVKeVJBVExTTlpSdFZIRkdRN3hOdVdzdGFYOURBT3ZCRDhNTStFVmtlRWVYNEgrdEExCmFWclZhMG10QWdNQkFBRUNnZ0VBWW9OWXhwakFmWW54M1NwbHQxU0pyUGFLZ3krVlhSSHBEVVI0dVNNQXJHY1MKc3BjWXBvS0tGbmk3SjE0V3NibERKVkR5bm9aeWZzcDAvR0VtSTVFQ0RtdDNzNThSZ1F4V0tTTmxyWllBSkhENApHKzJNNGsrL1o1YUEvUWJwSjFDeWhETnlpWmtZUnk4K3hYa3lWWXpPWlJ0aEJSUG9tWGRwMGJ1Y0wybEFrN3NJCnVTUWIreTJtTUFXY1Q2UmRpYnFqcnNNMkE5YW1PQWc1bHd4L3NQUHRTbEdmVkZ6eExQYklDK3o3UmR1eWcyVEwKOXhnZkV5c0Y2dkpxSzJieW1pNGprd3dVZnhFRHluTmtIbEwzR1NsQlE1TkxnVjRVaXhrWEhKZ21OY041OERGTwpwT1NHQzAxMkNOVjQ4b3Fuc3VObEVjeVZhbTVSZk1iWXlCRm5PQVF5WlFLQmdRRGUxNmdISUk3Yk1VaXVLZHBwClV0YU8vMTNjMzlqQXFIcnllVnQ5UUhaTjU5aXdGZlN1N3kzZlYzVlFZRWJYZVpIU1ZkbC9uakhYTmRaaHdtbmUKWlcvZ3UzbHo4TlVjSElhaWZuT2RVSEh0czY2bjFlYUNvZDN0T29VYkhVUEhqYUl2L0F6ZlZTNWtBNzB6RTh6RApRNW5qS2JEc1hucExKY0QrV25VYzVIUlNjd0tCZ1FESDVueGZBaGkxckppQk1TeUpJYkZjUTl0dVVMT3JiQk9mCkZSeVArQzZybi9kZndvb09vL2lvaHJvT3FPSnVZTG9oTTltN1NvaHNpU3R2bG1VVEl3YlVTd1NNR00yMFdlK1cKR0ZjT01rQlk5NFVXdHF2aDlDaGMycmV6NkNDZE1VQkNHaVlMQ1V1SGp4ZDZqZ3ZZbG5vS2xsZzVBakJ2aUJDbApNM0VNZ2tOTFh3S0JnUUNwUVNGRmNJd3duZWszSjJEVjJHNVFwRk0xZk91VHdTUEk0VFlGRng0RUpCRm9CUFVZCm5WKzVJQ05oamc2Z2dKeXFKanlSZXFVZWNheklDYk1Ca1FmOXFFY2lNWXliMG1yTUpzRkhmaDlhVEx4ZWk4K04KN3NXeDlsMjg3MmhZdkJHdzRuOGdiZ0ZUUTZmRGtNbFlraExpLy9wNlBYUWplYVJ4VEdGaE5YL0lVd0tCZ0dKeQpyTVhOcm9XcW51RGhhdUdPYWw3YVBITXo0NGlGRFpUSFBPM2FlSUdsb3ByU29GTmRoZFRacFVBYkJJai9zaXN2CjhnYy9TYmpLUlU0TGIzUGhTRGU5U2x3RXl5b0xNT2RtelZqOGZweFNLb1ZwS1hWNlhYWjljUU4xU3JxZnl0bkQKTHdFNGJxNHdWb3ZROFJ5VjN6emZsa3RkUEtWeENXR1MyQllsQVNkWkFvR0FGRjliM2QvRko4Rm0rS25qNlhTaAozT3FuZlJ6NGRLN042bkxIUGdxelBGdVdiVWVPRGY1dTkrN3NpUVlNVkZyRWlZUlNvRStqc0FWREhBb1dIZ1Q3CmZlM2lUNzZuZVlHWVd3M1VwTjdQby9udTNiT3FWUzZSUEs0L05wZ0ZuM1ZzTUdyRTVKVVY5N0Z1Q1NKNHM4Wk8KTzJnWnBRdVpHQm40Und0LzEwOXdEYTQ9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0='
export const TEST_PRIVATE = crypto.createPrivateKey(Buffer.from(SYSTEM_TESTS_PRIVATE, 'base64').toString('utf8'))
const { outputFiles: [{ contents: stubProtocolRaw }] } = esbuild.buildSync({
entryPoints: [path.join(__dirname, 'protocolStub.ts')],
bundle: true,
format: 'cjs',
write: false,
})
export const CYPRESS_LOCAL_PROTOCOL_STUB = new TextDecoder('utf-8').decode(stubProtocolRaw)
export const CYPRESS_LOCAL_PROTOCOL_STUB_COMPRESSED = gzipSync(CYPRESS_LOCAL_PROTOCOL_STUB)
export const CYPRESS_LOCAL_PROTOCOL_STUB_HASH = base64Url.fromBase64(crypto.createHash('SHA256').update(CYPRESS_LOCAL_PROTOCOL_STUB).digest('base64'))
export const CYPRESS_LOCAL_PROTOCOL_STUB_SIGN = base64Url.fromBase64(crypto.createSign('SHA256').update(CYPRESS_LOCAL_PROTOCOL_STUB).sign(TEST_PRIVATE, 'base64'))

View File

@@ -2,20 +2,29 @@ import crypto from 'crypto'
import _ from 'lodash'
import Bluebird from 'bluebird'
import bodyParser from 'body-parser'
import { api as jsonSchemas } from '@cypress/json-schemas'
import type { RequestHandler } from 'express'
import { getExample, assertSchema, RecordSchemaVersions } from './validations/cloudValidations'
import * as jose from 'jose'
import base64Url from 'base64url'
import systemTests from './system-tests'
const SYSTEM_TESTS_PRIVATE = 'LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRQ3VBa1docWZSTTB3dFUKZ0toNXE5Z2hTU1BsdG5kM1UxUWk1VHhuZm1pR3lvQVZlL25HRkFidkxXQjNMaTRoVTBkVGlSTjg4TUIwam5hMQpXbHIwK2F1YzBmeVYwMTNiaW5ONFRxWUhFNjdaUUlKYkJNWDNMOEE5K1BybDJ6WkVqZlFZNkYraklKbXNIQ29RCnl5NXU3WGxSS09VOU9rQ0ZsQmp0L3FXbnd6b3RvM0lnY3JmcmJUejkxbk9LVHdKSXBtWGFRRGd3TEhLVm84aFgKbFJWMmI0UjIvWnErWWF6K2dMbE5aUkR2MVFsdXZUMTdPYUY1cldyL2xYT2lQaWp6MGtrS0ROQnF0aWo5UDdsaQpUSnUyQ0YzZkRxdzRuUUVKeVJBVExTTlpSdFZIRkdRN3hOdVdzdGFYOURBT3ZCRDhNTStFVmtlRWVYNEgrdEExCmFWclZhMG10QWdNQkFBRUNnZ0VBWW9OWXhwakFmWW54M1NwbHQxU0pyUGFLZ3krVlhSSHBEVVI0dVNNQXJHY1MKc3BjWXBvS0tGbmk3SjE0V3NibERKVkR5bm9aeWZzcDAvR0VtSTVFQ0RtdDNzNThSZ1F4V0tTTmxyWllBSkhENApHKzJNNGsrL1o1YUEvUWJwSjFDeWhETnlpWmtZUnk4K3hYa3lWWXpPWlJ0aEJSUG9tWGRwMGJ1Y0wybEFrN3NJCnVTUWIreTJtTUFXY1Q2UmRpYnFqcnNNMkE5YW1PQWc1bHd4L3NQUHRTbEdmVkZ6eExQYklDK3o3UmR1eWcyVEwKOXhnZkV5c0Y2dkpxSzJieW1pNGprd3dVZnhFRHluTmtIbEwzR1NsQlE1TkxnVjRVaXhrWEhKZ21OY041OERGTwpwT1NHQzAxMkNOVjQ4b3Fuc3VObEVjeVZhbTVSZk1iWXlCRm5PQVF5WlFLQmdRRGUxNmdISUk3Yk1VaXVLZHBwClV0YU8vMTNjMzlqQXFIcnllVnQ5UUhaTjU5aXdGZlN1N3kzZlYzVlFZRWJYZVpIU1ZkbC9uakhYTmRaaHdtbmUKWlcvZ3UzbHo4TlVjSElhaWZuT2RVSEh0czY2bjFlYUNvZDN0T29VYkhVUEhqYUl2L0F6ZlZTNWtBNzB6RTh6RApRNW5qS2JEc1hucExKY0QrV25VYzVIUlNjd0tCZ1FESDVueGZBaGkxckppQk1TeUpJYkZjUTl0dVVMT3JiQk9mCkZSeVArQzZybi9kZndvb09vL2lvaHJvT3FPSnVZTG9oTTltN1NvaHNpU3R2bG1VVEl3YlVTd1NNR00yMFdlK1cKR0ZjT01rQlk5NFVXdHF2aDlDaGMycmV6NkNDZE1VQkNHaVlMQ1V1SGp4ZDZqZ3ZZbG5vS2xsZzVBakJ2aUJDbApNM0VNZ2tOTFh3S0JnUUNwUVNGRmNJd3duZWszSjJEVjJHNVFwRk0xZk91VHdTUEk0VFlGRng0RUpCRm9CUFVZCm5WKzVJQ05oamc2Z2dKeXFKanlSZXFVZWNheklDYk1Ca1FmOXFFY2lNWXliMG1yTUpzRkhmaDlhVEx4ZWk4K04KN3NXeDlsMjg3MmhZdkJHdzRuOGdiZ0ZUUTZmRGtNbFlraExpLy9wNlBYUWplYVJ4VEdGaE5YL0lVd0tCZ0dKeQpyTVhOcm9XcW51RGhhdUdPYWw3YVBITXo0NGlGRFpUSFBPM2FlSUdsb3ByU29GTmRoZFRacFVBYkJJai9zaXN2CjhnYy9TYmpLUlU0TGIzUGhTRGU5U2x3RXl5b0xNT2RtelZqOGZweFNLb1ZwS1hWNlhYWjljUU4xU3JxZnl0bkQKTHdFNGJxNHdWb3ZROFJ5VjN6emZsa3RkUEtWeENXR1MyQllsQVNkWkFvR0FGRjliM2QvRko4Rm0rS25qNlhTaAozT3FuZlJ6NGRLN042bkxIUGdxelBGdVdiVWVPRGY1dTkrN3NpUVlNVkZyRWlZUlNvRStqc0FWREhBb1dIZ1Q3CmZlM2lUNzZuZVlHWVd3M1VwTjdQby9udTNiT3FWUzZSUEs0L05wZ0ZuM1ZzTUdyRTVKVVY5N0Z1Q1NKNHM4Wk8KTzJnWnBRdVpHQm40Und0LzEwOXdEYTQ9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0='
const TEST_PRIVATE = crypto.createPrivateKey(Buffer.from(SYSTEM_TESTS_PRIVATE, 'base64').toString('utf8'))
let CAPTURE_PROTOCOL_ENABLED = false
export const postRunResponseWithWarnings = jsonSchemas.getExample('postRunResponse')('2.2.0')
import {
TEST_PRIVATE,
CYPRESS_LOCAL_PROTOCOL_STUB_COMPRESSED,
CYPRESS_LOCAL_PROTOCOL_STUB_HASH,
CYPRESS_LOCAL_PROTOCOL_STUB_SIGN,
} from './protocolStubResponse'
export const postRunInstanceResponse = jsonSchemas.getExample('postRunInstanceResponse')('2.1.0')
export const postRunResponseWithWarnings = getExample('createRun', 4, 'res')
export const postInstanceTestsResponse = jsonSchemas.getExample('postInstanceTestsResponse')('1.0.0')
export const postRunInstanceResponse = getExample('createInstance', 5, 'res')
export const postInstanceTestsResponse = getExample('postInstanceTests', 1, 'res')
postInstanceTestsResponse.actions = []
export const postRunResponse = _.assign({}, postRunResponseWithWarnings, { warnings: [] })
@@ -69,7 +78,19 @@ export const encryptBody = async (req, res, body) => {
return await enc.encrypt()
}
export const routeHandlers = {
type RouteHandler = {
method: 'get' | 'post' | 'put' | 'delete'
url: string
reqSchema?: {
[K in keyof RecordSchemaVersions]: [K, keyof RecordSchemaVersions[K]]
}[keyof RecordSchemaVersions]
resSchema?: {
[K in keyof RecordSchemaVersions]: [K, keyof RecordSchemaVersions[K]]
}[keyof RecordSchemaVersions]
res?: RequestHandler | object
}
export const routeHandlers: Record<string, RouteHandler> = {
sendPreflight: {
method: 'post',
url: '/preflight',
@@ -82,14 +103,22 @@ export const routeHandlers = {
postRun: {
method: 'post',
url: '/runs',
reqSchema: 'postRunRequest@2.4.0',
resSchema: 'postRunResponse@2.2.0',
reqSchema: ['createRun', 4],
resSchema: ['createRun', 4],
res: (req, res) => {
if (!req.body.specs) {
throw new Error('expected for Test Runner to post specs')
}
mockServerState.setSpecs(req)
if (CAPTURE_PROTOCOL_ENABLED && req.body.runnerCapabilities.protocolMountVersion === 1) {
res.json({
...postRunResponse,
captureProtocolUrl: `http://localhost:1234/capture-protocol/script/${CYPRESS_LOCAL_PROTOCOL_STUB_HASH}.js`,
})
return
}
return res.json(postRunResponse)
},
@@ -97,8 +126,8 @@ export const routeHandlers = {
postRunInstance: {
method: 'post',
url: '/runs/:id/instances',
reqSchema: 'postRunInstanceRequest@2.1.0',
resSchema: 'postRunInstanceResponse@2.1.0',
reqSchema: ['createInstance', 5],
resSchema: ['createInstance', 5],
res: (req, res) => {
const response = {
...postRunInstanceResponse,
@@ -113,21 +142,29 @@ export const routeHandlers = {
postInstanceTests: {
method: 'post',
url: '/instances/:id/tests',
reqSchema: 'postInstanceTestsRequest@1.0.0',
resSchema: 'postInstanceTestsResponse@1.0.0',
reqSchema: ['postInstanceTests', 1],
resSchema: ['postInstanceTests', 1],
res: postInstanceTestsResponse,
},
postInstanceResults: {
method: 'post',
url: '/instances/:id/results',
reqSchema: 'postInstanceResultsRequest@1.1.0',
resSchema: 'postInstanceResultsResponse@1.0.0',
reqSchema: ['postInstanceResults', 1],
resSchema: ['postInstanceResults', 1],
res: sendUploadUrls,
},
putArtifacts: {
method: 'put',
url: '/instances/:id/artifacts',
// reqSchema: TODO(protocol): export this as part of manifest from cloud
res: async (req, res) => {
res.status(200)
},
},
putInstanceStdout: {
method: 'put',
url: '/instances/:id/stdout',
reqSchema: 'putInstanceStdoutRequest@1.0.0',
reqSchema: ['updateInstanceStdout', 1],
res (req, res) {
return res.sendStatus(200)
},
@@ -149,7 +186,14 @@ export const routeHandlers = {
})
},
},
getCaptureScript: {
method: 'get',
url: '/capture-protocol/script/*',
res: async (req, res) => {
res.header('x-cypress-signature', CYPRESS_LOCAL_PROTOCOL_STUB_SIGN)
res.status(200).send(CYPRESS_LOCAL_PROTOCOL_STUB_COMPRESSED)
},
},
}
export const createRoutes = (props: DeepPartial<typeof routeHandlers>) => {
@@ -170,15 +214,6 @@ export const getRequests = () => {
return mockServerState.requests.filter((r) => r.url !== 'POST /preflight')
}
const getSchemaErr = (tag, err, schema) => {
return {
errors: err.errors,
object: err.object,
example: err.example,
message: `${tag} should follow ${schema} schema`,
}
}
const getResponse = function (responseSchema) {
if (!responseSchema) {
throw new Error('No response schema supplied')
@@ -188,9 +223,10 @@ const getResponse = function (responseSchema) {
return responseSchema
}
const [name, version] = responseSchema.split('@')
const [name, version] = responseSchema
return jsonSchemas.getExample(name)(version)
// @ts-expect-error
return getExample(name, version, 'res')
}
const sendResponse = function (req, res, responseBody) {
@@ -216,7 +252,7 @@ const ensureSchema = function (onRequestBody, expectedRequestSchema, responseBod
let reqName; let reqVersion
if (expectedRequestSchema) {
[reqName, reqVersion] = expectedRequestSchema.split('@')
[reqName, reqVersion] = expectedRequestSchema
}
return async function (req, res) {
@@ -228,7 +264,8 @@ const ensureSchema = function (onRequestBody, expectedRequestSchema, responseBod
try {
if (expectedRequestSchema) {
jsonSchemas.assertSchema(reqName, reqVersion)(body)
// @ts-expect-error
assertSchema(reqName, reqVersion, 'req')(body)
}
res.expectedResponseSchema = expectedResponseSchema
@@ -245,7 +282,7 @@ const ensureSchema = function (onRequestBody, expectedRequestSchema, responseBod
// eslint-disable-next-line no-console
console.log('Schema Error:', err.message)
return res.status(412).json(getSchemaErr('request', err, expectedRequestSchema))
return res.status(412).json(err)
}
}
}
@@ -272,15 +309,15 @@ const assertResponseBodySchema = function (req, res, next) {
if (res.expectedResponseSchema && _.inRange(res.statusCode, 200, 299)) {
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'))
const [resName, resVersion] = res.expectedResponseSchema.split('@')
const [resName, resVersion] = res.expectedResponseSchema
try {
jsonSchemas.assertSchema(resName, resVersion)(body)
assertSchema(resName, resVersion, 'res')(body)
} catch (err) {
// eslint-disable-next-line no-console
console.log('Schema Error:', err.message)
return res.status(412).json(getSchemaErr('response', err, res.expectedResponseSchema))
return res.status(412).json(err)
}
}
@@ -332,6 +369,16 @@ const onServer = (routes) => {
})
}
export const enableCaptureProtocol = () => {
beforeEach(() => {
CAPTURE_PROTOCOL_ENABLED = true
})
afterEach(() => {
CAPTURE_PROTOCOL_ENABLED = false
})
}
export const setupStubbedServer = (routes) => {
systemTests.setup({
servers: [{

View File

@@ -11,7 +11,9 @@
"clean-deps": "find . -depth -name node_modules -type d -exec rimraf {} \\;",
"preprojects:yarn:install": "yarn clean-deps",
"projects:yarn:install": "node ./scripts/projects-yarn-install.js",
"pretest": "yarn gulp ensureCloudValidations",
"test": "node ./scripts/run.js --glob-in-dir=\"{test,test-binary}\"",
"pretest:ci": "yarn gulp ensureCloudValidations",
"test:ci": "node ./scripts/run.js",
"update:snapshots": "SNAPSHOT_UPDATE=1 npm run test"
},
@@ -20,7 +22,6 @@
"@babel/preset-env": "7.9.0",
"@cypress/commit-info": "2.2.0",
"@cypress/debugging-proxy": "2.0.1",
"@cypress/json-schemas": "5.39.0",
"@cypress/request": "2.88.10",
"@cypress/request-promise": "4.2.6",
"@cypress/sinon-chai": "2.9.1",
@@ -54,6 +55,7 @@
"debug": "^4.3.4",
"dedent": "^0.7.0",
"dockerode": "3.3.1",
"esbuild": "^0.15.3",
"execa": "4",
"express": "4.17.3",
"express-session": "1.16.1",

View File

@@ -2,15 +2,16 @@
const _ = require('lodash')
const path = require('path')
const Promise = require('bluebird')
const jsonSchemas = require('@cypress/json-schemas').api
const dedent = require('dedent')
const systemTests = require('../lib/system-tests').default
const { fs } = require('@packages/server/lib/util/fs')
const Fixtures = require('../lib/fixtures')
const { assertSchema } = require('../lib/validations/cloudValidations')
const {
createRoutes,
setupStubbedServer,
enableCaptureProtocol,
getRequestUrls,
getRequests,
postRunResponse,
@@ -319,7 +320,7 @@ describe('e2e record', () => {
resp.claimedInstances = claimed.length
resp.totalInstances = allSpecs.length
jsonSchemas.assertSchema('postRunInstanceResponse', '2.1.0')(resp)
assertSchema('createInstance', 5, 'req')(resp)
return res.json(resp)
}
@@ -669,6 +670,8 @@ describe('e2e record', () => {
...postInstanceTestsResponse,
actions: [{
type: 'SPEC',
clientId: null,
payload: null,
action: 'SKIP',
}],
})
@@ -709,6 +712,7 @@ describe('e2e record', () => {
console.log(requests[0].body.runnerCapabilities)
expect(requests[0].body).property('runnerCapabilities').deep.eq({
'dynamicSpecsInSerialMode': true,
'protocolMountVersion': 1,
'skipSpecAction': true,
})
})
@@ -1093,7 +1097,8 @@ describe('e2e record', () => {
describe('create run 412', () => {
setupStubbedServer(createRoutes({
postRun: {
reqSchema: 'postRunRequest@2.0.0', // force this to throw a schema error
reqSchema: ['createRun', 4],
// force this to throw a schema error
onReqBody (body) {
_.extend(body, {
ci: null,
@@ -2263,4 +2268,21 @@ describe('e2e record', () => {
})
})
})
describe('capture-protocol', () => {
setupStubbedServer(createRoutes())
enableCaptureProtocol()
describe('passing', () => {
it('retrieves the capture protocol', function () {
return systemTests.exec(this, {
key: 'f858a2bc-b469-4e48-be67-0876339ee7e1',
configFile: 'cypress-with-project-id.config.js',
spec: 'record_pass*',
record: true,
snapshot: true,
})
})
})
})
})

115
yarn.lock
View File

@@ -2177,17 +2177,6 @@
check-more-types "2.24.0"
lazy-ass "1.6.0"
"@bahmutov/is-my-json-valid@2.17.3":
version "2.17.3"
resolved "https://registry.yarnpkg.com/@bahmutov/is-my-json-valid/-/is-my-json-valid-2.17.3.tgz#955e8e9767db913e55c4c3f541dcfd2a30e160c4"
integrity sha1-lV6Ol2fbkT5VxMP1Qdz9KjDhYMQ=
dependencies:
generate-function "^2.0.0"
generate-object-property "^1.1.0"
is-my-ip-valid "^1.0.0"
jsonpointer "^4.0.0"
xtend "^4.0.0"
"@bcoe/v8-coverage@^0.2.3":
version "0.2.3"
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
@@ -2305,17 +2294,6 @@
optionalDependencies:
registry-js "1.8.0"
"@cypress/json-schemas@5.39.0":
version "5.39.0"
resolved "https://registry.yarnpkg.com/@cypress/json-schemas/-/json-schemas-5.39.0.tgz#a650ba91d0124f56a2954edc156704da4a313780"
integrity sha512-QpSUflVOP06Bi4lCoDlMWgbfeDN4N3UHZqH9s2qC65FRhdSek0BcUc9jd/Q4cGUlHYIfDdCLmSRG1b1/0Vh/PQ==
dependencies:
"@cypress/schema-tools" "4.7.7"
lodash "^4.17.21"
lodash.clonedeep "^4.5.0"
lodash.merge "^4.6.2"
lodash.omit "^4.5.0"
"@cypress/mocha-teamcity-reporter@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@cypress/mocha-teamcity-reporter/-/mocha-teamcity-reporter-1.0.0.tgz#efc8ab938c99f9654f438bef412bce1cd5e129d7"
@@ -2382,24 +2360,6 @@
tunnel-agent "^0.6.0"
uuid "^8.3.2"
"@cypress/schema-tools@4.7.7":
version "4.7.7"
resolved "https://registry.yarnpkg.com/@cypress/schema-tools/-/schema-tools-4.7.7.tgz#251a9864caba0eded884ff5c71de16c76dbf556a"
integrity sha512-RRzksoJIXDTeUjt7YE9xAhOynqc7R+j8Tx8ebpkSPJB6Z3WujdLP0sigVh2AV24G/CySOvJGuQQY94aBEpCZaA==
dependencies:
"@bahmutov/all-paths" "1.0.2"
"@bahmutov/is-my-json-valid" "2.17.3"
"@types/ramda" "0.25.47"
debug "4.3.1"
json-stable-stringify "1.0.1"
json2md "1.6.3"
lodash.camelcase "4.3.0"
lodash.get "4.4.2"
lodash.reduce "^4.6.0"
lodash.set "4.3.2"
quote "0.4.0"
ramda "0.25.0"
"@cypress/sinon-chai@2.9.1":
version "2.9.1"
resolved "https://registry.yarnpkg.com/@cypress/sinon-chai/-/sinon-chai-2.9.1.tgz#1705c0341bc286740979b1b1cac89b7f5d34d6bc"
@@ -7011,11 +6971,6 @@
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.6.tgz#df9c3c8b31a247ec315e6996566be3171df4b3b1"
integrity sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA==
"@types/ramda@0.25.47":
version "0.25.47"
resolved "https://registry.yarnpkg.com/@types/ramda/-/ramda-0.25.47.tgz#904f2ee46149af42902fe7dc01867e32798e8b37"
integrity sha512-+ffSU83+PR4/cZtNTkUcFkg70sK4GePle7p5h05bQ37ycPumOx/TBpU52bt36GKDlds6tCqXheqPvgC52MMLug==
"@types/range-parser@*":
version "1.2.3"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
@@ -7051,6 +7006,14 @@
"@types/bluebird" "*"
"@types/request" "*"
"@types/request-promise@^4.1.48":
version "4.1.48"
resolved "https://registry.yarnpkg.com/@types/request-promise/-/request-promise-4.1.48.tgz#46f4225a58cefaa342c87fe5f2efb8ad3cb2c2e3"
integrity sha512-sLsfxfwP5G3E3U64QXxKwA6ctsxZ7uKyl4I28pMj3JvV+ztWECRns73GL71KMOOJME5u1A5Vs5dkBqyiR1Zcnw==
dependencies:
"@types/bluebird" "*"
"@types/request" "*"
"@types/request@*":
version "2.48.5"
resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.5.tgz#019b8536b402069f6d11bee1b2c03e7f232937a0"
@@ -15920,20 +15883,6 @@ gauge@~2.7.3:
strip-ansi "^3.0.1"
wide-align "^1.1.0"
generate-function@^2.0.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f"
integrity sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==
dependencies:
is-property "^1.0.2"
generate-object-property@^1.1.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/generate-object-property/-/generate-object-property-1.2.0.tgz#9c0e1c40308ce804f4783618b937fa88f99d50d0"
integrity sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA=
dependencies:
is-property "^1.0.0"
gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
@@ -17695,11 +17644,6 @@ indent-string@^4.0.0:
resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
indento@^1.1.7:
version "1.1.13"
resolved "https://registry.yarnpkg.com/indento/-/indento-1.1.13.tgz#751331b327c04740eeb7be40c5606e6e255c9e36"
integrity sha512-YZWk3mreBEM7sBPddsiQnW9Z8SGg/gNpFfscJq00HCDS7pxcQWWWMSVKJU7YkTRyDu1Zv2s8zaK8gQWKmCXHlg==
indexof@0.0.1, indexof@~0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
@@ -18282,11 +18226,6 @@ is-module@^1.0.0:
resolved "https://registry.yarnpkg.com/is-module/-/is-module-1.0.0.tgz#3258fb69f78c14d5b815d664336b4cffb6441591"
integrity sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=
is-my-ip-valid@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-my-ip-valid/-/is-my-ip-valid-1.0.0.tgz#7b351b8e8edd4d3995d4d066680e664d94696824"
integrity sha512-gmh/eWXROncUzRnIa1Ubrt5b8ep/MGSnfAUI3aRp+sqTCs1tv1Isl8d8F6JmkN3dXKc3ehZMrtiPN9eL03NuaQ==
is-negated-glob@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-negated-glob/-/is-negated-glob-1.0.0.tgz#6910bca5da8c95e784b5751b976cf5a10fee36d2"
@@ -18441,11 +18380,6 @@ is-promise@^2.0.0, is-promise@^2.1.0:
resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1"
integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==
is-property@^1.0.0, is-property@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84"
integrity sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=
is-reference@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/is-reference/-/is-reference-1.2.1.tgz#8b2dac0b371f4bc994fdeaba9eb542d03002d0b7"
@@ -19126,13 +19060,6 @@ json-wire-protocol@^1.0.0:
resolved "https://registry.yarnpkg.com/json-wire-protocol/-/json-wire-protocol-1.0.0.tgz#50a6fd7e5f1406dbaf5a4d3279be2620181276f8"
integrity sha1-UKb9fl8UBtuvWk0yeb4mIBgSdvg=
json2md@1.6.3:
version "1.6.3"
resolved "https://registry.yarnpkg.com/json2md/-/json2md-1.6.3.tgz#852e52f9fe579691eaf3c75f626992aefa33ca09"
integrity sha512-bdza+dm2rKu9NgguimGe9Os7grpYE8CCLXIXMkIYGOfkZLxSMKN487OOT8PBgBW2xFCcItoxh6WFA7SJOEDKkw==
dependencies:
indento "^1.1.7"
json3@3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.2.tgz#3c0434743df93e2f5c42aee7b19bcb483575f4e1"
@@ -19221,11 +19148,6 @@ jsonparse@^1.2.0, jsonparse@^1.3.1:
resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
jsonpointer@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.1.0.tgz#501fb89986a2389765ba09e6053299ceb4f2c2cc"
integrity sha512-CXcRvMyTlnR53xMcKnuMzfCA5i/nfblTnnr74CZb6C4vG39eu6w51t7nKmU5MfLfbTgGItliNyjO/ciNPDqClg==
jsonwebtoken@^8.5.1:
version "8.5.1"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d"
@@ -20065,7 +19987,7 @@ lodash._isiterateecall@^3.0.0:
resolved "https://registry.yarnpkg.com/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz#5203ad7ba425fae842460e696db9cf3e6aac057c"
integrity sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=
lodash.camelcase@4.3.0, lodash.camelcase@^4.3.0:
lodash.camelcase@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY=
@@ -20114,7 +20036,7 @@ lodash.flatten@^4.4.0:
resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
lodash.get@4.4.2, lodash.get@^4, lodash.get@^4.0.0, lodash.get@^4.4.2:
lodash.get@^4, lodash.get@^4.0.0, lodash.get@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
@@ -20183,7 +20105,7 @@ lodash.keys@^3.0.0:
lodash.isarguments "^3.0.0"
lodash.isarray "^3.0.0"
lodash.merge@4.6.2, lodash.merge@^4.6.2:
lodash.merge@4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
@@ -20193,11 +20115,6 @@ lodash.mergewith@^4.6.2:
resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55"
integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==
lodash.omit@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.omit/-/lodash.omit-4.5.0.tgz#6eb19ae5a1ee1dd9df0b969e66ce0b7fa30b5e60"
integrity sha1-brGa5aHuHdnfC5aeZs4Lf6MLXmA=
lodash.once@^4.0.0, lodash.once@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
@@ -20208,16 +20125,6 @@ lodash.range@3.2.0:
resolved "https://registry.yarnpkg.com/lodash.range/-/lodash.range-3.2.0.tgz#f461e588f66683f7eadeade513e38a69a565a15d"
integrity sha1-9GHliPZmg/fq3q3lE+OKaaVloV0=
lodash.reduce@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/lodash.reduce/-/lodash.reduce-4.6.0.tgz#f1ab6b839299ad48f784abbf476596f03b914d3b"
integrity sha1-8atrg5KZrUj3hKu/R2WW8DuRTTs=
lodash.set@4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=
lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"