diff --git a/.node-version b/.node-version index 2a0dc9a810cf..62df50f1eefe 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -14.16.0 +14.17.0 diff --git a/appveyor.yml b/appveyor.yml index 48189a8e4246..e68db73558be 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -8,7 +8,7 @@ branches: # https://www.appveyor.com/docs/lang/nodejs-iojs/ environment: # use matching version of Node.js - nodejs_version: "14.16.0" + nodejs_version: "14.17.0" # encode secure variables which will NOT be used # in pull requests # https://www.appveyor.com/docs/build-configuration/#secure-variables diff --git a/browser-versions.json b/browser-versions.json index db0494cb6f7d..5a5bbf07df0a 100644 --- a/browser-versions.json +++ b/browser-versions.json @@ -1,4 +1,4 @@ { - "chrome:beta": "93.0.4577.18", - "chrome:stable": "92.0.4515.107" + "chrome:beta": "93.0.4577.25", + "chrome:stable": "92.0.4515.131" } diff --git a/circle.yml b/circle.yml index 9f41937774dd..2117d90f6b3e 100644 --- a/circle.yml +++ b/circle.yml @@ -49,7 +49,7 @@ executors: # the Docker image with Cypress dependencies and Chrome browser cy-doc: docker: - - image: cypress/browsers:node14.16.0-chrome90-ff88 + - image: cypress/browsers:node14.17.0-chrome91-ff89 # by default, we use "small" to save on CI costs. bump on a per-job basis if needed. resource_class: small environment: @@ -58,7 +58,7 @@ executors: # Docker image with non-root "node" user non-root-docker-user: docker: - - image: cypress/browsers:node14.16.0-chrome90-ff88 + - image: cypress/browsers:node14.17.0-chrome91-ff89 user: node environment: PLATFORM: linux @@ -1126,6 +1126,7 @@ jobs: runner-integration-tests-electron: <<: *defaults + resource_class: medium parallelism: 2 steps: - run-runner-integration-tests: diff --git a/package.json b/package.json index 595df8963bde..2b7d11513933 100644 --- a/package.json +++ b/package.json @@ -196,7 +196,7 @@ "yarn-deduplicate": "3.1.0" }, "engines": { - "node": ">=14.16.0", + "node": ">=14.17.0", "yarn": ">=1.17.3" }, "productName": "Cypress", diff --git a/packages/driver/cypress/integration/commands/net_stubbing_spec.ts b/packages/driver/cypress/integration/commands/net_stubbing_spec.ts index 4210c22deba2..8dac97dcb092 100644 --- a/packages/driver/cypress/integration/commands/net_stubbing_spec.ts +++ b/packages/driver/cypress/integration/commands/net_stubbing_spec.ts @@ -2127,6 +2127,61 @@ describe('network stubbing', { retries: 2 }, function () { cy.get('#request').click() cy.get('#result').should('contain', 'client') }) + + it('works with reply', () => { + cy.intercept({ + method: 'POST', + times: 1, + url: '/post-only', + }, + (req) => { + req.reply('stubbed data') + }).as('interceptor') + + cy.visit('fixtures/request.html') + + cy.get('#request').click() + cy.get('#result').should('contain', 'stubbed data') + + cy.get('#request').click() + cy.get('#result').should('contain', 'client') + }) + + it('works with reply and fallthrough', () => { + let times = 0 + + cy.intercept({ + method: 'POST', + times: 3, + url: '/post-only', + }, + (req) => { + req.reply(`${req.body === 'foo' ? 'foo' : 'nothing'} stubbed data ${times++}`) + }) + + cy.intercept({ + method: 'POST', + times: 2, + url: '/post-only', + }, + (req) => { + req.body = 'foo' + }) + + cy.visit('fixtures/request.html') + + cy.get('#request').click() + cy.get('#result').should('contain', 'foo stubbed data 0') + + cy.get('#request').click() + cy.get('#result').should('contain', 'foo stubbed data 1') + + cy.get('#request').click() + cy.get('#result').should('contain', 'nothing stubbed data 2') + + cy.get('#request').click() + cy.get('#result').should('contain', 'client') + }) }) }) }) @@ -2628,6 +2683,31 @@ describe('network stubbing', { retries: 2 }, function () { .wait('@get') }) + // https://github.com/cypress-io/cypress/issues/17084 + it('does not overwrite the json-related content-type header', () => { + cy.intercept('/json-content-type', (req) => { + req.on('response', (res) => { + res.send({ + statusCode: 500, + headers: { + 'content-type': 'application/problem+json', + 'access-control-allow-origin': '*', + }, + body: { + status: 500, + title: 'Internal Server Error', + }, + }) + }) + }) + .then(() => { + return fetch('/json-content-type') + .then((res) => { + expect(res.headers.get('content-type')).to.eq('application/problem+json') + }) + }) + }) + context('body parsing', function () { [ 'application/json', diff --git a/packages/driver/cypress/integration/commands/waiting_spec.js b/packages/driver/cypress/integration/commands/waiting_spec.js index 901882aa8934..6d32cbb531a3 100644 --- a/packages/driver/cypress/integration/commands/waiting_spec.js +++ b/packages/driver/cypress/integration/commands/waiting_spec.js @@ -735,7 +735,7 @@ describe('src/cy/commands/waiting', () => { cy.wait('@foo', '@bar') }) - it('throws when passed caallback function', (done) => { + it('throws when passed callback function', (done) => { cy.on('fail', (err) => { expect(err.message).to.eq('`cy.wait()` was passed invalid arguments. You cannot pass a function. If you would like to wait on the result of a `cy.wait()`, use `cy.then()`.') expect(err.docsUrl).to.eq('https://on.cypress.io/wait') diff --git a/packages/driver/cypress/integration/cypress/command_queue_spec.ts b/packages/driver/cypress/integration/cypress/command_queue_spec.ts new file mode 100644 index 000000000000..f8fe80a0ddaa --- /dev/null +++ b/packages/driver/cypress/integration/cypress/command_queue_spec.ts @@ -0,0 +1,196 @@ +import _ from 'lodash' +import $Command from '../../../src/cypress/command' + +import { create } from '../../../src/cypress/command_queue' + +const createCommand = (props = {}) => { + return $Command.create(_.extend({ + name: 'get', + args: ['#foo'], + type: 'parent', + chainerId: _.uniqueId('ch'), + userInvocationStack: '', + injected: false, + fn () {}, + }, props)) +} + +const log = (props = {}) => { + return Cypress.log(_.extend({ + name: _.uniqueId('l'), + }, props)) +} + +describe('src/cypress/command_queue', () => { + let queue + const state = () => {} + const timeouts = { timeout () {} } + const stability = { whenStable () {} } + const cleanup = () => {} + const fail = () => {} + const isCy = () => {} + + beforeEach(() => { + queue = create(state, timeouts, stability, cleanup, fail, isCy) + + queue.add(createCommand({ + name: 'get', + logs: [log({ name: 'l1', alias: 'alias-1' }), log({ name: 'l2', alias: 'alias-1' })], + })) + + queue.add(createCommand({ + name: 'find', + logs: [log({ name: 'l3', alias: 'alias-2' })], + })) + + queue.add(createCommand({ + name: 'click', + logs: [log({ name: 'l4', alias: 'alias-1' }), log({ name: 'l5', alias: 'alias-3' })], + })) + }) + + context('#logs', () => { + it('returns a flat list of logs from the commands', () => { + const logs = queue.logs() + + expect(_.invokeMap(logs, 'get', 'name')).to.eql(['l1', 'l2', 'l3', 'l4', 'l5']) + }) + + it('returns a filtered list of logs if filter is provided', () => { + const logs = queue.logs({ alias: 'alias-1' }) + + expect(_.invokeMap(logs, 'get', 'name')).to.eql(['l1', 'l2', 'l4']) + }) + }) + + context('#get', () => { + it('returns list of commands', () => { + const commands = queue.get() + + expect(_.invokeMap(commands, 'get', 'name')).to.eql(['get', 'find', 'click']) + }) + }) + + context('#names', () => { + it('returns list of command names', () => { + const names = queue.names() + + expect(names).to.eql(['get', 'find', 'click']) + }) + }) + + context('#insert', () => { + it('inserts command into queue at index', () => { + queue.insert(1, createCommand({ name: 'eq' })) + + expect(queue.names()).to.eql(['get', 'eq', 'find', 'click']) + }) + + it('returns the command', () => { + const command = createCommand({ name: 'eq' }) + const result = queue.insert(1, command) + + expect(result).to.equal(command) + }) + + it('resets the next and prev commands', () => { + const command = queue.insert(1, createCommand({ name: 'eq' })) + const prev = queue.at(0) + const next = queue.at(2) + + expect(command.get('prev')).to.equal(prev) + expect(command.get('next')).to.equal(next) + expect(prev.get('next')).to.equal(command) + expect(next.get('prev')).to.equal(command) + }) + + it('works with start boundary index', () => { + const command = queue.insert(0, createCommand({ name: 'eq' })) + const next = queue.at(1) + + expect(queue.names()).to.eql(['eq', 'get', 'find', 'click']) + expect(command.get('prev')).to.be.undefined + expect(command.get('next')).to.equal(next) + expect(next.get('prev')).to.equal(command) + }) + + it('works with end boundary index', () => { + const command = queue.insert(3, createCommand({ name: 'eq' })) + const prev = queue.at(2) + + expect(queue.names()).to.eql(['get', 'find', 'click', 'eq']) + expect(command.get('prev')).to.equal(prev) + expect(command.get('next')).to.be.undefined + expect(prev.get('next')).to.equal(command) + }) + }) + + context('#slice', () => { + it('returns commands from the index', () => { + const commands = queue.slice(1) + + expect(_.invokeMap(commands, 'get', 'name')).to.eql(['find', 'click']) + }) + }) + + context('#at', () => { + it('returns command at index', () => { + const command = queue.at(1) + + expect(command.get('name')).to.equal('find') + }) + }) + + context('#find', () => { + it('returns command that matches attributes', () => { + const command = queue.find({ name: 'click' }) + + expect(command.get('name')).to.equal('click') + }) + }) + + context('#reset', () => { + it('resets the queue stopped state', () => { + queue.reset() + + expect(queue.stopped).to.be.false + }) + }) + + context('#clear', () => { + it('removes all commands from queue', () => { + queue.stop() + queue.clear() + + expect(queue.get().length).to.equal(0) + }) + }) + + context('#stop', () => { + it('stops the queue', () => { + queue.stop() + + expect(queue.stopped).to.be.true + }) + }) + + context('.length', () => { + it('is the number of commands in the queue', () => { + expect(queue.length).to.equal(3) + queue.insert(0, createCommand({ name: 'eq' })) + expect(queue.length).to.equal(4) + }) + }) + + context('.stopped', () => { + it('is true when queue is stopped', () => { + queue.stop() + + expect(queue.stopped).to.true + }) + + it('is false when queue is not stopped', () => { + expect(queue.stopped).to.false + }) + }) +}) diff --git a/packages/driver/cypress/integration/util/queue_spec.ts b/packages/driver/cypress/integration/util/queue_spec.ts new file mode 100644 index 000000000000..82e661c9bf49 --- /dev/null +++ b/packages/driver/cypress/integration/util/queue_spec.ts @@ -0,0 +1,204 @@ +import Bluebird from 'bluebird' + +import { create } from '../../../src/util/queue' + +const ids = (queueables) => queueables.map((q) => q.id) + +describe('src/util/queue', () => { + let queue + + beforeEach(() => { + queue = create([ + { id: '1' }, + { id: '2' }, + { id: '3' }, + ]) + }) + + context('#get', () => { + it('returns list of queueable data', () => { + const queueables = queue.get() + + expect(ids(queueables)).to.eql(['1', '2', '3']) + }) + }) + + context('#add', () => { + it('adds queueable to end of queue', () => { + queue.add({ id: '4' }) + queue.add({ id: '5' }) + + expect(ids(queue.get())).to.eql(['1', '2', '3', '4', '5']) + }) + }) + + context('#insert', () => { + it('inserts queueable into queue at index', () => { + queue.insert(1, { id: '4' }) + + expect(ids(queue.get())).to.eql(['1', '4', '2', '3']) + }) + + it('returns the queueable', () => { + const queueable = { id: '4' } + const result = queue.insert(1, queueable) + + expect(result).to.equal(queueable) + }) + + it('works with start boundary index', () => { + queue.insert(0, { id: '4' }) + + expect(ids(queue.get())).to.eql(['4', '1', '2', '3']) + }) + + it('works with end boundary index', () => { + queue.insert(3, { id: '4' }) + + expect(ids(queue.get())).to.eql(['1', '2', '3', '4']) + }) + + it('throws when index is negative', () => { + expect(() => { + queue.insert(-1, { id: '4' }) + }) + .to.throw('queue.insert must be called with a valid index - the index (-1) is out of bounds') + }) + + it('throws when index is out of bounds', () => { + expect(() => { + queue.insert(4, { id: '4' }) + }) + .to.throw('queue.insert must be called with a valid index - the index (4) is out of bounds') + }) + }) + + context('#slice', () => { + it('returns queueables data from the index', () => { + const queueables = queue.slice(1) + + expect(ids(queueables)).to.eql(['2', '3']) + }) + }) + + context('#at', () => { + it('returns queueable data at index', () => { + const queueable = queue.at(1) + + expect(queueable.id).to.equal('2') + }) + }) + + context('#clear', () => { + it('removes all queueables from queue', () => { + queue.clear() + + expect(queue.get().length).to.equal(0) + }) + }) + + context('#reset', () => { + it('resets queue.stopped to false', () => { + queue.stop() + queue.reset() + + expect(queue.stopped).to.false + }) + }) + + context('#stop', () => { + it('sets queue.stopped to true', () => { + queue.stop() + + expect(queue.stopped).to.true + }) + }) + + context('#run', () => { + let props + + beforeEach(() => { + props = { + onRun: cy.stub(), + onError: cy.stub(), + onFinish: cy.stub(), + } + }) + + it('runs the onRun function', () => { + return queue.run(props).promise.then(() => { + expect(props.onRun).to.be.called + }) + }) + + it('returns the promise and the cancel and reject functions', () => { + const result = queue.run(props) + + expect(result.promise).to.be.an.instanceOf(Bluebird) + expect(result.cancel).to.be.a('function') + expect(result.reject).to.be.a('function') + }) + + it('calls onError if onRun errors', () => { + const expectedErr = new Error('onRun failed') + + props.onRun.throws(expectedErr) + + return queue.run(props).promise.then(() => { + expect(props.onError).to.be.calledWith(expectedErr) + }) + }) + + it('calls onError when outer promise is rejected', () => { + const expectedErr = new Error('rejected') + + // hold up running with a never-resolving promise + // giving us time to reject the outer promise + props.onRun = () => { + return new Promise(() => {}) + } + + const { promise, reject } = queue.run(props) + + reject(expectedErr) + + return promise.then(() => { + expect(props.onError).to.be.calledWith(expectedErr) + }) + }) + + it('calls onFinish if it succeeds', () => { + return queue.run(props).promise.then(() => { + expect(props.onFinish).to.be.called + }) + }) + + it('calls onFinish if it fails', () => { + props.onRun.throws(new Error('fails')) + + return queue.run(props).promise.then(() => { + expect(props.onFinish).to.be.called + }) + }) + }) + + context('.length', () => { + it('is the number of queueables in the queue', () => { + expect(queue.length).to.equal(3) + queue.insert(0, { id: '4' }) + expect(queue.length).to.equal(4) + }) + }) + + context('.stopped', () => { + it('is true when queue is stopped', () => { + queue.stop() + + expect(queue.stopped).to.true + }) + + it('is false when queue is not stopped', () => { + expect(queue.stopped).to.false + }) + }) +}) diff --git a/packages/driver/src/cy/assertions.js b/packages/driver/src/cy/assertions.js index 7c35be95f8cb..51ce7c88d0c8 100644 --- a/packages/driver/src/cy/assertions.js +++ b/packages/driver/src/cy/assertions.js @@ -76,7 +76,7 @@ const create = function (Cypress, cy) { const assertions = [] // grab the rest of the queue'd commands - for (let cmd of cy.queue.slice(index).get()) { + for (let cmd of cy.queue.slice(index)) { // don't break on utilities, just skip over them if (cmd.is('utility')) { continue diff --git a/packages/driver/src/cy/commands/misc.js b/packages/driver/src/cy/commands/misc.js index 364fbd1a8088..a85022a00a12 100644 --- a/packages/driver/src/cy/commands/misc.js +++ b/packages/driver/src/cy/commands/misc.js @@ -1,6 +1,7 @@ const _ = require('lodash') const Promise = require('bluebird') +const $Command = require('../../cypress/command') const $dom = require('../../dom') const $errUtils = require('../../cypress/error_utils') @@ -24,11 +25,11 @@ module.exports = (Commands, Cypress, cy, state) => { if (state('current').get('injected')) { const restoreCmdIndex = state('index') + 1 - cy.queue.splice(restoreCmdIndex, 0, { + cy.queue.insert(restoreCmdIndex, $Command.create({ args: [state('subject')], name: 'log-restore', fn: (subject) => subject, - }) + })) state('index', restoreCmdIndex) } diff --git a/packages/driver/src/cy/commands/querying.js b/packages/driver/src/cy/commands/querying.js index 0a0279bcbfa2..4208028fd5af 100644 --- a/packages/driver/src/cy/commands/querying.js +++ b/packages/driver/src/cy/commands/querying.js @@ -1,6 +1,7 @@ const _ = require('lodash') const Promise = require('bluebird') +const $Command = require('../../cypress/command') const $dom = require('../../dom') const $elements = require('../../dom/elements') const $errUtils = require('../../cypress/error_utils') @@ -635,11 +636,11 @@ module.exports = (Commands, Cypress, cy, state) => { // commands inside within() callback and commands chained to it. const restoreCmdIndex = state('index') + 1 - cy.queue.splice(restoreCmdIndex, 0, { + cy.queue.insert(restoreCmdIndex, $Command.create({ args: [subject], name: 'within-restore', fn: (subject) => subject, - }) + })) state('index', restoreCmdIndex) diff --git a/packages/driver/src/cy/commands/sessions.ts b/packages/driver/src/cy/commands/sessions.ts index 77ca8896cb0c..fbe93ae06cad 100644 --- a/packages/driver/src/cy/commands/sessions.ts +++ b/packages/driver/src/cy/commands/sessions.ts @@ -686,7 +686,12 @@ export default function (Commands, Cypress, cy) { } cy.state('onCommandFailed', (err, queue, next) => { - const index = _.findIndex(queue.commands, (v: any) => _commandToResume && v.attributes.chainerId === _commandToResume.chainerId) + const index = _.findIndex(queue.get(), (command: any) => { + return ( + _commandToResume + && command.attributes.chainerId === _commandToResume.chainerId + ) + }) // attach codeframe and cleanse the stack trace since we will not hit the cy.fail callback // if this is the first time validate fails diff --git a/packages/driver/src/cy/net-stubbing/events/response.ts b/packages/driver/src/cy/net-stubbing/events/response.ts index 8220899d3c38..846799848257 100644 --- a/packages/driver/src/cy/net-stubbing/events/response.ts +++ b/packages/driver/src/cy/net-stubbing/events/response.ts @@ -77,7 +77,17 @@ export const onResponse: HandlerFn = async (Cyp // arguments to res.send() are merged with the existing response const _staticResponse = _.defaults({}, staticResponse, _.pick(res, STATIC_RESPONSE_KEYS)) - _.defaults(_staticResponse.headers, res.headers) + _staticResponse.headers = _.defaults({}, _staticResponse.headers, res.headers) + + // https://github.com/cypress-io/cypress/issues/17084 + // When a user didn't provide content-type, + // and they provided body as an object, + // we remove the content-type provided by the server + if (!staticResponse.headers || !staticResponse.headers['content-type']) { + if (typeof _staticResponse.body === 'object') { + delete _staticResponse.headers['content-type'] + } + } sendStaticResponse(requestId, _staticResponse) diff --git a/packages/driver/src/cy/net-stubbing/static-response-utils.ts b/packages/driver/src/cy/net-stubbing/static-response-utils.ts index 3e67fab322d7..7e5961e7af45 100644 --- a/packages/driver/src/cy/net-stubbing/static-response-utils.ts +++ b/packages/driver/src/cy/net-stubbing/static-response-utils.ts @@ -5,6 +5,9 @@ import { BackendStaticResponseWithArrayBuffer, FixtureOpts, } from '@packages/net-stubbing/lib/types' +import { + caseInsensitiveHas, +} from '@packages/net-stubbing/lib/util' import * as $errUtils from '../../cypress/error_utils' // user-facing StaticResponse only @@ -112,7 +115,16 @@ export function getBackendStaticResponse (staticResponse: Readonly { - return matchesFilters(log.get()) - }) - } - - return logs - } - - add (obj) { - if (utils.isInstanceOf(obj, $Command)) { - return obj - } - - return $Command.create(obj) - } - - get () { - return this.commands - } - - names () { - return this.invokeMap('get', 'name') - } - - splice (start, end, obj) { - const cmd = this.add(obj) - - this.commands.splice(start, end, cmd) - - const prev = this.at(start - 1) - const next = this.at(start + 1) - - if (prev) { - prev.set('next', cmd) - cmd.set('prev', prev) - } - - if (next) { - next.set('prev', cmd) - cmd.set('next', next) - } - - return cmd - } - - slice (...args) { - const cmds = this.commands.slice.apply(this.commands, args) - - return $CommandQueue.create(cmds) - } - - at (index) { - return this.commands[index] - } - - _filterByAttrs (attrs, method) { - const matchesAttrs = _.matches(attrs) - - return _[method](this.commands, (command) => { - return matchesAttrs(command.attributes) - }) - } - - filter (attrs) { - return this._filterByAttrs(attrs, 'filter') - } - - find (attrs) { - return this._filterByAttrs(attrs, 'find') - } - - toJSON () { - return this.invokeMap('toJSON') - } - - reset () { - this.commands.splice(0, this.commands.length) - - return this - } - - static create (cmds) { - return new $CommandQueue(cmds) - } -} - -Object.defineProperty($CommandQueue.prototype, 'length', { - get () { - return this.commands.length - }, -}) - -// mixin lodash methods -_.each(['invokeMap', 'map', 'first', 'reduce', 'reject', 'last', 'indexOf', 'each'], (method) => { - return $CommandQueue.prototype[method] = function (...args) { - args.unshift(this.commands) - - return _[method].apply(_, args) - } -}) - -module.exports = $CommandQueue diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts new file mode 100644 index 000000000000..fc3cd15fa9f9 --- /dev/null +++ b/packages/driver/src/cypress/command_queue.ts @@ -0,0 +1,394 @@ +import _ from 'lodash' +import $ from 'jquery' +import Bluebird from 'bluebird' +import Debug from 'debug' + +import { create as createQueue } from '../util/queue' +import $dom from '../dom' +import $utils from './utils' +import * as $errUtils from './error_utils' + +const debugErrors = Debug('cypress:driver:errors') + +interface Command { + get(key: string): any + get(): any + set(key: string, value: any): any + set(options: any): any + attributes: object + finishLogs(): void +} + +const __stackReplacementMarker = (fn, ctx, args) => { + return fn.apply(ctx, args) +} + +const commandRunningFailed = (Cypress, state, err) => { + // allow for our own custom onFail function + if (err.onFail) { + err.onFail(err) + + // clean up this onFail callback after it's been called + delete err.onFail + + return + } + + const current = state('current') + + return Cypress.log({ + end: true, + snapshot: true, + error: err, + consoleProps () { + if (!current) return + + const consoleProps = {} + const prev = current.get('prev') + + if (current.get('type') === 'parent' || !prev) return + + // if type isn't parent then we know its dual or child + // and we can add Applied To if there is a prev command + // and it is a parent + consoleProps['Applied To'] = $dom.isElement(prev.get('subject')) ? + $dom.getElements(prev.get('subject')) : + prev.get('subject') + + return consoleProps + }, + }) +} + +export const create = (state, timeouts, stability, cleanup, fail, isCy) => { + const queue = createQueue() + + const { get, slice, at, reset, clear, stop } = queue + + const logs = (filter) => { + let logs = _.flatten(_.invokeMap(queue.get(), 'get', 'logs')) + + if (filter) { + const matchesFilter = _.matches(filter) + + logs = _.filter(logs, (log) => { + return matchesFilter(log.get()) + }) + } + + return logs + } + + const names = () => { + return _.invokeMap(queue.get(), 'get', 'name') + } + + const add = (command) => { + queue.add(command) + } + + const insert = (index: number, command: Command) => { + queue.insert(index, command) + + const prev = at(index - 1) as Command + const next = at(index + 1) as Command + + if (prev) { + prev.set('next', command) + command.set('prev', prev) + } + + if (next) { + next.set('prev', command) + command.set('next', next) + } + + return command + } + + const find = (attrs) => { + const matchesAttrs = _.matches(attrs) + + return _.find(queue.get(), (command: Command) => { + return matchesAttrs(command.attributes) + }) + } + + const runCommand = (command: Command) => { + // bail here prior to creating a new promise + // because we could have stopped / canceled + // prior to ever making it through our first + // command + if (queue.stopped) { + return + } + + state('current', command) + state('chainerId', command.get('chainerId')) + + return stability.whenStable(() => { + state('nestedIndex', state('index')) + + return command.get('args') + }) + .then((args) => { + // store this if we enqueue new commands + // to check for promise violations + let ret + let enqueuedCmd + + const commandEnqueued = (obj) => { + return enqueuedCmd = obj + } + + // only check for command enqueing when none + // of our args are functions else commands + // like cy.then or cy.each would always fail + // since they return promises and queue more + // new commands + if ($utils.noArgsAreAFunction(args)) { + Cypress.once('command:enqueued', commandEnqueued) + } + + // run the command's fn with runnable's context + try { + ret = __stackReplacementMarker(command.get('fn'), state('ctx'), args) + } catch (err) { + throw err + } finally { + // always remove this listener + Cypress.removeListener('command:enqueued', commandEnqueued) + } + + state('commandIntermediateValue', ret) + + // we cannot pass our cypress instance or our chainer + // back into bluebird else it will create a thenable + // which is never resolved + if (isCy(ret)) { + return null + } + + if (!(!enqueuedCmd || !$utils.isPromiseLike(ret))) { + return $errUtils.throwErrByPath( + 'miscellaneous.command_returned_promise_and_commands', { + args: { + current: command.get('name'), + called: enqueuedCmd.name, + }, + }, + ) + } + + if (!(!enqueuedCmd || !!_.isUndefined(ret))) { + ret = _.isFunction(ret) ? + ret.toString() : + $utils.stringify(ret) + + // if we got a return value and we enqueued + // a new command and we didn't return cy + // or an undefined value then throw + return $errUtils.throwErrByPath( + 'miscellaneous.returned_value_and_commands_from_custom_command', { + args: { + current: command.get('name'), + returned: ret, + }, + }, + ) + } + + return ret + }).then((subject) => { + state('commandIntermediateValue', undefined) + + // we may be given a regular array here so + // we need to re-wrap the array in jquery + // if that's the case if the first item + // in this subject is a jquery element. + // we want to do this because in 3.1.2 there + // was a regression when wrapping an array of elements + const firstSubject = $utils.unwrapFirst(subject) + + // if ret is a DOM element and its not an instance of our own jQuery + if (subject && $dom.isElement(firstSubject) && !$utils.isInstanceOf(subject, $)) { + // set it back to our own jquery object + // to prevent it from being passed downstream + // TODO: enable turning this off + // wrapSubjectsInJquery: false + // which will just pass subjects downstream + // without modifying them + subject = $dom.wrap(subject) + } + + command.set({ subject }) + + // end / snapshot our logs + // if they need it + command.finishLogs() + + // reset the nestedIndex back to null + state('nestedIndex', null) + + // also reset recentlyReady back to null + state('recentlyReady', null) + + // we're finished with the current command + // so set it back to null + state('current', null) + + state('subject', subject) + + return subject + }) + } + + const run = () => { + const next = () => { + // bail if we've been told to abort in case + // an old command continues to run after + if (queue.stopped) { + return + } + + // start at 0 index if we dont have one + let index = state('index') || state('index', 0) + + const command = at(index) as Command + + // if the command should be skipped + // just bail and increment index + // and set the subject + if (command && command.get('skip')) { + // must set prev + next since other + // operations depend on this state being correct + command.set({ + prev: at(index - 1) as Command, + next: at(index + 1) as Command, + }) + + state('index', index + 1) + state('subject', command.get('subject')) + + return next() + } + + // if we're at the very end + if (!command) { + // trigger queue is almost finished + Cypress.action('cy:command:queue:before:end') + + // we need to wait after all commands have + // finished running if the application under + // test is no longer stable because we cannot + // move onto the next test until its finished + return stability.whenStable(() => { + Cypress.action('cy:command:queue:end') + + return null + }) + } + + // store the previous timeout + const prevTimeout = timeouts.timeout() + + // store the current runnable + const runnable = state('runnable') + + Cypress.action('cy:command:start', command) + + return runCommand(command) + .then(() => { + // each successful command invocation should + // always reset the timeout for the current runnable + // unless it already has a state. if it has a state + // and we reset the timeout again, it will always + // cause a timeout later no matter what. by this time + // mocha expects the test to be done + let fn + + if (!runnable.state) { + timeouts.timeout(prevTimeout) + } + + // mutate index by incrementing it + // this allows us to keep the proper index + // in between different hooks like before + beforeEach + // else run will be called again and index would start + // over at 0 + index += 1 + state('index', index) + + Cypress.action('cy:command:end', command) + + fn = state('onPaused') + + if (fn) { + return new Bluebird((resolve) => { + return fn(resolve) + }).then(next) + } + + return next() + }) + } + + const onError = (err: Error | string) => { + if (state('onCommandFailed')) { + return state('onCommandFailed')(err, queue, next) + } + + debugErrors('caught error in promise chain: %o', err) + + // since this failed this means that a specific command failed + // and we should highlight it in red or insert a new command + if (_.isObject(err)) { + // @ts-ignore + err.name = err.name || 'CypressError' + } + + commandRunningFailed(Cypress, state, err) + + return fail(err) + } + + const { promise, reject, cancel } = queue.run({ + onRun: next, + onError, + onFinish: cleanup, + }) + + state('promise', promise) + state('reject', reject) + state('cancel', () => { + cancel() + + Cypress.action('cy:canceled') + }) + + return promise + } + + return { + logs, + names, + add, + insert, + find, + run, + get, + slice, + at, + reset, + clear, + stop, + + get length () { + return queue.length + }, + + get stopped () { + return queue.stopped + }, + } +} diff --git a/packages/driver/src/cypress/cy.js b/packages/driver/src/cypress/cy.js index 626a7cbc1c31..b66bdbb2e93b 100644 --- a/packages/driver/src/cypress/cy.js +++ b/packages/driver/src/cypress/cy.js @@ -1,6 +1,5 @@ /* eslint-disable prefer-rest-params */ const _ = require('lodash') -const $ = require('jquery') const Promise = require('bluebird') const debugErrors = require('debug')('cypress:driver:errors') @@ -27,20 +26,13 @@ const $Retries = require('../cy/retries') const $Stability = require('../cy/stability') const $selection = require('../dom/selection') const $Snapshots = require('../cy/snapshots') +const $Command = require('./command') const $CommandQueue = require('./command_queue') const $VideoRecorder = require('../cy/video-recorder') const $TestConfigOverrides = require('../cy/testConfigOverrides') const { registerFetch } = require('unfetch') -const noArgsAreAFunction = (args) => { - return !_.some(args, _.isFunction) -} - -const isPromiseLike = (ret) => { - return ret && _.isFunction(ret.then) -} - const returnedFalse = (result) => { return result === false } @@ -121,61 +113,16 @@ const setTopOnError = function (Cypress, cy) { top.__alreadySetErrorHandlers__ = true } -const commandRunningFailed = (Cypress, state, err) => { - // allow for our own custom onFail function - if (err.onFail) { - err.onFail(err) - - // clean up this onFail callback after it's been called - delete err.onFail - - return - } - - const current = state('current') - - return Cypress.log({ - end: true, - snapshot: true, - error: err, - consoleProps () { - if (!current) return - - const obj = {} - const prev = current.get('prev') - - // if type isnt parent then we know its dual or child - // and we can add Applied To if there is a prev command - // and it is a parent - if (current.get('type') !== 'parent' && prev) { - const ret = $dom.isElement(prev.get('subject')) ? - $dom.getElements(prev.get('subject')) - : - prev.get('subject') - - obj['Applied To'] = ret - - return obj - } - }, - }) -} - // NOTE: this makes the cy object an instance // TODO: refactor the 'create' method below into this class class $Cy {} const create = function (specWindow, Cypress, Cookies, state, config, log) { let cy = new $Cy() - let stopped = false const commandFns = {} state('specWindow', specWindow) - const isStopped = () => { - return stopped - } - const onFinishAssertions = function () { return assertions.finishAssertions.apply(window, arguments) } @@ -196,11 +143,10 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { return $dom.query(selector, context) } - const queue = $CommandQueue.create() - $VideoRecorder.create(Cypress) const timeouts = $Timeouts.create(state) const stability = $Stability.create(Cypress, state) + const retries = $Retries.create(Cypress, state, timeouts.timeout, timeouts.clearTimeout, stability.whenStable, onFinishAssertions) const assertions = $Assertions.create(Cypress, cy) @@ -221,6 +167,10 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { const snapshots = $Snapshots.create($$, state) const testConfigOverrides = $TestConfigOverrides.create() + const isStopped = () => { + return queue.stopped + } + const isCy = (val) => { return (val === cy) || $utils.isInstanceOf(val, $Chainer) } @@ -354,7 +304,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { const enqueue = function (obj) { // if we have a nestedIndex it means we're processing - // nested commands and need to splice them into the + // nested commands and need to insert them into the // index past the current index as opposed to // pushing them to the end we also dont want to // reset the run defer because splicing means we're @@ -365,22 +315,21 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { // we had a bug that would bomb on custom commands when it was the // first command. this was due to nestedIndex being undefined at that // time. so we have to ensure to check that its any kind of number (even 0) - // in order to know to splice into the existing array. + // in order to know to insert it into the existing array. let nestedIndex = state('nestedIndex') - // if this is a number then we know - // we're about to splice this into our commands - // and need to reset next + increment the index + // if this is a number, then we know we're about to insert this + // into our commands and need to reset next + increment the index if (_.isNumber(nestedIndex)) { state('nestedIndex', (nestedIndex += 1)) } // we look at whether or not nestedIndex is a number, because if it - // is then we need to splice inside of our commands, else just push + // is then we need to insert inside of our commands, else just push // it onto the end of the queu const index = _.isNumber(nestedIndex) ? nestedIndex : queue.length - queue.splice(index, 0, obj) + queue.insert(index, $Command.create(obj)) return Cypress.action('cy:command:enqueued', obj) } @@ -401,298 +350,6 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { return getCommandsUntilFirstParentOrValidSubject(command.get('prev'), memo) } - const runCommand = function (command) { - // bail here prior to creating a new promise - // because we could have stopped / canceled - // prior to ever making it through our first - // command - if (stopped) { - return - } - - state('current', command) - state('chainerId', command.get('chainerId')) - - return stability.whenStable(() => { - // TODO: handle this event - // @trigger "invoke:start", command - - state('nestedIndex', state('index')) - - return command.get('args') - }) - - .then((args) => { - // store this if we enqueue new commands - // to check for promise violations - let ret - let enqueuedCmd = null - - const commandEnqueued = (obj) => { - return enqueuedCmd = obj - } - - // only check for command enqueing when none - // of our args are functions else commands - // like cy.then or cy.each would always fail - // since they return promises and queue more - // new commands - if (noArgsAreAFunction(args)) { - Cypress.once('command:enqueued', commandEnqueued) - } - - // run the command's fn with runnable's context - try { - ret = __stackReplacementMarker(command.get('fn'), state('ctx'), args) - } catch (err) { - throw err - } finally { - // always remove this listener - Cypress.removeListener('command:enqueued', commandEnqueued) - } - - state('commandIntermediateValue', ret) - - // we cannot pass our cypress instance or our chainer - // back into bluebird else it will create a thenable - // which is never resolved - if (isCy(ret)) { - return null - } - - if (!(!enqueuedCmd || !isPromiseLike(ret))) { - return $errUtils.throwErrByPath( - 'miscellaneous.command_returned_promise_and_commands', { - args: { - current: command.get('name'), - called: enqueuedCmd.name, - }, - }, - ) - } - - if (!(!enqueuedCmd || !!_.isUndefined(ret))) { - // TODO: clean this up in the utility function - // to conditionally stringify functions - ret = _.isFunction(ret) ? - ret.toString() - : - $utils.stringify(ret) - - // if we got a return value and we enqueued - // a new command and we didn't return cy - // or an undefined value then throw - return $errUtils.throwErrByPath( - 'miscellaneous.returned_value_and_commands_from_custom_command', { - args: { - current: command.get('name'), - returned: ret, - }, - }, - ) - } - - return ret - }).then((subject) => { - state('commandIntermediateValue', undefined) - - // we may be given a regular array here so - // we need to re-wrap the array in jquery - // if that's the case if the first item - // in this subject is a jquery element. - // we want to do this because in 3.1.2 there - // was a regression when wrapping an array of elements - const firstSubject = $utils.unwrapFirst(subject) - - // if ret is a DOM element and its not an instance of our own jQuery - if (subject && $dom.isElement(firstSubject) && !$utils.isInstanceOf(subject, $)) { - // set it back to our own jquery object - // to prevent it from being passed downstream - // TODO: enable turning this off - // wrapSubjectsInJquery: false - // which will just pass subjects downstream - // without modifying them - subject = $dom.wrap(subject) - } - - command.set({ subject }) - - // end / snapshot our logs - // if they need it - command.finishLogs() - - // reset the nestedIndex back to null - state('nestedIndex', null) - - // also reset recentlyReady back to null - state('recentlyReady', null) - - // we're finished with the current command - // so set it back to null - state('current', null) - - state('subject', subject) - - return subject - }) - } - - const run = function () { - const next = function () { - // bail if we've been told to abort in case - // an old command continues to run after - if (stopped) { - return - } - - // start at 0 index if we dont have one - let index = state('index') || state('index', 0) - - const command = queue.at(index) - - // if the command should be skipped - // just bail and increment index - // and set the subject - // TODO DRY THIS LOGIC UP - if (command && command.get('skip')) { - // must set prev + next since other - // operations depend on this state being correct - command.set({ prev: queue.at(index - 1), next: queue.at(index + 1) }) - state('index', index + 1) - state('subject', command.get('subject')) - - return next() - } - - // if we're at the very end - if (!command) { - // trigger queue is almost finished - Cypress.action('cy:command:queue:before:end') - - // we need to wait after all commands have - // finished running if the application under - // test is no longer stable because we cannot - // move onto the next test until its finished - return stability.whenStable(() => { - Cypress.action('cy:command:queue:end') - - return null - }) - } - - // store the previous timeout - const prevTimeout = timeouts.timeout() - - // store the current runnable - const runnable = state('runnable') - - Cypress.action('cy:command:start', command) - - return runCommand(command) - .then(() => { - // each successful command invocation should - // always reset the timeout for the current runnable - // unless it already has a state. if it has a state - // and we reset the timeout again, it will always - // cause a timeout later no matter what. by this time - // mocha expects the test to be done - let fn - - if (!runnable.state) { - timeouts.timeout(prevTimeout) - } - - // mutate index by incrementing it - // this allows us to keep the proper index - // in between different hooks like before + beforeEach - // else run will be called again and index would start - // over at 0 - state('index', (index += 1)) - - Cypress.action('cy:command:end', command) - - fn = state('onPaused') - - if (fn) { - return new Promise((resolve) => { - return fn(resolve) - }).then(next) - } - - return next() - }) - } - - let inner = null - - // this ends up being the parent promise wrapper - const promise = new Promise((resolve, reject) => { - // bubble out the inner promise - // we must use a resolve(null) here - // so the outer promise is first defined - // else this will kick off the 'next' call - // too soon and end up running commands prior - // to promise being defined - inner = Promise - .resolve(null) - .then(next) - .then(resolve) - .catch(reject) - - // can't use onCancel argument here because - // its called asynchronously - - // when we manually reject our outer promise we - // have to immediately cancel the inner one else - // it won't be notified and its callbacks will - // continue to be invoked - // normally we don't have to do this because rejections - // come from the inner promise and bubble out to our outer - // - // but when we manually reject the outer promise we - // have to go in the opposite direction from outer -> inner - const rejectOuterAndCancelInner = function (err) { - inner.cancel() - - return reject(err) - } - - state('resolve', resolve) - state('reject', rejectOuterAndCancelInner) - }) - .catch((err) => { - if (state('onCommandFailed')) { - return state('onCommandFailed')(err, queue, next) - } - - debugErrors('caught error in promise chain: %o', err) - - // since this failed this means that a - // specific command failed and we should - // highlight it in red or insert a new command - err.name = err.name || 'CypressError' - commandRunningFailed(Cypress, state, err) - - return fail(err) - }) - .finally(cleanup) - - // cancel both promises - const cancel = function () { - promise.cancel() - inner.cancel() - - // notify the world - return Cypress.action('cy:canceled') - } - - state('cancel', cancel) - state('promise', promise) - - // return this outer bluebird promise - return promise - } - const removeSubject = () => { return state('subject', undefined) } @@ -736,7 +393,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { } const doneEarly = function () { - stopped = true + queue.stop() // we only need to worry about doneEarly when // it comes from a manual event such as stopping @@ -795,7 +452,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { let rets - stopped = true + queue.stop() if (typeof err === 'string') { err = new Error(err) @@ -875,6 +532,8 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { return finish(err) } + const queue = $CommandQueue.create(state, timeouts, stability, cleanup, fail, isCy) + _.extend(cy, { id: _.uniqueId('cy'), @@ -1032,7 +691,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { stop () { // don't do anything if we've already stopped - if (stopped) { + if (queue.stopped) { return } @@ -1040,8 +699,6 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { }, reset (attrs, test) { - stopped = false - const s = state() const backup = { @@ -1059,6 +716,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { state(backup) queue.reset() + queue.clear() timers.reset() testConfigOverrides.restoreAndSetTestConfigOverrides(test, Cypress.config, Cypress.env) @@ -1130,7 +788,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { const current = state('current') // if this is a custom promise - if (isPromiseLike(ret) && noArgsAreAFunction(current.get('args'))) { + if ($utils.isPromiseLike(ret) && $utils.noArgsAreAFunction(current.get('args'))) { $errUtils.throwErrByPath( 'miscellaneous.command_returned_promise_and_commands', { args: { @@ -1149,7 +807,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { warnMixingPromisesAndCommands() } - run() + queue.run() } return chain @@ -1326,7 +984,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { setRunnable (runnable, hookId) { // when we're setting a new runnable // prepare to run again! - stopped = false + queue.reset() // reset the promise again state('promise', undefined) @@ -1397,7 +1055,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { if (ret && (queue.length > currentLength) && (!isCy(ret)) && - (!isPromiseLike(ret))) { + (!$utils.isPromiseLike(ret))) { // TODO: clean this up in the utility function // to conditionally stringify functions ret = _.isFunction(ret) ? @@ -1423,7 +1081,7 @@ const create = function (specWindow, Cypress, Cookies, state, config, log) { } // if we returned a promise like object - if ((!isCy(ret)) && isPromiseLike(ret)) { + if ((!isCy(ret)) && $utils.isPromiseLike(ret)) { // indicate we've returned a custom promise state('returnedCustomPromise', true) diff --git a/packages/driver/src/cypress/utils.js b/packages/driver/src/cypress/utils.js index 51fecc3d5a71..3e19a7cff576 100644 --- a/packages/driver/src/cypress/utils.js +++ b/packages/driver/src/cypress/utils.js @@ -399,4 +399,12 @@ module.exports = { return String.fromCharCode(`0x${p1}`) })) }, + + noArgsAreAFunction (args) { + return !_.some(args, _.isFunction) + }, + + isPromiseLike (ret) { + return ret && _.isFunction(ret.then) + }, } diff --git a/packages/driver/src/util/queue.ts b/packages/driver/src/util/queue.ts new file mode 100644 index 000000000000..18783a3d1232 --- /dev/null +++ b/packages/driver/src/util/queue.ts @@ -0,0 +1,114 @@ +import Bluebird from 'bluebird' + +interface QueueRunProps { + onRun: () => Bluebird | Promise + onError: (err: Error) => void + onFinish: () => void +} + +export const create = (queueables: T[] = []) => { + let stopped = false + + const get = (): T[] => { + return queueables + } + + const add = (queueable: T) => { + queueables.push(queueable) + } + + const insert = (index: number, queueable: T) => { + if (index < 0 || index > queueables.length) { + throw new Error(`queue.insert must be called with a valid index - the index (${index}) is out of bounds`) + } + + queueables.splice(index, 0, queueable) + + return queueable + } + + const slice = (index: number) => { + return queueables.slice(index) + } + + const at = (index: number): T => { + return get()[index] + } + + const reset = () => { + stopped = false + } + + const clear = () => { + queueables.length = 0 + } + + const stop = () => { + stopped = true + } + + const run = ({ onRun, onError, onFinish }: QueueRunProps) => { + let inner + let rejectOuterAndCancelInner + + // this ends up being the parent promise wrapper + const promise = new Bluebird((resolve, reject) => { + // bubble out the inner promise. we must use a resolve(null) here + // so the outer promise is first defined else this will kick off + // the 'next' call too soon and end up running commands prior to + // the promise being defined + inner = Bluebird + .resolve(null) + .then(onRun) + .then(resolve) + .catch(reject) + + // can't use onCancel argument here because it's called asynchronously. + // when we manually reject our outer promise we have to immediately + // cancel the inner one else it won't be notified and its callbacks + // will continue to be invoked. normally we don't have to do this + // because rejections come from the inner promise and bubble out to + // our outer, but when we manually reject the outer promise, we + // have to go in the opposite direction from outer -> inner + rejectOuterAndCancelInner = (err) => { + inner.cancel() + reject(err) + } + }) + .catch(onError) + .finally(onFinish) + + const cancel = () => { + promise.cancel() + inner.cancel() + } + + return { + promise, + cancel, + // wrapped to ensure `rejectOuterAndCancelInner` is assigned + // before reject is called + reject: (err) => rejectOuterAndCancelInner(err), + } + } + + return { + get, + add, + insert, + slice, + at, + reset, + clear, + stop, + run, + + get length () { + return queueables.length + }, + + get stopped () { + return stopped + }, + } +} diff --git a/packages/electron/package.json b/packages/electron/package.json index 393751786549..29ee2d7b9449 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -24,7 +24,7 @@ "minimist": "1.2.5" }, "devDependencies": { - "electron": "12.0.0-beta.14", + "electron": "13.1.7", "execa": "4.1.0", "mocha": "3.5.3" }, diff --git a/packages/https-proxy/lib/ca.js b/packages/https-proxy/lib/ca.js index 47d008ed385a..d26612daa109 100644 --- a/packages/https-proxy/lib/ca.js +++ b/packages/https-proxy/lib/ca.js @@ -20,7 +20,6 @@ const { const generateKeyPairAsync = Promise.promisify(pki.rsa.generateKeyPair) const ipAddressRe = /^[\d\.]+$/ -const asterisksRe = /\*/g const CAattrs = [{ name: 'commonName', @@ -119,6 +118,10 @@ const ServerExtensions = [{ name: 'subjectKeyIdentifier', }] +function hostnameToFilename (hostname) { + return hostname.replace(/\*/g, '_') +} + class CA { constructor (caFolder) { if (!caFolder) { @@ -167,9 +170,9 @@ class CA { this.CAkeys = keys return Promise.all([ - fs.outputFileAsync(path.join(this.certsFolder, 'ca.pem'), pki.certificateToPem(cert)), - fs.outputFileAsync(path.join(this.keysFolder, 'ca.private.key'), pki.privateKeyToPem(keys.privateKey)), - fs.outputFileAsync(path.join(this.keysFolder, 'ca.public.key'), pki.publicKeyToPem(keys.publicKey)), + fs.outputFileAsync(this.getCACertPath(), pki.certificateToPem(cert)), + fs.outputFileAsync(this.getCAPrivateKeyPath(), pki.privateKeyToPem(keys.privateKey)), + fs.outputFileAsync(this.getCAPublicKeyPath(), pki.publicKeyToPem(keys.publicKey)), this.writeCAVersion(), ]) }) @@ -177,9 +180,9 @@ class CA { loadCA () { return Promise.props({ - certPEM: fs.readFileAsync(path.join(this.certsFolder, 'ca.pem'), 'utf-8'), - keyPrivatePEM: fs.readFileAsync(path.join(this.keysFolder, 'ca.private.key'), 'utf-8'), - keyPublicPEM: fs.readFileAsync(path.join(this.keysFolder, 'ca.public.key'), 'utf-8'), + certPEM: fs.readFileAsync(this.getCACertPath(), 'utf-8'), + keyPrivatePEM: fs.readFileAsync(this.getCAPrivateKeyPath(), 'utf-8'), + keyPublicPEM: fs.readFileAsync(this.getCAPublicKeyPath(), 'utf-8'), }) .then((results) => { this.CAcert = pki.certificateFromPem(results.certPEM) @@ -231,29 +234,59 @@ class CA { const keyPrivatePem = pki.privateKeyToPem(keysServer.privateKey) const keyPublicPem = pki.publicKeyToPem(keysServer.publicKey) - const dest = mainHost.replace(asterisksRe, '_') + const baseFilename = hostnameToFilename(mainHost) return Promise.all([ - fs.outputFileAsync(path.join(this.certsFolder, `${dest}.pem`), certPem), - fs.outputFileAsync(path.join(this.keysFolder, `${dest}.key`), keyPrivatePem), - fs.outputFileAsync(path.join(this.keysFolder, `${dest}.public.key`), keyPublicPem), + fs.outputFileAsync(this.getCertPath(baseFilename), certPem), + fs.outputFileAsync(this.getPrivateKeyPath(baseFilename), keyPrivatePem), + fs.outputFileAsync(this.getPublicKeyPath(baseFilename), keyPublicPem), ]) .return([certPem, keyPrivatePem]) } + clearDataForHostname (hostname) { + const baseFilename = hostnameToFilename(hostname) + + return Promise.all([ + fs.remove(this.getCertPath(baseFilename)), + fs.remove(this.getPrivateKeyPath(baseFilename)), + fs.remove(this.getPublicKeyPath(baseFilename)), + ]) + } + getCertificateKeysForHostname (hostname) { - const dest = hostname.replace(asterisksRe, '_') + const baseFilename = hostnameToFilename(hostname) return Promise.all([ - fs.readFileAsync(path.join(this.certsFolder, `${dest}.pem`)), - fs.readFileAsync(path.join(this.keysFolder, `${dest}.key`)), + fs.readFileAsync(this.getCertPath(baseFilename)), + fs.readFileAsync(this.getPrivateKeyPath(baseFilename)), ]) } + getPrivateKeyPath (baseFilename) { + return path.join(this.keysFolder, `${baseFilename}.key`) + } + + getPublicKeyPath (baseFilename) { + return path.join(this.keysFolder, `${baseFilename}.public.key`) + } + + getCertPath (baseFilename) { + return path.join(this.certsFolder, `${baseFilename}.pem`) + } + getCACertPath () { return path.join(this.certsFolder, 'ca.pem') } + getCAPrivateKeyPath () { + return path.join(this.keysFolder, 'ca.private.key') + } + + getCAPublicKeyPath () { + return path.join(this.keysFolder, 'ca.public.key') + } + getCAVersionPath () { return path.join(this.baseCAFolder, 'ca_version.txt') } @@ -286,7 +319,7 @@ class CA { static create (caFolder) { const ca = new CA(caFolder) - return fs.statAsync(path.join(ca.certsFolder, 'ca.pem')) + return fs.statAsync(ca.getCACertPath()) .bind(ca) .then(ca.assertMinimumCAVersion) .tapCatch(ca.removeAll) diff --git a/packages/https-proxy/lib/server.js b/packages/https-proxy/lib/server.js index 8b22c33171ac..ccd2a373d236 100644 --- a/packages/https-proxy/lib/server.js +++ b/packages/https-proxy/lib/server.js @@ -183,6 +183,15 @@ class Server { } return this._getPortFor(hostname) + .catch(async (err) => { + debug('Error adding context, deleting certs and regenning %o', { hostname, err }) + + // files on disk can be corrupted, so try again + // @see https://github.com/cypress-io/cypress/issues/8705 + await this._ca.clearDataForHostname(hostname) + + return this._getPortFor(hostname) + }) .then((port) => { sslServers[hostname] = { port } diff --git a/packages/https-proxy/test/integration/proxy_spec.js b/packages/https-proxy/test/integration/proxy_spec.js index 1beeb2b7741c..f25bccf2c5f9 100644 --- a/packages/https-proxy/test/integration/proxy_spec.js +++ b/packages/https-proxy/test/integration/proxy_spec.js @@ -9,6 +9,7 @@ const Promise = require('bluebird') const proxy = require('../helpers/proxy') const httpServer = require('../helpers/http_server') const httpsServer = require('../helpers/https_server') +const fs = require('fs').promises describe('Proxy', () => { beforeEach(function () { @@ -149,6 +150,44 @@ describe('Proxy', () => { }) }) + // @see https://github.com/cypress-io/cypress/issues/8705 + it('handles errors with reusing existing certificates', async function () { + await this.proxy._ca.removeAll() + + proxy.reset() + const genSpy = this.sandbox.spy(this.proxy, '_generateMissingCertificates') + + await request({ + strictSSL: false, + url: 'https://localhost:8443/', + proxy: 'http://localhost:3333', + }) + + proxy.reset() + expect(genSpy).to.be.calledWith('localhost').and.calledOnce + + const privateKeyPath = this.proxy._ca.getPrivateKeyPath('localhost') + const key = (await fs.readFile(privateKeyPath)).toString().trim() + + expect(key).to.match(/^-----BEGIN RSA PRIVATE KEY-----/) + .and.match(/-----END RSA PRIVATE KEY-----$/) + + await fs.writeFile(privateKeyPath, 'some random garbage') + + await request({ + strictSSL: false, + url: 'https://localhost:8443/', + proxy: 'http://localhost:3333', + }) + + expect(genSpy).to.always.have.been.calledWith('localhost').and.calledTwice + + const key2 = (await fs.readFile(privateKeyPath)).toString().trim() + + expect(key2).to.match(/^-----BEGIN RSA PRIVATE KEY-----/) + .and.match(/-----END RSA PRIVATE KEY-----$/) + }) + // https://github.com/cypress-io/cypress/issues/771 it('generates certs and can proxy requests for HTTPS requests to IPs', function () { this.sandbox.spy(this.proxy, '_generateMissingCertificates') @@ -204,7 +243,6 @@ describe('Proxy', () => { }) }) - // TODO context('with an upstream proxy', () => { beforeEach(function () { // PROXY vars should override npm_config vars, so set them to cause failures if they are used diff --git a/packages/net-stubbing/lib/server/intercepted-request.ts b/packages/net-stubbing/lib/server/intercepted-request.ts index 3c00b779cc57..f9b1b7f6218b 100644 --- a/packages/net-stubbing/lib/server/intercepted-request.ts +++ b/packages/net-stubbing/lib/server/intercepted-request.ts @@ -153,6 +153,18 @@ export class InterceptedRequest { data, } + // https://github.com/cypress-io/cypress/issues/17139 + // Routes should be counted before they're sent. + if (eventName === 'before:request') { + const route = this.matchingRoutes.find(({ id }) => id === subscription.routeId) as BackendRoute + + route.matches++ + + if (route.routeMatcher.times && route.matches >= route.routeMatcher.times) { + route.disabled = true + } + } + const _emit = () => emit(this.socket, eventName, eventFrame) if (!subscription.await) { @@ -176,7 +188,7 @@ export class InterceptedRequest { } } - for (const { routeId, subscriptions, immediateStaticResponse } of this.subscriptionsByRoute) { + for (const { subscriptions, immediateStaticResponse } of this.subscriptionsByRoute) { for (const subscription of subscriptions) { await handleSubscription(subscription) @@ -186,14 +198,6 @@ export class InterceptedRequest { } if (eventName === 'before:request') { - const route = this.matchingRoutes.find(({ id }) => id === routeId) as BackendRoute - - route.matches++ - - if (route.routeMatcher.times && route.matches >= route.routeMatcher.times) { - route.disabled = true - } - if (immediateStaticResponse) { await sendStaticResponse(this, immediateStaticResponse) diff --git a/packages/net-stubbing/lib/server/util.ts b/packages/net-stubbing/lib/server/util.ts index 72dffffac264..9b36b058e46b 100644 --- a/packages/net-stubbing/lib/server/util.ts +++ b/packages/net-stubbing/lib/server/util.ts @@ -16,6 +16,7 @@ import ThrottleStream from 'throttle' import MimeTypes from 'mime-types' import { CypressIncomingRequest } from '@packages/proxy' import { InterceptedRequest } from './intercepted-request' +import { caseInsensitiveGet, caseInsensitiveHas } from '../util' // TODO: move this into net-stubbing once cy.route is removed import { parseContentType } from '@packages/server/lib/controllers/xhrs' @@ -79,24 +80,6 @@ function _getFakeClientResponse (opts: { return clientResponse } -const caseInsensitiveGet = function (obj, lowercaseProperty) { - for (let key of Object.keys(obj)) { - if (key.toLowerCase() === lowercaseProperty) { - return obj[key] - } - } -} - -const caseInsensitiveHas = function (obj, lowercaseProperty) { - for (let key of Object.keys(obj)) { - if (key.toLowerCase() === lowercaseProperty) { - return true - } - } - - return false -} - export function setDefaultHeaders (req: CypressIncomingRequest, res: IncomingMessage) { const setDefaultHeader = (lowercaseHeader: string, defaultValueFn: () => string) => { if (!caseInsensitiveHas(res.headers, lowercaseHeader)) { diff --git a/packages/net-stubbing/lib/util.ts b/packages/net-stubbing/lib/util.ts new file mode 100644 index 000000000000..e922fcc8c120 --- /dev/null +++ b/packages/net-stubbing/lib/util.ts @@ -0,0 +1,17 @@ +export const caseInsensitiveGet = function (obj, lowercaseProperty) { + for (let key of Object.keys(obj)) { + if (key.toLowerCase() === lowercaseProperty) { + return obj[key] + } + } +} + +export const caseInsensitiveHas = function (obj, lowercaseProperty) { + for (let key of Object.keys(obj)) { + if (key.toLowerCase() === lowercaseProperty) { + return true + } + } + + return false +} diff --git a/packages/server/lib/browsers/cdp_automation.ts b/packages/server/lib/browsers/cdp_automation.ts index 2f7b455b02af..a8e5dcef4215 100644 --- a/packages/server/lib/browsers/cdp_automation.ts +++ b/packages/server/lib/browsers/cdp_automation.ts @@ -19,6 +19,11 @@ export type CyCookie = Pick> - & Partial> + & Partial> export interface Cfg extends ReceivedCypressOptions { projectRoot: string @@ -675,7 +675,7 @@ export class ProjectBase extends EE { if (theCfg.browsers) { theCfg.browsers = theCfg.browsers?.map((browser) => { - if (browser.family === 'chromium') { + if (browser.family === 'chromium' || theCfg.chromeWebSecurity) { return browser } diff --git a/packages/server/lib/video_capture.js b/packages/server/lib/video_capture.js deleted file mode 100644 index c807d065113a..000000000000 --- a/packages/server/lib/video_capture.js +++ /dev/null @@ -1,348 +0,0 @@ -const _ = require('lodash') -const utils = require('fluent-ffmpeg/lib/utils') -const debug = require('debug')('cypress:server:video') -const ffmpeg = require('fluent-ffmpeg') -const stream = require('stream') -const Promise = require('bluebird') -const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path -const BlackHoleStream = require('black-hole-stream') -const { fs } = require('./util/fs') - -// extra verbose logs for logging individual frames -const debugFrames = require('debug')('cypress-verbose:server:video:frames') - -debug('using ffmpeg from %s', ffmpegPath) - -ffmpeg.setFfmpegPath(ffmpegPath) - -const deferredPromise = function () { - let reject - let resolve = (reject = null) - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve - reject = _reject - }) - - return { promise, resolve, reject } -} - -module.exports = { - generateFfmpegChaptersConfig (tests) { - if (!tests) { - return null - } - - const configString = tests.map((test) => { - return test.attempts.map((attempt, i) => { - const { videoTimestamp, wallClockDuration } = attempt - let title = test.title ? test.title.join(' ') : '' - - if (i > 0) { - title += `attempt ${i}` - } - - return [ - '[CHAPTER]', - 'TIMEBASE=1/1000', - `START=${videoTimestamp - wallClockDuration}`, - `END=${videoTimestamp}`, - `title=${title}`, - ].join('\n') - }).join('\n') - }).join('\n') - - return `;FFMETADATA1\n${configString}` - }, - - getMsFromDuration (duration) { - return utils.timemarkToSeconds(duration) * 1000 - }, - - getCodecData (src) { - return new Promise((resolve, reject) => { - return ffmpeg() - .on('stderr', (stderr) => { - return debug('get codecData stderr log %o', { message: stderr }) - }).on('codecData', resolve) - .input(src) - .format('null') - .output(new BlackHoleStream()) - .run() - }).tap((data) => { - return debug('codecData %o', { - src, - data, - }) - }).tapCatch((err) => { - return debug('getting codecData failed', { err }) - }) - }, - - getChapters (fileName) { - return new Promise((resolve, reject) => { - ffmpeg.ffprobe(fileName, ['-show_chapters'], (err, metadata) => { - if (err) { - return reject(err) - } - - resolve(metadata) - }) - }) - }, - - copy (src, dest) { - debug('copying from %s to %s', src, dest) - - return fs - .copyAsync(src, dest, { overwrite: true }) - .catch({ code: 'ENOENT' }, () => {}) - }, - // dont yell about ENOENT errors - - start (name, options = {}) { - const pt = stream.PassThrough() - const ended = deferredPromise() - let done = false - let wantsWrite = true - let skippedChunksCount = 0 - let writtenChunksCount = 0 - - _.defaults(options, { - onError () {}, - }) - - const endVideoCapture = function (waitForMoreChunksTimeout = 3000) { - debugFrames('frames written:', writtenChunksCount) - - // in some cases (webm) ffmpeg will crash if fewer than 2 buffers are - // written to the stream, so we don't end capture until we get at least 2 - if (writtenChunksCount < 2) { - return new Promise((resolve) => { - pt.once('data', resolve) - }) - .then(endVideoCapture) - .timeout(waitForMoreChunksTimeout) - } - - done = true - - pt.end() - - // return the ended promise which will eventually - // get resolve or rejected - return ended.promise - } - - const lengths = {} - - const writeVideoFrame = function (data) { - // make sure we haven't ended - // our stream yet because paint - // events can linger beyond - // finishing the actual video - if (done) { - return - } - - // when `data` is empty, it is sent as an empty Buffer (``) - // which can crash the process. this can happen if there are - // errors in the video capture process, which are handled later - // on, so just skip empty frames here. - // @see https://github.com/cypress-io/cypress/pull/6818 - if (_.isEmpty(data)) { - debugFrames('empty chunk received %o', data) - - return - } - - if (options.webmInput) { - if (lengths[data.length]) { - // this prevents multiple chunks of webm metadata from being written to the stream - // which would crash ffmpeg - debugFrames('duplicate length frame received:', data.length) - - return - } - - lengths[data.length] = true - } - - writtenChunksCount++ - - debugFrames('writing video frame') - - if (wantsWrite) { - if (!(wantsWrite = pt.write(data))) { - return pt.once('drain', () => { - debugFrames('video stream drained') - - wantsWrite = true - }) - } - } else { - skippedChunksCount += 1 - - return debugFrames('skipping video frame %o', { skipped: skippedChunksCount }) - } - } - - const startCapturing = () => { - return new Promise((resolve) => { - const cmd = ffmpeg({ - source: pt, - priority: 20, - }) - .videoCodec('libx264') - .outputOptions('-preset ultrafast') - .on('start', (command) => { - debug('capture started %o', { command }) - - return resolve({ - cmd, - startedVideoCapture: new Date, - }) - }).on('codecData', (data) => { - return debug('capture codec data: %o', data) - }).on('stderr', (stderr) => { - return debug('capture stderr log %o', { message: stderr }) - }).on('error', (err, stdout, stderr) => { - debug('capture errored: %o', { error: err.message, stdout, stderr }) - - // bubble errors up - options.onError(err, stdout, stderr) - - // reject the ended promise - return ended.reject(err) - }).on('end', () => { - debug('capture ended') - - return ended.resolve() - }) - - // this is to prevent the error "invalid data input" error - // when input frames have an odd resolution - .videoFilters(`crop='floor(in_w/2)*2:floor(in_h/2)*2'`) - - if (options.webmInput) { - cmd - .inputFormat('webm') - - // assume 18 fps. This number comes from manual measurement of avg fps coming from firefox. - // TODO: replace this with the 'vfr' option below when dropped frames issue is fixed. - .inputFPS(18) - - // 'vsync vfr' (variable framerate) works perfectly but fails on top page navigation - // since video timestamp resets to 0, timestamps already written will be dropped - // .outputOption('-vsync vfr') - } else { - cmd - .inputFormat('image2pipe') - .inputOptions('-use_wallclock_as_timestamps 1') - } - - return cmd.save(name) - }) - } - - return startCapturing() - .then(({ cmd, startedVideoCapture }) => { - return { - _pt: pt, - cmd, - endVideoCapture, - writeVideoFrame, - startedVideoCapture, - } - }) - }, - - async process (name, cname, videoCompression, ffmpegchaptersConfig, onProgress = function () {}) { - const metaFileName = `${name}.meta` - - const maybeGenerateMetaFile = Promise.method(() => { - if (!ffmpegchaptersConfig) { - return false - } - - // Writing the metadata to filesystem is necessary because fluent-ffmpeg is just a wrapper of ffmpeg command. - return fs.writeFile(metaFileName, ffmpegchaptersConfig).then(() => true) - }) - - const addChaptersMeta = await maybeGenerateMetaFile() - - let total = null - - return new Promise((resolve, reject) => { - debug('processing video from %s to %s video compression %o', - name, cname, videoCompression) - - const command = ffmpeg() - const outputOptions = [ - '-preset fast', - `-crf ${videoCompression}`, - ] - - if (addChaptersMeta) { - command.input(metaFileName) - outputOptions.push('-map_metadata 1') - } - - command.input(name) - .videoCodec('libx264') - .outputOptions(outputOptions) - // .videoFilters("crop='floor(in_w/2)*2:floor(in_h/2)*2'") - .on('start', (command) => { - debug('compression started %o', { command }) - }) - .on('codecData', (data) => { - debug('compression codec data: %o', data) - - total = utils.timemarkToSeconds(data.duration) - }) - .on('stderr', (stderr) => { - debug('compression stderr log %o', { message: stderr }) - }) - .on('progress', (progress) => { - // bail if we dont have total yet - if (!total) { - return - } - - debug('compression progress: %o', progress) - - const progressed = utils.timemarkToSeconds(progress.timemark) - - const percent = progressed / total - - if (percent < 1) { - return onProgress(percent) - } - }) - .on('error', (err, stdout, stderr) => { - debug('compression errored: %o', { error: err.message, stdout, stderr }) - - return reject(err) - }) - .on('end', () => { - debug('compression ended') - - // we are done progressing - onProgress(1) - - // rename and obliterate the original - return fs.moveAsync(cname, name, { - overwrite: true, - }) - .then(() => { - if (addChaptersMeta) { - return fs.unlink(metaFileName) - } - }) - .then(() => { - return resolve() - }) - }).save(cname) - }) - }, - -} diff --git a/packages/server/lib/video_capture.ts b/packages/server/lib/video_capture.ts new file mode 100644 index 000000000000..bccd7b4be876 --- /dev/null +++ b/packages/server/lib/video_capture.ts @@ -0,0 +1,362 @@ +import _ from 'lodash' +import utils from 'fluent-ffmpeg/lib/utils' +import Debug from 'debug' +import ffmpeg from 'fluent-ffmpeg' +import stream from 'stream' +import Bluebird from 'bluebird' +import { path as ffmpegPath } from '@ffmpeg-installer/ffmpeg' +import BlackHoleStream from 'black-hole-stream' +import { fs } from './util/fs' + +const debug = Debug('cypress:server:video') +// extra verbose logs for logging individual frames +const debugFrames = Debug('cypress-verbose:server:video:frames') + +debug('using ffmpeg from %s', ffmpegPath) + +ffmpeg.setFfmpegPath(ffmpegPath) + +const deferredPromise = function () { + let reject + let resolve + const promise = new Bluebird((_resolve, _reject) => { + resolve = _resolve + reject = _reject + }) + + return { promise, resolve, reject } +} + +export function generateFfmpegChaptersConfig (tests) { + if (!tests) { + return null + } + + const configString = tests.map((test) => { + return test.attempts.map((attempt, i) => { + const { videoTimestamp, wallClockDuration } = attempt + let title = test.title ? test.title.join(' ') : '' + + if (i > 0) { + title += `attempt ${i}` + } + + return [ + '[CHAPTER]', + 'TIMEBASE=1/1000', + `START=${videoTimestamp - wallClockDuration}`, + `END=${videoTimestamp}`, + `title=${title}`, + ].join('\n') + }).join('\n') + }).join('\n') + + return `;FFMETADATA1\n${configString}` +} + +export function getMsFromDuration (duration) { + return utils.timemarkToSeconds(duration) * 1000 +} + +export function getCodecData (src) { + return new Bluebird((resolve, reject) => { + return ffmpeg() + .on('stderr', (stderr) => { + return debug('get codecData stderr log %o', { message: stderr }) + }).on('codecData', resolve) + .input(src) + .format('null') + .output(new BlackHoleStream()) + .run() + }).tap((data) => { + return debug('codecData %o', { + src, + data, + }) + }).tapCatch((err) => { + return debug('getting codecData failed', { err }) + }) +} + +export function getChapters (fileName) { + return new Bluebird((resolve, reject) => { + ffmpeg.ffprobe(fileName, ['-show_chapters'], (err, metadata) => { + if (err) { + return reject(err) + } + + resolve(metadata) + }) + }) +} + +export function copy (src, dest) { + debug('copying from %s to %s', src, dest) + + return fs + .copy(src, dest, { overwrite: true }) + .catch((err) => { + if (err.code === 'ENOENT') { + debug('caught ENOENT error on copy, ignoring %o', { src, dest, err }) + + return + } + + throw err + }) +} + +type StartOptions = { + // If set, expect input frames as webm chunks. + webmInput?: boolean + // Callback for asynchronous errors in video processing/compression. + onError?: (err: Error, stdout: string, stderr: string) => void +} + +export function start (name, options: StartOptions = {}) { + const pt = new stream.PassThrough() + const ended = deferredPromise() + let done = false + let wantsWrite = true + let skippedChunksCount = 0 + let writtenChunksCount = 0 + + _.defaults(options, { + onError () {}, + }) + + const endVideoCapture = function (waitForMoreChunksTimeout = 3000) { + debugFrames('frames written:', writtenChunksCount) + + // in some cases (webm) ffmpeg will crash if fewer than 2 buffers are + // written to the stream, so we don't end capture until we get at least 2 + if (writtenChunksCount < 2) { + return new Bluebird((resolve) => { + pt.once('data', resolve) + }) + .then(() => endVideoCapture()) + .timeout(waitForMoreChunksTimeout) + } + + done = true + + pt.end() + + // return the ended promise which will eventually + // get resolve or rejected + return ended.promise + } + + const lengths = {} + + const writeVideoFrame = function (data) { + // make sure we haven't ended + // our stream yet because paint + // events can linger beyond + // finishing the actual video + if (done) { + return + } + + // when `data` is empty, it is sent as an empty Buffer (``) + // which can crash the process. this can happen if there are + // errors in the video capture process, which are handled later + // on, so just skip empty frames here. + // @see https://github.com/cypress-io/cypress/pull/6818 + if (_.isEmpty(data)) { + debugFrames('empty chunk received %o', data) + + return + } + + if (options.webmInput) { + if (lengths[data.length]) { + // this prevents multiple chunks of webm metadata from being written to the stream + // which would crash ffmpeg + debugFrames('duplicate length frame received:', data.length) + + return + } + + lengths[data.length] = true + } + + writtenChunksCount++ + + debugFrames('writing video frame') + + if (wantsWrite) { + if (!(wantsWrite = pt.write(data))) { + return pt.once('drain', () => { + debugFrames('video stream drained') + + wantsWrite = true + }) + } + } else { + skippedChunksCount += 1 + + return debugFrames('skipping video frame %o', { skipped: skippedChunksCount }) + } + } + + const startCapturing = () => { + return new Bluebird((resolve) => { + const cmd = ffmpeg({ + source: pt, + priority: 20, + }) + .videoCodec('libx264') + .outputOptions('-preset ultrafast') + .on('start', (command) => { + debug('capture started %o', { command }) + + return resolve({ + cmd, + startedVideoCapture: new Date, + }) + }).on('codecData', (data) => { + return debug('capture codec data: %o', data) + }).on('stderr', (stderr) => { + return debug('capture stderr log %o', { message: stderr }) + }).on('error', (err, stdout, stderr) => { + debug('capture errored: %o', { error: err.message, stdout, stderr }) + + // bubble errors up + options.onError?.(err, stdout, stderr) + + // reject the ended promise + return ended.reject(err) + }).on('end', () => { + debug('capture ended') + + return ended.resolve() + }) + + // this is to prevent the error "invalid data input" error + // when input frames have an odd resolution + .videoFilters(`crop='floor(in_w/2)*2:floor(in_h/2)*2'`) + + if (options.webmInput) { + cmd + .inputFormat('webm') + + // assume 18 fps. This number comes from manual measurement of avg fps coming from firefox. + // TODO: replace this with the 'vfr' option below when dropped frames issue is fixed. + .inputFPS(18) + + // 'vsync vfr' (variable framerate) works perfectly but fails on top page navigation + // since video timestamp resets to 0, timestamps already written will be dropped + // .outputOption('-vsync vfr') + } else { + cmd + .inputFormat('image2pipe') + .inputOptions('-use_wallclock_as_timestamps 1') + } + + return cmd.save(name) + }) + } + + return startCapturing() + .then(({ cmd, startedVideoCapture }: any) => { + return { + _pt: pt, + cmd, + endVideoCapture, + writeVideoFrame, + startedVideoCapture, + } + }) +} + +// Progress callback called with percentage `0 <= p <= 1` of compression progress. +type OnProgress = (p: number) => void + +export async function process (name, cname, videoCompression, ffmpegchaptersConfig, onProgress: OnProgress = function () {}) { + const metaFileName = `${name}.meta` + + const maybeGenerateMetaFile = Bluebird.method(() => { + if (!ffmpegchaptersConfig) { + return false + } + + // Writing the metadata to filesystem is necessary because fluent-ffmpeg is just a wrapper of ffmpeg command. + return fs.writeFile(metaFileName, ffmpegchaptersConfig).then(() => true) + }) + + const addChaptersMeta = await maybeGenerateMetaFile() + + let total = null + + return new Bluebird((resolve, reject) => { + debug('processing video from %s to %s video compression %o', + name, cname, videoCompression) + + const command = ffmpeg() + const outputOptions = [ + '-preset fast', + `-crf ${videoCompression}`, + ] + + if (addChaptersMeta) { + command.input(metaFileName) + outputOptions.push('-map_metadata 1') + } + + command.input(name) + .videoCodec('libx264') + .outputOptions(outputOptions) + // .videoFilters("crop='floor(in_w/2)*2:floor(in_h/2)*2'") + .on('start', (command) => { + debug('compression started %o', { command }) + }) + .on('codecData', (data) => { + debug('compression codec data: %o', data) + + total = utils.timemarkToSeconds(data.duration) + }) + .on('stderr', (stderr) => { + debug('compression stderr log %o', { message: stderr }) + }) + .on('progress', (progress) => { + // bail if we dont have total yet + if (!total) { + return + } + + debug('compression progress: %o', progress) + + const progressed = utils.timemarkToSeconds(progress.timemark) + + // @ts-ignore + const percent = progressed / total + + if (percent < 1) { + return onProgress(percent) + } + }) + .on('error', (err, stdout, stderr) => { + debug('compression errored: %o', { error: err.message, stdout, stderr }) + + return reject(err) + }) + .on('end', async () => { + debug('compression ended') + + // we are done progressing + onProgress(1) + + // rename and obliterate the original + await fs.move(cname, name, { + overwrite: true, + }) + + if (addChaptersMeta) { + await fs.unlink(metaFileName) + } + + resolve() + }).save(cname) + }) +} diff --git a/packages/server/test/integration/video_capture_spec.ts b/packages/server/test/integration/video_capture_spec.ts index 91e08bf793b9..74c180ecdea5 100644 --- a/packages/server/test/integration/video_capture_spec.ts +++ b/packages/server/test/integration/video_capture_spec.ts @@ -1,5 +1,5 @@ const { expect, sinon } = require('../spec_helper') -import videoCapture from '../../lib/video_capture' +import * as videoCapture from '../../lib/video_capture' import path from 'path' import fse from 'fs-extra' import os from 'os' diff --git a/packages/server/test/support/helpers/e2e.ts b/packages/server/test/support/helpers/e2e.ts index ab8ea52bb98b..58083f23bf77 100644 --- a/packages/server/test/support/helpers/e2e.ts +++ b/packages/server/test/support/helpers/e2e.ts @@ -788,6 +788,9 @@ const e2e = { // Emulate no typescript environment CYPRESS_INTERNAL_NO_TYPESCRIPT: options.noTypeScript ? '1' : '0', + // disable frame skipping to make quick Chromium tests have matching snapshots/working video + CYPRESS_EVERY_NTH_FRAME: 1, + // force file watching for use with --no-exit ...(options.noExit ? { CYPRESS_INTERNAL_FORCE_FILEWATCH: '1' } : {}), }) diff --git a/packages/server/test/unit/project_spec.js b/packages/server/test/unit/project_spec.js index c72789fbdfac..c19c8f5d88e8 100644 --- a/packages/server/test/unit/project_spec.js +++ b/packages/server/test/unit/project_spec.js @@ -255,6 +255,33 @@ This option will not have an effect in Some-other-name. Tests that rely on web s expect(cfg).ok }) }) + + // https://github.com/cypress-io/cypress/issues/17614 + it('only attaches warning to non-chrome browsers when chromeWebSecurity:true', async function () { + config.get.restore() + sinon.stub(config, 'get').returns({ + integrationFolder, + browsers: [{ family: 'chromium', name: 'Canary' }, { family: 'some-other-family', name: 'some-other-name' }], + chromeWebSecurity: true, + }) + + await this.project.initializeConfig() + .then(() => { + const cfg = this.project.getConfig() + + expect(cfg.chromeWebSecurity).eq(true) + expect(cfg.browsers).deep.eq([ + { + family: 'chromium', + name: 'Canary', + }, + { + family: 'some-other-family', + name: 'some-other-name', + }, + ]) + }) + }) }) context('#initializeConfig', function () { diff --git a/scripts/run-docker-local.sh b/scripts/run-docker-local.sh index 17dd15696c02..2f359b980fbf 100755 --- a/scripts/run-docker-local.sh +++ b/scripts/run-docker-local.sh @@ -3,7 +3,7 @@ set e+x echo "This script should be run from cypress's root" -name=cypress/browsers:node14.16.0-chrome90-ff88 +name=cypress/browsers:node14.17.0-chrome91-ff89 echo "Pulling CI container $name" docker pull $name diff --git a/yarn.lock b/yarn.lock index d260936f3fc2..2ce9355ea4f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17191,10 +17191,10 @@ electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.378, electron-to-chromi resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.727.tgz#857e310ca00f0b75da4e1db6ff0e073cc4a91ddf" integrity sha512-Mfz4FIB4FSvEwBpDfdipRIrwd6uo8gUDoRDF4QEYb4h4tSuI3ov594OrjU6on042UlFHouIJpClDODGkPcBSbg== -electron@12.0.0-beta.14: - version "12.0.0-beta.14" - resolved "https://registry.npmjs.org/electron/-/electron-12.0.0-beta.14.tgz#f8c40c7e479879c305e519380e710c0a357aa734" - integrity sha512-PYM+EepIEj9kLePXEb9gIxzZk5H4zM7LGg5iw60OHt+SYEECPNFJmPj3N6oHKu3W+KrCG7285Vgz2ZCp1u0kKA== +electron@13.1.7: + version "13.1.7" + resolved "https://registry.yarnpkg.com/electron/-/electron-13.1.7.tgz#7e17f5c93a8d182a2a486884fed3dc34ab101be9" + integrity sha512-sVfpP/0s6a82FK32LMuEe9L+aWZw15u3uYn9xUJArPjy4OZHteE6yM5871YCNXNiDnoCLQ5eqQWipiVgHsf8nQ== dependencies: "@electron/get" "^1.0.1" "@types/node" "^14.6.2"