mirror of
https://github.com/cypress-io/cypress.git
synced 2026-02-15 11:46:57 -06:00
Merge branch 'develop' into 66e8896b66-master-into-develop
This commit is contained in:
@@ -1 +1 @@
|
||||
14.16.0
|
||||
14.17.0
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
"yarn-deduplicate": "3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.16.0",
|
||||
"node": ">=14.17.0",
|
||||
"yarn": ">=1.17.3"
|
||||
},
|
||||
"productName": "Cypress",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
204
packages/driver/cypress/integration/util/queue_spec.ts
Normal file
204
packages/driver/cypress/integration/util/queue_spec.ts
Normal file
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,7 +77,17 @@ export const onResponse: HandlerFn<CyHttpMessages.IncomingResponse> = 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)
|
||||
|
||||
|
||||
@@ -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<StaticRespons
|
||||
backendStaticResponse.body = staticResponse.body
|
||||
} else {
|
||||
backendStaticResponse.body = JSON.stringify(staticResponse.body)
|
||||
_.set(backendStaticResponse, 'headers.content-type', 'application/json')
|
||||
|
||||
// There are various json-related MIME types. We cannot simply set it as `application/json`.
|
||||
// @see https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||
if (
|
||||
!backendStaticResponse.headers ||
|
||||
(backendStaticResponse.headers &&
|
||||
!caseInsensitiveHas(backendStaticResponse.headers, 'content-type'))
|
||||
) {
|
||||
_.set(backendStaticResponse, 'headers.content-type', 'application/json')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const _ = require('lodash')
|
||||
const utils = require('./utils')
|
||||
|
||||
class $Command {
|
||||
constructor (obj = {}) {
|
||||
@@ -124,6 +125,10 @@ class $Command {
|
||||
}
|
||||
|
||||
static create (obj) {
|
||||
if (utils.isInstanceOf(obj, $Command)) {
|
||||
return obj
|
||||
}
|
||||
|
||||
return new $Command(obj)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
const _ = require('lodash')
|
||||
const utils = require('./utils')
|
||||
const $Command = require('./command')
|
||||
|
||||
class $CommandQueue {
|
||||
constructor (cmds = []) {
|
||||
this.commands = cmds
|
||||
}
|
||||
|
||||
logs (filters) {
|
||||
let logs = _.flatten(this.invokeMap('get', 'logs'))
|
||||
|
||||
if (filters) {
|
||||
const matchesFilters = _.matches(filters)
|
||||
|
||||
logs = _.filter(logs, (log) => {
|
||||
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
|
||||
394
packages/driver/src/cypress/command_queue.ts
Normal file
394
packages/driver/src/cypress/command_queue.ts
Normal file
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -399,4 +399,12 @@ module.exports = {
|
||||
return String.fromCharCode(`0x${p1}`)
|
||||
}))
|
||||
},
|
||||
|
||||
noArgsAreAFunction (args) {
|
||||
return !_.some(args, _.isFunction)
|
||||
},
|
||||
|
||||
isPromiseLike (ret) {
|
||||
return ret && _.isFunction(ret.then)
|
||||
},
|
||||
}
|
||||
|
||||
114
packages/driver/src/util/queue.ts
Normal file
114
packages/driver/src/util/queue.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import Bluebird from 'bluebird'
|
||||
|
||||
interface QueueRunProps {
|
||||
onRun: () => Bluebird<any> | Promise<any>
|
||||
onError: (err: Error) => void
|
||||
onFinish: () => void
|
||||
}
|
||||
|
||||
export const create = <T>(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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
getCertificateKeysForHostname (hostname) {
|
||||
const dest = hostname.replace(asterisksRe, '_')
|
||||
clearDataForHostname (hostname) {
|
||||
const baseFilename = hostnameToFilename(hostname)
|
||||
|
||||
return Promise.all([
|
||||
fs.readFileAsync(path.join(this.certsFolder, `${dest}.pem`)),
|
||||
fs.readFileAsync(path.join(this.keysFolder, `${dest}.key`)),
|
||||
fs.remove(this.getCertPath(baseFilename)),
|
||||
fs.remove(this.getPrivateKeyPath(baseFilename)),
|
||||
fs.remove(this.getPublicKeyPath(baseFilename)),
|
||||
])
|
||||
}
|
||||
|
||||
getCertificateKeysForHostname (hostname) {
|
||||
const baseFilename = hostnameToFilename(hostname)
|
||||
|
||||
return Promise.all([
|
||||
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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
17
packages/net-stubbing/lib/util.ts
Normal file
17
packages/net-stubbing/lib/util.ts
Normal file
@@ -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
|
||||
}
|
||||
@@ -19,6 +19,11 @@ export type CyCookie = Pick<chrome.cookies.Cookie, 'name' | 'value' | 'expiratio
|
||||
// https://developer.chrome.com/extensions/cookies#method-getAll
|
||||
type CyCookieFilter = chrome.cookies.GetAllDetails
|
||||
|
||||
export const screencastOpts: cdp.Page.StartScreencastRequest = {
|
||||
format: 'jpeg',
|
||||
everyNthFrame: Number(process.env.CYPRESS_EVERY_NTH_FRAME || 5),
|
||||
}
|
||||
|
||||
function convertSameSiteExtensionToCdp (str: CyCookie['sameSite']): cdp.Network.CookieSameSite | undefined {
|
||||
return str ? ({
|
||||
'no_restriction': 'None',
|
||||
|
||||
@@ -11,7 +11,7 @@ import { launch } from '@packages/launcher'
|
||||
|
||||
import appData from '../util/app_data'
|
||||
import { fs } from '../util/fs'
|
||||
import { CdpAutomation } from './cdp_automation'
|
||||
import { CdpAutomation, screencastOpts } from './cdp_automation'
|
||||
import * as CriClient from './cri-client'
|
||||
import * as protocol from './protocol'
|
||||
import utils from './utils'
|
||||
@@ -273,9 +273,7 @@ const _maybeRecordVideo = async function (client, options) {
|
||||
client.send('Page.screencastFrameAck', { sessionId: meta.sessionId })
|
||||
})
|
||||
|
||||
await client.send('Page.startScreencast', {
|
||||
format: 'jpeg',
|
||||
})
|
||||
await client.send('Page.startScreencast', screencastOpts)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ const Bluebird = require('bluebird')
|
||||
const debug = require('debug')('cypress:server:browsers:electron')
|
||||
const menu = require('../gui/menu')
|
||||
const Windows = require('../gui/windows')
|
||||
const { CdpAutomation } = require('./cdp_automation')
|
||||
const { CdpAutomation, screencastOpts } = require('./cdp_automation')
|
||||
const savedState = require('../saved_state')
|
||||
const utils = require('./utils')
|
||||
const errors = require('../errors')
|
||||
@@ -62,7 +62,7 @@ const _getAutomation = function (win, options, parent) {
|
||||
return fn(message, data)
|
||||
}
|
||||
|
||||
await sendCommand('Page.startScreencast')
|
||||
await sendCommand('Page.startScreencast', screencastOpts)
|
||||
|
||||
const ret = await fn(message, data)
|
||||
|
||||
@@ -104,9 +104,7 @@ const _maybeRecordVideo = function (webContents, options) {
|
||||
}
|
||||
})
|
||||
|
||||
await webContents.debugger.sendCommand('Page.startScreencast', {
|
||||
format: 'jpeg',
|
||||
})
|
||||
await webContents.debugger.sendCommand('Page.startScreencast', screencastOpts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ import { checkSupportFile } from './project_utils'
|
||||
// TODO: Figure out how to type this better.
|
||||
type ReceivedCypressOptions =
|
||||
Partial<Pick<Cypress.RuntimeConfigOptions, 'hosts' | 'projectName' | 'clientRoute' | 'devServerPublicPathRoute' | 'namespace' | 'report' | 'socketIoCookie' | 'configFile' | 'isTextTerminal' | 'isNewProject' | 'proxyUrl' | 'browsers'>>
|
||||
& Partial<Pick<Cypress.ResolvedConfigOptions, 'supportFolder' | 'experimentalSourceRewriting' | 'fixturesFolder' | 'reporter' | 'reporterOptions' | 'screenshotsFolder' | 'pluginsFile' | 'supportFile' | 'integrationFolder' | 'baseUrl' | 'viewportHeight' | 'viewportWidth' | 'port' | 'experimentalInteractiveRunEvents'>>
|
||||
& Partial<Pick<Cypress.ResolvedConfigOptions, 'chromeWebSecurity' | 'supportFolder' | 'experimentalSourceRewriting' | 'fixturesFolder' | 'reporter' | 'reporterOptions' | 'screenshotsFolder' | 'pluginsFile' | 'supportFile' | 'integrationFolder' | 'baseUrl' | 'viewportHeight' | 'viewportWidth' | 'port' | 'experimentalInteractiveRunEvents'>>
|
||||
|
||||
export interface Cfg extends ReceivedCypressOptions {
|
||||
projectRoot: string
|
||||
@@ -675,7 +675,7 @@ export class ProjectBase<TServer extends ServerE2E | ServerCt> extends EE {
|
||||
|
||||
if (theCfg.browsers) {
|
||||
theCfg.browsers = theCfg.browsers?.map((browser) => {
|
||||
if (browser.family === 'chromium') {
|
||||
if (browser.family === 'chromium' || theCfg.chromeWebSecurity) {
|
||||
return browser
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (`<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)
|
||||
})
|
||||
},
|
||||
|
||||
}
|
||||
362
packages/server/lib/video_capture.ts
Normal file
362
packages/server/lib/video_capture.ts
Normal file
@@ -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 (`<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)
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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' } : {}),
|
||||
})
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user