chore: internal refactor of privileged commands (#27060)

This commit is contained in:
Chris Breiding
2023-06-16 10:45:53 -04:00
committed by GitHub
parent 9cb7e1f915
commit 89f0fb6465
56 changed files with 1561 additions and 385 deletions
+1 -2
View File
@@ -74,8 +74,7 @@ windowsWorkflowFilters: &windows-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'matth/feat/chrome-headless', << pipeline.git.branch >> ]
- equal: [ 'lmiller/fix-windows-regressions', << pipeline.git.branch >> ]
- equal: [ 'privileged-commands-refactor', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
+2 -2
View File
@@ -3,8 +3,8 @@ type EventEmitter2 = import("eventemitter2").EventEmitter2
interface CyEventEmitter extends Omit<EventEmitter2, 'waitFor'> {
proxyTo: (cy: Cypress.cy) => null
emitMap: (eventName: string, args: any[]) => Array<(...args: any[]) => any>
emitThen: (eventName: string, args: any[]) => Bluebird.BluebirdStatic
emitMap: (eventName: string, ...args: any[]) => Array<(...args: any[]) => any>
emitThen: (eventName: string, ...args: any[]) => Bluebird.BluebirdStatic
}
// Copied from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/events.d.ts
+2 -1
View File
@@ -1,4 +1,5 @@
cypress/videos/*
cypress/screenshots/*
cypress/downloads/*
components.d.ts
components.d.ts
@@ -343,18 +343,6 @@ export const generateCtErrorTests = (server: 'Webpack' | 'Vite', configFile: str
})
})
it('cy.readFile', () => {
const verify = loadErrorSpec({
filePath: 'errors/readfile.cy.js',
failCount: 1,
}, configFile)
verify('existence failure', {
column: [8, 9],
message: 'failed because the file does not exist',
})
})
it('validation errors', () => {
const verify = loadErrorSpec({
filePath: 'errors/validation.cy.js',
@@ -321,18 +321,6 @@ describe('errors ui', {
})
})
it('cy.readFile', () => {
const verify = loadErrorSpec({
filePath: 'errors/readfile.cy.js',
failCount: 1,
})
verify('existence failure', {
column: 8,
message: 'failed because the file does not exist',
})
})
it('validation errors', () => {
const verify = loadErrorSpec({
filePath: 'errors/validation.cy.js',
+7 -1
View File
@@ -750,7 +750,13 @@ export class EventManager {
* Return it's response.
*/
Cypress.primaryOriginCommunicator.on('backend:request', async ({ args }, { source, responseEvent }) => {
const response = await Cypress.backend(...args)
let response
try {
response = await Cypress.backend(...args)
} catch (error) {
response = { error }
}
Cypress.primaryOriginCommunicator.toSource(source, responseEvent, response)
})
+26 -5
View File
@@ -152,13 +152,25 @@ function setupRunner () {
createIframeModel()
}
interface GetSpecUrlOptions {
browserFamily?: string
namespace: string
specSrc: string
}
/**
* Get the URL for the spec. This is the URL of the AUT IFrame.
* CT uses absolute URLs, and serves from the dev server.
* E2E uses relative, serving from our internal server's spec controller.
*/
function getSpecUrl (namespace: string, specSrc: string) {
return `/${namespace}/iframes/${specSrc}`
function getSpecUrl ({ browserFamily, namespace, specSrc }: GetSpecUrlOptions) {
let url = `/${namespace}/iframes/${specSrc}`
if (browserFamily) {
url += `?browserFamily=${browserFamily}`
}
return url
}
/**
@@ -202,13 +214,15 @@ export function addCrossOriginIframe (location) {
return
}
const config = getRunnerConfigFromWindow()
addIframe({
id,
// the cross origin iframe is added to the document body instead of the
// container since it needs to match the size of the top window for screenshots
$container: document.body,
className: 'spec-bridge-iframe',
src: `${location.origin}/${getRunnerConfigFromWindow().namespace}/spec-bridge-iframes`,
src: `${location.origin}/${config.namespace}/spec-bridge-iframes?browserFamily=${config.browser.family}`,
})
}
@@ -234,7 +248,10 @@ function runSpecCT (config, spec: SpecFile) {
const autIframe = getAutIframeModel()
const $autIframe: JQuery<HTMLIFrameElement> = autIframe.create().appendTo($container)
const specSrc = getSpecUrl(config.namespace, spec.absolute)
const specSrc = getSpecUrl({
namespace: config.namespace,
specSrc: spec.absolute,
})
autIframe._showInitialBlankPage()
$autIframe.prop('src', specSrc)
@@ -297,7 +314,11 @@ function runSpecE2E (config, spec: SpecFile) {
autIframe.visitBlankPage()
// create Spec IFrame
const specSrc = getSpecUrl(config.namespace, encodeURIComponent(spec.relative))
const specSrc = getSpecUrl({
browserFamily: config.browser.family,
namespace: config.namespace,
specSrc: encodeURIComponent(spec.relative),
})
// FIXME: BILL Determine where to call client with to force browser repaint
/**
+1
View File
@@ -1,2 +1,3 @@
cypress/videos
cypress/screenshots
cypress/downloads
+17 -11
View File
@@ -12,14 +12,18 @@ describe('src/cy/commands/exec', () => {
cy.stub(Cypress, 'backend').callThrough()
})
it('triggers \'exec\' with the right options', () => {
it('sends privileged exec to backend with the right options', () => {
Cypress.backend.resolves(okResponse)
cy.exec('ls').then(() => {
expect(Cypress.backend).to.be.calledWith('exec', {
cmd: 'ls',
timeout: 2500,
env: {},
expect(Cypress.backend).to.be.calledWith('run:privileged', {
commandName: 'exec',
userArgs: ['ls'],
options: {
cmd: 'ls',
timeout: 2500,
env: {},
},
})
})
})
@@ -28,17 +32,19 @@ describe('src/cy/commands/exec', () => {
Cypress.backend.resolves(okResponse)
cy.exec('ls', { env: { FOO: 'foo' } }).then(() => {
expect(Cypress.backend).to.be.calledWith('exec', {
cmd: 'ls',
timeout: 2500,
env: {
FOO: 'foo',
expect(Cypress.backend).to.be.calledWith('run:privileged', {
commandName: 'exec',
userArgs: ['ls', { env: { FOO: 'foo' } }],
options: {
cmd: 'ls',
timeout: 2500,
env: { FOO: 'foo' },
},
})
})
})
it('really works', () => {
it('works e2e', () => {
// output is trimmed
cy.exec('echo foo', { timeout: 20000 }).its('stdout').should('eq', 'foo')
})
@@ -14,14 +14,20 @@ describe('src/cy/commands/files', () => {
})
describe('#readFile', () => {
it('triggers \'read:file\' with the right options', () => {
it('sends privileged readFile to backend with the right options', () => {
Cypress.backend.resolves(okResponse)
cy.readFile('foo.json').then(() => {
expect(Cypress.backend).to.be.calledWith(
'read:file',
'foo.json',
{ encoding: 'utf8' },
'run:privileged',
{
commandName: 'readFile',
userArgs: ['foo.json'],
options: {
file: 'foo.json',
encoding: 'utf8',
},
},
)
})
})
@@ -31,9 +37,15 @@ describe('src/cy/commands/files', () => {
cy.readFile('foo.json', 'ascii').then(() => {
expect(Cypress.backend).to.be.calledWith(
'read:file',
'foo.json',
{ encoding: 'ascii' },
'run:privileged',
{
commandName: 'readFile',
userArgs: ['foo.json', 'ascii'],
options: {
file: 'foo.json',
encoding: 'ascii',
},
},
)
})
})
@@ -47,9 +59,15 @@ describe('src/cy/commands/files', () => {
cy.readFile('foo.json', null).then(() => {
expect(Cypress.backend).to.be.calledWith(
'read:file',
'foo.json',
{ encoding: null },
'run:privileged',
{
commandName: 'readFile',
userArgs: ['foo.json', null],
options: {
file: 'foo.json',
encoding: null,
},
},
)
}).should('eql', Buffer.from('\n'))
})
@@ -426,17 +444,21 @@ describe('src/cy/commands/files', () => {
})
describe('#writeFile', () => {
it('triggers \'write:file\' with the right options', () => {
it('sends privileged writeFile to backend with the right options', () => {
Cypress.backend.resolves(okResponse)
cy.writeFile('foo.txt', 'contents').then(() => {
expect(Cypress.backend).to.be.calledWith(
'write:file',
'foo.txt',
'contents',
'run:privileged',
{
encoding: 'utf8',
flag: 'w',
commandName: 'writeFile',
userArgs: ['foo.txt', 'contents'],
options: {
fileName: 'foo.txt',
contents: 'contents',
encoding: 'utf8',
flag: 'w',
},
},
)
})
@@ -447,12 +469,16 @@ describe('src/cy/commands/files', () => {
cy.writeFile('foo.txt', 'contents', 'ascii').then(() => {
expect(Cypress.backend).to.be.calledWith(
'write:file',
'foo.txt',
'contents',
'run:privileged',
{
encoding: 'ascii',
flag: 'w',
commandName: 'writeFile',
userArgs: ['foo.txt', 'contents', 'ascii'],
options: {
fileName: 'foo.txt',
contents: 'contents',
encoding: 'ascii',
flag: 'w',
},
},
)
})
@@ -462,14 +488,20 @@ describe('src/cy/commands/files', () => {
it('explicit null encoding is sent to server as Buffer', () => {
Cypress.backend.resolves(okResponse)
cy.writeFile('foo.txt', Buffer.from([0, 0, 54, 255]), null).then(() => {
const buffer = Buffer.from([0, 0, 54, 255])
cy.writeFile('foo.txt', buffer, null).then(() => {
expect(Cypress.backend).to.be.calledWith(
'write:file',
'foo.txt',
Buffer.from([0, 0, 54, 255]),
'run:privileged',
{
encoding: null,
flag: 'w',
commandName: 'writeFile',
userArgs: ['foo.txt', buffer, null],
options: {
fileName: 'foo.txt',
contents: buffer,
encoding: null,
flag: 'w',
},
},
)
})
@@ -480,12 +512,16 @@ describe('src/cy/commands/files', () => {
cy.writeFile('foo.txt', 'contents', { encoding: 'ascii' }).then(() => {
expect(Cypress.backend).to.be.calledWith(
'write:file',
'foo.txt',
'contents',
'run:privileged',
{
encoding: 'ascii',
flag: 'w',
commandName: 'writeFile',
userArgs: ['foo.txt', 'contents', { encoding: 'ascii' }],
options: {
fileName: 'foo.txt',
contents: 'contents',
encoding: 'ascii',
flag: 'w',
},
},
)
})
@@ -531,12 +567,16 @@ describe('src/cy/commands/files', () => {
cy.writeFile('foo.txt', 'contents', { flag: 'a+' }).then(() => {
expect(Cypress.backend).to.be.calledWith(
'write:file',
'foo.txt',
'contents',
'run:privileged',
{
encoding: 'utf8',
flag: 'a+',
commandName: 'writeFile',
userArgs: ['foo.txt', 'contents', { flag: 'a+' }],
options: {
fileName: 'foo.txt',
contents: 'contents',
encoding: 'utf8',
flag: 'a+',
},
},
)
})
+16 -10
View File
@@ -9,14 +9,18 @@ describe('src/cy/commands/task', () => {
cy.stub(Cypress, 'backend').callThrough()
})
it('calls Cypress.backend(\'task\') with the right options', () => {
it('sends privileged task to backend with the right options', () => {
Cypress.backend.resolves(null)
cy.task('foo').then(() => {
expect(Cypress.backend).to.be.calledWith('task', {
task: 'foo',
timeout: 2500,
arg: undefined,
expect(Cypress.backend).to.be.calledWith('run:privileged', {
commandName: 'task',
userArgs: ['foo'],
options: {
task: 'foo',
timeout: 2500,
arg: undefined,
},
})
})
})
@@ -25,11 +29,13 @@ describe('src/cy/commands/task', () => {
Cypress.backend.resolves(null)
cy.task('foo', { foo: 'foo' }).then(() => {
expect(Cypress.backend).to.be.calledWith('task', {
task: 'foo',
timeout: 2500,
arg: {
foo: 'foo',
expect(Cypress.backend).to.be.calledWith('run:privileged', {
commandName: 'task',
userArgs: ['foo', { foo: 'foo' }],
options: {
task: 'foo',
timeout: 2500,
arg: { foo: 'foo' },
},
})
})
@@ -22,8 +22,13 @@ describe('src/cypress/script_utils', () => {
cy.stub($sourceMapUtils, 'initializeSourceMapConsumer').resolves()
})
it('fetches each script', () => {
return $scriptUtils.runScripts(scriptWindow, scripts)
it('fetches each script in non-webkit browsers', () => {
return $scriptUtils.runScripts({
browser: { family: 'chromium' },
scripts,
specWindow: scriptWindow,
testingType: 'e2e',
})
.then(() => {
expect($networkUtils.fetch).to.be.calledTwice
expect($networkUtils.fetch).to.be.calledWith(scripts[0].relativeUrl)
@@ -31,8 +36,62 @@ describe('src/cypress/script_utils', () => {
})
})
it('appends each script in e2e webkit', async () => {
const foundScript = {
after: cy.stub(),
}
const createdScript1 = {
addEventListener: cy.stub(),
}
const createdScript2 = {
addEventListener: cy.stub(),
}
const doc = {
querySelector: cy.stub().returns(foundScript),
createElement: cy.stub(),
}
doc.createElement.onCall(0).returns(createdScript1)
doc.createElement.onCall(1).returns(createdScript2)
scriptWindow.document = doc
const runScripts = $scriptUtils.runScripts({
scripts,
specWindow: scriptWindow,
browser: { family: 'webkit' },
testingType: 'e2e',
})
// each script is appended and run before the next
await Promise.delay(1) // wait a tick due to promise
expect(createdScript1.addEventListener).to.be.calledWith('load')
createdScript1.addEventListener.lastCall.args[1]()
await Promise.delay(1) // wait a tick due to promise
expect(createdScript2.addEventListener).to.be.calledWith('load')
createdScript2.addEventListener.lastCall.args[1]()
await runScripts
// sets script src
expect(createdScript1.src).to.equal(scripts[0].relativeUrl)
expect(createdScript2.src).to.equal(scripts[1].relativeUrl)
// appends scripts
expect(foundScript.after).to.be.calledTwice
expect(foundScript.after).to.be.calledWith(createdScript1)
expect(foundScript.after).to.be.calledWith(createdScript2)
})
it('extracts the source map from each script', () => {
return $scriptUtils.runScripts(scriptWindow, scripts)
return $scriptUtils.runScripts({
browser: { family: 'chromium' },
scripts,
specWindow: scriptWindow,
testingType: 'e2e',
})
.then(() => {
expect($sourceMapUtils.extractSourceMap).to.be.calledTwice
expect($sourceMapUtils.extractSourceMap).to.be.calledWith('the script contents')
@@ -41,7 +100,12 @@ describe('src/cypress/script_utils', () => {
})
it('evals each script', () => {
return $scriptUtils.runScripts(scriptWindow, scripts)
return $scriptUtils.runScripts({
browser: { family: 'chromium' },
scripts,
specWindow: scriptWindow,
testingType: 'e2e',
})
.then(() => {
expect(scriptWindow.eval).to.be.calledTwice
expect(scriptWindow.eval).to.be.calledWith('the script contents\n//# sourceURL=http://localhost:3500cypress/integration/script1.js')
@@ -53,7 +117,12 @@ describe('src/cypress/script_utils', () => {
context('#runPromises', () => {
it('handles promises and doesnt try to fetch + eval manually', async () => {
const scriptsAsPromises = [() => Promise.resolve(), () => Promise.resolve()]
const result = await $scriptUtils.runScripts({}, scriptsAsPromises)
const result = await $scriptUtils.runScripts({
browser: { family: 'chromium' },
scripts: scriptsAsPromises,
specWindow: {},
testingType: 'e2e',
})
expect(result).to.have.length(scriptsAsPromises.length)
})
@@ -35,12 +35,16 @@ context('cy.origin files', { browser: '!webkit' }, () => {
cy.writeFile('foo.json', contents).then(() => {
expect(Cypress.backend).to.be.calledWith(
'write:file',
'foo.json',
contents,
'run:privileged',
{
encoding: 'utf8',
flag: 'w',
commandName: 'writeFile',
userArgs: ['foo.json', contents],
options: {
fileName: 'foo.json',
contents,
encoding: 'utf8',
flag: 'w',
},
},
)
})
@@ -208,7 +208,7 @@ context('cy.origin misc', { browser: '!webkit' }, () => {
it('verifies number of cy commands', () => {
// remove custom commands we added for our own testing
const customCommands = ['getAll', 'shouldWithTimeout', 'originLoadUtils']
const customCommands = ['getAll', 'shouldWithTimeout', 'originLoadUtils', 'runSupportFileCustomPrivilegedCommands']
// @ts-ignore
const actualCommands = Cypress._.pullAll([...Object.keys(cy.commandFns), ...Object.keys(cy.queryFns)], customCommands)
const expectedCommands = [
@@ -7,7 +7,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a localhost domain name', () => {
cy.origin('localhost', () => undefined)
cy.then(() => {
const expectedSrc = `https://localhost/__cypress/spec-bridge-iframes`
const expectedSrc = `https://localhost/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://localhost') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -17,7 +17,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on an ip address', () => {
cy.origin('127.0.0.1', () => undefined)
cy.then(() => {
const expectedSrc = `https://127.0.0.1/__cypress/spec-bridge-iframes`
const expectedSrc = `https://127.0.0.1/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://127.0.0.1') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -29,7 +29,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it.skip('succeeds on an ipv6 address', () => {
cy.origin('0000:0000:0000:0000:0000:0000:0000:0001', () => undefined)
cy.then(() => {
const expectedSrc = `https://[::1]/__cypress/spec-bridge-iframes`
const expectedSrc = `https://[::1]/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://[::1]') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -39,7 +39,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a unicode domain', () => {
cy.origin('はじめよう.みんな', () => undefined)
cy.then(() => {
const expectedSrc = `https://xn--p8j9a0d9c9a.xn--q9jyb4c/__cypress/spec-bridge-iframes`
const expectedSrc = `https://xn--p8j9a0d9c9a.xn--q9jyb4c/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://xn--p8j9a0d9c9a.xn--q9jyb4c') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -49,7 +49,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a complete origin', () => {
cy.origin('http://foobar1.com:3500', () => undefined)
cy.then(() => {
const expectedSrc = `http://foobar1.com:3500/__cypress/spec-bridge-iframes`
const expectedSrc = `http://foobar1.com:3500/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ http://foobar1.com:3500') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -59,7 +59,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a complete origin using https', () => {
cy.origin('https://www.foobar2.com:3500', () => undefined)
cy.then(() => {
const expectedSrc = `https://www.foobar2.com:3500/__cypress/spec-bridge-iframes`
const expectedSrc = `https://www.foobar2.com:3500/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://www.foobar2.com:3500') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -69,7 +69,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a hostname and port', () => {
cy.origin('foobar3.com:3500', () => undefined)
cy.then(() => {
const expectedSrc = `https://foobar3.com:3500/__cypress/spec-bridge-iframes`
const expectedSrc = `https://foobar3.com:3500/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://foobar3.com:3500') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -79,7 +79,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a protocol and hostname', () => {
cy.origin('http://foobar4.com', () => undefined)
cy.then(() => {
const expectedSrc = `http://foobar4.com/__cypress/spec-bridge-iframes`
const expectedSrc = `http://foobar4.com/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ http://foobar4.com') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -89,7 +89,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a subdomain', () => {
cy.origin('app.foobar5.com', () => undefined)
cy.then(() => {
const expectedSrc = `https://app.foobar5.com/__cypress/spec-bridge-iframes`
const expectedSrc = `https://app.foobar5.com/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://app.foobar5.com') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -99,7 +99,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds when only domain is passed', () => {
cy.origin('foobar6.com', () => undefined)
cy.then(() => {
const expectedSrc = `https://foobar6.com/__cypress/spec-bridge-iframes`
const expectedSrc = `https://foobar6.com/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://foobar6.com') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -109,7 +109,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a url with path', () => {
cy.origin('http://www.foobar7.com/login', () => undefined)
cy.then(() => {
const expectedSrc = `http://www.foobar7.com/__cypress/spec-bridge-iframes`
const expectedSrc = `http://www.foobar7.com/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ http://www.foobar7.com') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -119,7 +119,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a url with a hash', () => {
cy.origin('http://www.foobar8.com/#hash', () => undefined)
cy.then(() => {
const expectedSrc = `http://www.foobar8.com/__cypress/spec-bridge-iframes`
const expectedSrc = `http://www.foobar8.com/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ http://www.foobar8.com') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -129,7 +129,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a url with a path and hash', () => {
cy.origin('http://www.foobar9.com/login/#hash', () => undefined)
cy.then(() => {
const expectedSrc = `http://www.foobar9.com/__cypress/spec-bridge-iframes`
const expectedSrc = `http://www.foobar9.com/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ http://www.foobar9.com') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -139,7 +139,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a domain with path', () => {
cy.origin('foobar10.com/login', () => undefined)
cy.then(() => {
const expectedSrc = `https://foobar10.com/__cypress/spec-bridge-iframes`
const expectedSrc = `https://foobar10.com/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://foobar10.com') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -149,7 +149,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a domain with a hash', () => {
cy.origin('foobar11.com/#hash', () => undefined)
cy.then(() => {
const expectedSrc = `https://foobar11.com/__cypress/spec-bridge-iframes`
const expectedSrc = `https://foobar11.com/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://foobar11.com') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -159,7 +159,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a domain with a path and hash', () => {
cy.origin('foobar12.com/login/#hash', () => undefined)
cy.then(() => {
const expectedSrc = `https://foobar12.com/__cypress/spec-bridge-iframes`
const expectedSrc = `https://foobar12.com/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://foobar12.com') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -169,7 +169,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a public suffix with a subdomain', () => {
cy.origin('app.foobar.herokuapp.com', () => undefined)
cy.then(() => {
const expectedSrc = `https://app.foobar.herokuapp.com/__cypress/spec-bridge-iframes`
const expectedSrc = `https://app.foobar.herokuapp.com/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://app.foobar.herokuapp.com') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -179,7 +179,7 @@ describe('cy.origin', { browser: '!webkit' }, () => {
it('succeeds on a machine name', () => {
cy.origin('machine-name', () => undefined)
cy.then(() => {
const expectedSrc = `https://machine-name/__cypress/spec-bridge-iframes`
const expectedSrc = `https://machine-name/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://machine-name') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -356,7 +356,7 @@ describe('cy.origin - external hosts', { browser: '!webkit' }, () => {
cy.visit('https://www.foobar.com:3502/fixtures/primary-origin.html')
cy.origin('https://www.idp.com:3502', () => undefined)
cy.then(() => {
const expectedSrc = `https://www.idp.com:3502/__cypress/spec-bridge-iframes`
const expectedSrc = `https://www.idp.com:3502/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://www.idp.com:3502') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -372,7 +372,7 @@ describe('cy.origin - external hosts', { browser: '!webkit' }, () => {
cy.visit('https://www.google.com')
cy.origin('accounts.google.com', () => undefined)
cy.then(() => {
const expectedSrc = `https://accounts.google.com/__cypress/spec-bridge-iframes`
const expectedSrc = `https://accounts.google.com/__cypress/spec-bridge-iframes?browserFamily=${Cypress.browser.family}`
const iframe = window.top?.document.getElementById('Spec\ Bridge:\ https://accounts.google.com') as HTMLIFrameElement
expect(iframe.src).to.equal(expectedSrc)
@@ -0,0 +1,191 @@
import { runImportedPrivilegedCommands } from '../../support/utils'
const isWebkit = Cypress.isBrowser({ family: 'webkit' })
function runSpecFunctionCommands () {
cy.exec('echo "hello"')
cy.readFile('cypress/fixtures/app.json')
cy.writeFile('cypress/_test-output/written.json', 'contents')
cy.task('return:arg', 'arg')
cy.get('#basic').selectFile('cypress/fixtures/valid.json')
if (!isWebkit) {
cy.origin('http://foobar.com:3500', () => {})
}
}
Cypress.Commands.add('runSpecFileCustomPrivilegedCommands', runSpecFunctionCommands)
describe('privileged commands', () => {
describe('in spec file or support file', () => {
let ranInBeforeEach = false
beforeEach(() => {
if (ranInBeforeEach) return
ranInBeforeEach = true
// ensures these run properly in hooks, but only run it once per spec run
cy.exec('echo "hello"')
cy.readFile('cypress/fixtures/app.json')
cy.writeFile('cypress/_test-output/written.json', 'contents')
cy.task('return:arg', 'arg')
cy.get('#basic').selectFile('cypress/fixtures/valid.json')
if (!isWebkit) {
cy.origin('http://foobar.com:3500', () => {})
}
})
it('passes in test body', () => {
cy.exec('echo "hello"')
cy.readFile('cypress/fixtures/app.json')
cy.writeFile('cypress/_test-output/written.json', 'contents')
cy.task('return:arg', 'arg')
cy.get('#basic').selectFile('cypress/fixtures/valid.json')
if (!isWebkit) {
cy.origin('http://foobar.com:3500', () => {})
}
})
it('passes two or more exact commands in a row', () => {
cy.task('return:arg', 'arg')
cy.task('return:arg', 'arg')
})
it('passes in test body .then() callback', () => {
cy.then(() => {
cy.exec('echo "hello"')
cy.readFile('cypress/fixtures/app.json')
cy.writeFile('cypress/_test-output/written.json', 'contents')
cy.task('return:arg', 'arg')
cy.get('#basic').selectFile('cypress/fixtures/valid.json')
if (!isWebkit) {
cy.origin('http://foobar.com:3500', () => {})
}
})
})
it('passes in spec function', () => {
runSpecFunctionCommands()
})
it('passes in imported function', () => {
runImportedPrivilegedCommands()
})
it('passes in support file global function', () => {
window.runGlobalPrivilegedCommands()
})
it('passes in spec file custom command', () => {
cy.runSpecFileCustomPrivilegedCommands()
})
it('passes in support file custom command', () => {
cy.runSupportFileCustomPrivilegedCommands()
})
// cy.origin() doesn't currently have webkit support
it('passes in .origin() callback', { browser: '!webkit' }, () => {
cy.origin('http://foobar.com:3500', () => {
cy.exec('echo "hello"')
cy.readFile('cypress/fixtures/app.json')
cy.writeFile('cypress/_test-output/written.json', 'contents')
cy.task('return:arg', 'arg')
// there's a bug using cy.selectFile() with a path inside of
// cy.origin(): https://github.com/cypress-io/cypress/issues/25261
// cy.visit('/fixtures/files-form.html')
// cy.get('#basic').selectFile('cypress/fixtures/valid.json')
})
})
})
describe('in AUT', () => {
const strategies = ['inline', 'then', 'eval', 'function']
const commands = ['exec', 'readFile', 'writeFile', 'selectFile', 'task']
// cy.origin() doesn't currently have webkit support
if (!Cypress.isBrowser({ family: 'webkit' })) {
commands.push('origin')
}
const errorForCommand = (commandName) => {
return `\`cy.${commandName}()\` must only be invoked from the spec file or support file.`
}
strategies.forEach((strategy) => {
commands.forEach((command) => {
describe(`strategy: ${strategy}`, () => {
describe(`command: ${command}`, () => {
it('fails in html script', (done) => {
cy.on('fail', (err) => {
expect(err.message).to.include(errorForCommand(command))
done()
})
cy.visit(`/aut-commands?strategy=${strategy}&command=${command}`)
})
it('fails in separate script', (done) => {
cy.on('fail', (err) => {
expect(err.message).to.include(errorForCommand(command))
done()
})
cy.visit(`/fixtures/aut-commands.html?strategy=${strategy}&command=${command}`)
})
it('does not run command in separate script appended to spec frame', () => {
let ranCommand = false
cy.on('log:added', (attrs) => {
if (attrs.name === command) {
ranCommand = true
}
})
// this attempts to run the command by appending a <script> to the
// spec frame, but the Content-Security-Policy we set will prevent
// that script from running
cy.visit(`/aut-commands?appendToSpecFrame=true&strategy=${strategy}&command=${command}`)
// wait 500ms then ensure the command did not run
cy.wait(500).then(() => {
expect(ranCommand, `expected cy.${command}() not to run, but it did`).to.be.false
})
})
// while not immediately obvious, this basically triggers using
// cy.origin() within itself. that doesn't work anyways and
// hits a different error, so it can't be used outside of the spec
// in this manner
if (command !== 'origin') {
// cy.origin() doesn't currently have webkit support
it('fails in cross-origin html script', { browser: '!webkit' }, (done) => {
cy.on('fail', (err) => {
expect(err.message).to.include(errorForCommand(command))
done()
})
cy.origin('http://foobar.com:3500', { args: { strategy, command } }, ({ strategy, command }) => {
cy.visit(`/aut-commands?strategy=${strategy}&command=${command}`)
})
})
// cy.origin() doesn't currently have webkit support
it('fails in cross-origin separate script', { browser: '!webkit' }, (done) => {
cy.on('fail', (err) => {
expect(err.message).to.include(errorForCommand(command))
done()
})
cy.origin('http://foobar.com:3500', { args: { strategy, command } }, ({ strategy, command }) => {
cy.visit(`/fixtures/aut-commands.html?strategy=${strategy}&command=${command}`)
})
})
}
})
})
})
})
})
})
@@ -0,0 +1,2 @@
<input type="file" />
<script src="aut-commands.js"></script>
@@ -0,0 +1,97 @@
(() => {
const urlParams = new URLSearchParams(window.__search || window.location.search)
const appendToSpecFrame = !!urlParams.get('appendToSpecFrame')
const strategy = urlParams.get('strategy')
const command = urlParams.get('command')
const cy = window.Cypress.cy
if (cy.state('current')) {
cy.state('current').attributes.args = [() => {}]
}
const TOP = 'top' // prevents frame-busting
// recursively tries sibling frames until finding the spec frame, which
// should be the first same-origin one we come across
const specFrame = window.__isSpecFrame ? window : (() => {
const tryFrame = (index) => {
try {
// will throw if cross-origin
window[TOP].frames[index].location.href
return window[TOP].frames[index]
} catch (err) {
return tryFrame(index + 1)
}
}
return tryFrame(1)
})()
const run = () => {
switch (command) {
case 'exec':
cy.exec('echo "Goodbye"')
break
case 'readFile':
cy.readFile('cypress/fixtures/example.json')
break
case 'writeFile':
cy.writeFile('cypress/_test-output/written.json', 'other contents')
break
case 'task':
cy.task('return:arg', 'other arg')
break
case 'selectFile':
cy.get('input').selectFile('cypress/fixtures/example.json')
break
case 'origin':
cy.origin('http://barbaz.com:3500', () => {})
break
default:
throw new Error(`Command not supported: ${command}`)
}
}
const runString = run.toString()
// instead of running this script in the AUT, this appends it to the
// spec frame to run it there
if (appendToSpecFrame) {
cy.wait(500) // gives the script time to run without the queue ending
const beforeScript = specFrame.document.createElement('script')
beforeScript.textContent = `
window.__search = '${window.location.search.replace('appendToSpecFrame=true&', '')}'
window.__isSpecFrame = true
`
specFrame.document.body.appendChild(beforeScript)
const scriptEl = specFrame.document.createElement('script')
scriptEl.src = '/fixtures/aut-commands.js'
specFrame.document.body.appendChild(scriptEl)
return
}
switch (strategy) {
case 'inline':
run()
break
case 'then':
cy.then(run)
break
case 'eval':
specFrame.eval(`(command) => { (${runString})() }`)(command)
break
case 'function': {
const fn = new specFrame.Function('command', `(${runString})()`)
fn.call(specFrame, command)
break
}
default:
throw new Error(`Strategy not supported: ${strategy}`)
}
})()
+16 -3
View File
@@ -1,4 +1,4 @@
const fs = require('fs')
const fs = require('fs-extra')
const auth = require('basic-auth')
const bodyParser = require('body-parser')
const express = require('express')
@@ -355,11 +355,24 @@ const createApp = (port) => {
const el = document.createElement('p')
el.id = 'p' + i
el.innerHTML = 'x'.repeat(100000)
document.body.appendChild(el)
}
</script>
</html>
</html>
`)
})
app.get('/aut-commands', async (req, res) => {
const script = (await fs.readFileAsync(path.join(__dirname, '..', 'fixtures', 'aut-commands.js'))).toString()
res.send(`
<html>
<body>
<input type="file" />
<script>${script}</script>
</body>
</html>
`)
})
+22 -1
View File
@@ -11,7 +11,9 @@ if (!isActuallyInteractive) {
Cypress.config('retries', 2)
}
beforeEach(() => {
let ranPrivilegedCommandsInBeforeEach = false
beforeEach(function () {
// always set that we're interactive so we
// get consistent passes and failures when running
// from CI and when running in GUI mode
@@ -30,6 +32,25 @@ beforeEach(() => {
try {
$(cy.state('window')).off()
} catch (error) {} // eslint-disable-line no-empty
// only want to run this as part of the privileged commands spec
if (cy.config('spec').baseName === 'privileged_commands.cy.ts') {
cy.visit('/fixtures/files-form.html')
// it only needs to run once per spec run
if (ranPrivilegedCommandsInBeforeEach) return
ranPrivilegedCommandsInBeforeEach = true
cy.exec('echo "hello"')
cy.readFile('cypress/fixtures/app.json')
cy.writeFile('cypress/_test-output/written.json', 'contents')
cy.task('return:arg', 'arg')
cy.get('#basic').selectFile('cypress/fixtures/valid.json')
if (!Cypress.isBrowser({ family: 'webkit' })) {
cy.origin('http://foobar.com:3500', () => {})
}
}
})
// this is here to test that cy.origin() dependencies used directly in the
+23
View File
@@ -172,6 +172,29 @@ export const makeRequestForCookieBehaviorTests = (
})
}
function runCommands () {
cy.exec('echo "hello"')
cy.readFile('cypress/fixtures/app.json')
cy.writeFile('cypress/_test-output/written.json', 'contents')
cy.task('return:arg', 'arg')
cy.get('#basic').selectFile('cypress/fixtures/valid.json')
if (!Cypress.isBrowser({ family: 'webkit' })) {
cy.origin('http://foobar.com:3500', () => {})
}
}
export const runImportedPrivilegedCommands = runCommands
declare global {
interface Window {
runGlobalPrivilegedCommands: () => void
}
}
window.runGlobalPrivilegedCommands = runCommands
Cypress.Commands.add('runSupportFileCustomPrivilegedCommands', runCommands)
Cypress.Commands.addQuery('getAll', getAllFn)
Cypress.Commands.add('shouldWithTimeout', shouldWithTimeout)
@@ -167,6 +167,16 @@ export class PrimaryOriginCommunicator extends EventEmitter {
preprocessedData.args = data.args
}
// if the data has an error/err, it needs special handling for Firefox or
// else it will end up ignored because it's not structured-cloneable
if (data?.error) {
preprocessedData.error = preprocessForSerialization(data.error)
}
if (data?.err) {
preprocessedData.err = preprocessForSerialization(data.err)
}
// If there is no crossOriginDriverWindows, there is no need to send the message.
source.postMessage({
event,
@@ -8,6 +8,10 @@ export const handleSocketEvents = (Cypress) => {
timeout: Cypress.config().defaultCommandTimeout,
})
if (response && response.error) {
return callback({ error: response.error })
}
callback({ response })
}
@@ -181,9 +181,16 @@ export const handleOriginFn = (Cypress: Cypress.Cypress, cy: $Cy) => {
Cypress.specBridgeCommunicator.toPrimary('queue:finished', { err }, { syncGlobals: true })
})
// the name of this function is used to verify if privileged commands are
// properly called. it shouldn't be removed and if the name is changed, it
// needs to also be changed in server/lib/browsers/privileged-channel.js
function invokeOriginFn (callback) {
return window.eval(`(${callback})`)(args)
}
try {
const callback = await getCallbackFn(fn, file)
const value = window.eval(`(${callback})`)(args)
const value = invokeOriginFn(callback)
// If we detect a non promise value with commands in queue, throw an error
if (value && cy.queue.length > 0 && !value.then) {
@@ -6,6 +6,7 @@ import $dom from '../../../dom'
import $errUtils from '../../../cypress/error_utils'
import $actionability from '../../actionability'
import { addEventCoords, dispatch } from './trigger'
import { runPrivilegedCommand, trimUserArgs } from '../../../util/privileged_channel'
/* dropzone.js relies on an experimental, nonstandard API, webkitGetAsEntry().
* https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/webkitGetAsEntry
@@ -82,6 +83,15 @@ interface InternalSelectFileOptions extends Cypress.SelectFileOptions {
eventTarget: JQuery
}
interface FilePathObject {
fileName?: string
index: number
isFilePath: boolean
lastModified?: number
mimeType?: string
path: string
}
const ACTIONS = {
select: (element, dataTransfer, coords, state) => {
(element as HTMLInputElement).files = dataTransfer.files
@@ -153,32 +163,64 @@ export default (Commands, Cypress, cy, state, config) => {
}
}
// Uses backend read:file rather than cy.readFile because we don't want to retry
// loading a specific file until timeout, but rather retry the selectFile command as a whole
const handlePath = async (file, options) => {
return Cypress.backend('read:file', file.contents, { encoding: null })
.then(({ contents }) => {
return {
// We default to the filename on the path, but allow them to override
fileName: basename(file.contents),
...file,
contents: Cypress.Buffer.from(contents),
}
const readFiles = async (filePaths, options, userArgs) => {
if (!filePaths.length) return []
// This reads the file with privileged access in the same manner as
// cy.readFile(). We call directly into the backend rather than calling
// cy.readFile() directly because we don't want to retry loading a specific
// file until timeout, but rather retry the selectFile command as a whole
return runPrivilegedCommand({
commandName: 'selectFile',
cy,
Cypress: (Cypress as unknown) as InternalCypress.Cypress,
options: {
files: filePaths,
},
userArgs,
})
.then((results) => {
return results.map((result) => {
return {
// We default to the filename on the path, but allow them to override
fileName: basename(result.path),
...result,
contents: Cypress.Buffer.from(result.contents),
}
})
})
.catch((err) => {
if (err.isNonSpec) {
$errUtils.throwErrByPath('miscellaneous.non_spec_invocation', {
args: { cmd: 'selectFile' },
})
}
if (err.code === 'ENOENT') {
$errUtils.throwErrByPath('files.nonexistent', {
args: { cmd: 'selectFile', file: file.contents, filePath: err.filePath },
args: { cmd: 'selectFile', file: err.originalFilePath, filePath: err.filePath },
})
}
$errUtils.throwErrByPath('files.unexpected_error', {
onFail: options._log,
args: { cmd: 'selectFile', action: 'read', file, filePath: err.filePath, error: err.message },
args: { cmd: 'selectFile', action: 'read', file: err.originalFilePath, filePath: err.filePath, error: err.message },
})
})
}
const getFilePathObject = (file, index) => {
return {
encoding: null,
fileName: file.fileName,
index,
isFilePath: true,
lastModified: file.lastModified,
mimeType: file.mimeType,
path: file.contents,
}
}
/*
* Turns a user-provided file - a string shorthand, ArrayBuffer, or object
* into an object of form {
@@ -191,7 +233,7 @@ export default (Commands, Cypress, cy, state, config) => {
* we warn them and suggest how to fix it.
*/
const parseFile = (options) => {
return async (file: any, index: number, filesArray: any[]): Promise<Cypress.FileReferenceObject> => {
return (file: any, index: number, filesArray: any[]): Cypress.FileReferenceObject | FilePathObject => {
if (typeof file === 'string' || ArrayBuffer.isView(file)) {
file = { contents: file }
}
@@ -212,10 +254,13 @@ export default (Commands, Cypress, cy, state, config) => {
}
if (typeof file.contents === 'string') {
file = handleAlias(file, options) ?? await handlePath(file, options)
// if not an alias, an object representing that the file is a path that
// needs to be read from disk. contents are an empty string to they
// it skips the next check
file = handleAlias(file, options) ?? getFilePathObject(file, index)
}
if (!_.isString(file.contents) && !ArrayBuffer.isView(file.contents)) {
if (!file.isFilePath && !_.isString(file.contents) && !ArrayBuffer.isView(file.contents)) {
file.contents = JSON.stringify(file.contents)
}
@@ -223,8 +268,24 @@ export default (Commands, Cypress, cy, state, config) => {
}
}
async function collectFiles (files, options, userArgs) {
const filesCollection = ([] as (Cypress.FileReference | FilePathObject)[]).concat(files).map(parseFile(options))
// if there are any file paths, read them from the server in one go
const filePaths = filesCollection.filter((file) => (file as FilePathObject).isFilePath)
const filePathResults = await readFiles(filePaths, options, userArgs)
// stitch them back into the collection
filePathResults.forEach((filePathResult) => {
filesCollection[filePathResult.index] = _.pick(filePathResult, 'contents', 'fileName', 'mimeType', 'lastModified')
})
return filesCollection as Cypress.FileReferenceObject[]
}
Commands.addAll({ prevSubject: 'element' }, {
async selectFile (subject: JQuery<any>, files: Cypress.FileReference | Cypress.FileReference[], options: Partial<InternalSelectFileOptions>): Promise<JQuery> {
const userArgs = trimUserArgs([files, _.isObject(options) ? { ...options } : undefined])
options = _.defaults({}, options, {
action: 'select',
log: true,
@@ -287,8 +348,7 @@ export default (Commands, Cypress, cy, state, config) => {
}
// Make sure files is an array even if the user only passed in one
const filesArray = await Promise.all(([] as Cypress.FileReference[]).concat(files).map(parseFile(options)))
const filesArray = await collectFiles(files, options, userArgs)
const subjectChain = cy.subjectChain()
// We verify actionability on the subject, rather than the eventTarget,
+27 -9
View File
@@ -3,18 +3,24 @@ import Promise from 'bluebird'
import $errUtils from '../../cypress/error_utils'
import type { Log } from '../../cypress/log'
import { runPrivilegedCommand, trimUserArgs } from '../../util/privileged_channel'
interface InternalExecOptions extends Partial<Cypress.ExecOptions> {
_log?: Log
cmd?: string
timeout: number
}
export default (Commands, Cypress, cy) => {
Commands.addAll({
exec (cmd: string, userOptions: Partial<Cypress.ExecOptions> = {}) {
exec (cmd: string, userOptions: Partial<Cypress.ExecOptions>) {
const userArgs = trimUserArgs([cmd, userOptions])
userOptions = userOptions || {}
const options: InternalExecOptions = _.defaults({}, userOptions, {
log: true,
timeout: Cypress.config('execTimeout'),
timeout: Cypress.config('execTimeout') as number,
failOnNonZeroExit: true,
env: {},
})
@@ -46,7 +52,13 @@ export default (Commands, Cypress, cy) => {
// because we're handling timeouts ourselves
cy.clearTimeout()
return Cypress.backend('exec', _.pick(options, 'cmd', 'timeout', 'env'))
return runPrivilegedCommand({
commandName: 'exec',
cy,
Cypress: (Cypress as unknown) as InternalCypress.Cypress,
options: _.pick(options, 'cmd', 'timeout', 'env'),
userArgs,
})
.timeout(options.timeout)
.then((result) => {
if (options._log) {
@@ -75,20 +87,26 @@ export default (Commands, Cypress, cy) => {
})
})
.catch(Promise.TimeoutError, { timedOut: true }, () => {
return $errUtils.throwErrByPath('exec.timed_out', {
$errUtils.throwErrByPath('exec.timed_out', {
onFail: options._log,
args: { cmd, timeout: options.timeout },
})
})
.catch((error) => {
.catch((err) => {
// re-throw if timedOut error from above
if (error.name === 'CypressError') {
throw error
if (err.name === 'CypressError') {
throw err
}
return $errUtils.throwErrByPath('exec.failed', {
if (err.isNonSpec) {
$errUtils.throwErrByPath('miscellaneous.non_spec_invocation', {
args: { cmd: 'exec' },
})
}
$errUtils.throwErrByPath('exec.failed', {
onFail: options._log,
args: { cmd, error },
args: { cmd, error: err },
})
})
},
+55 -7
View File
@@ -3,24 +3,34 @@ import { basename } from 'path'
import $errUtils from '../../cypress/error_utils'
import type { Log } from '../../cypress/log'
import { runPrivilegedCommand, trimUserArgs } from '../../util/privileged_channel'
interface InternalReadFileOptions extends Partial<Cypress.Loggable & Cypress.Timeoutable> {
_log?: Log
encoding: Cypress.Encodings
timeout: number
}
interface InternalWriteFileOptions extends Partial<Cypress.WriteFileOptions & Cypress.Timeoutable> {
_log?: Log
timeout: number
}
type ReadFileOptions = Partial<Cypress.Loggable & Cypress.Timeoutable>
type WriteFileOptions = Partial<Cypress.WriteFileOptions & Cypress.Timeoutable>
export default (Commands, Cypress, cy, state) => {
Commands.addAll({
readFile (file, encoding, userOptions: Partial<Cypress.Loggable & Cypress.Timeoutable> = {}) {
readFile (file: string, encoding: Cypress.Encodings | ReadFileOptions | undefined, userOptions?: ReadFileOptions) {
const userArgs = trimUserArgs([file, encoding, _.isObject(userOptions) ? { ...userOptions } : undefined])
if (_.isObject(encoding)) {
userOptions = encoding
encoding = undefined
}
userOptions = userOptions || {}
const options: InternalReadFileOptions = _.defaults({}, userOptions, {
// https://github.com/cypress-io/cypress/issues/1558
// If no encoding is specified, then Cypress has historically defaulted
@@ -29,7 +39,7 @@ export default (Commands, Cypress, cy, state) => {
// to restore the default node behavior.
encoding: encoding === undefined ? 'utf8' : encoding,
log: true,
timeout: Cypress.config('defaultCommandTimeout'),
timeout: Cypress.config('defaultCommandTimeout') as number,
})
const consoleProps = {}
@@ -56,18 +66,34 @@ export default (Commands, Cypress, cy, state) => {
cy.clearTimeout()
const verifyAssertions = () => {
return Cypress.backend('read:file', file, _.pick(options, 'encoding')).timeout(options.timeout)
return runPrivilegedCommand({
commandName: 'readFile',
cy,
Cypress: (Cypress as unknown) as InternalCypress.Cypress,
options: {
file,
encoding: options.encoding,
},
userArgs,
})
.timeout(options.timeout)
.catch((err) => {
if (err.name === 'TimeoutError') {
return $errUtils.throwErrByPath('files.timed_out', {
$errUtils.throwErrByPath('files.timed_out', {
onFail: options._log,
args: { cmd: 'readFile', file, timeout: options.timeout },
})
}
if (err.isNonSpec) {
$errUtils.throwErrByPath('miscellaneous.non_spec_invocation', {
args: { cmd: 'readFile' },
})
}
// Non-ENOENT errors are not retried
if (err.code !== 'ENOENT') {
return $errUtils.throwErrByPath('files.unexpected_error', {
$errUtils.throwErrByPath('files.unexpected_error', {
onFail: options._log,
args: { cmd: 'readFile', action: 'read', file, filePath: err.filePath, error: err.message },
})
@@ -116,12 +142,16 @@ export default (Commands, Cypress, cy, state) => {
return verifyAssertions()
},
writeFile (fileName, contents, encoding, userOptions: Partial<Cypress.WriteFileOptions & Cypress.Timeoutable> = {}) {
writeFile (fileName: string, contents: string, encoding: Cypress.Encodings | WriteFileOptions | undefined, userOptions: WriteFileOptions) {
const userArgs = trimUserArgs([fileName, contents, encoding, _.isObject(userOptions) ? { ...userOptions } : undefined])
if (_.isObject(encoding)) {
userOptions = encoding
encoding = undefined
}
userOptions = userOptions || {}
const options: InternalWriteFileOptions = _.defaults({}, userOptions, {
// https://github.com/cypress-io/cypress/issues/1558
// If no encoding is specified, then Cypress has historically defaulted
@@ -168,7 +198,19 @@ export default (Commands, Cypress, cy, state) => {
// the timeout ourselves
cy.clearTimeout()
return Cypress.backend('write:file', fileName, contents, _.pick(options, 'encoding', 'flag')).timeout(options.timeout)
return runPrivilegedCommand({
commandName: 'writeFile',
cy,
Cypress: (Cypress as unknown) as InternalCypress.Cypress,
options: {
fileName,
contents,
encoding: options.encoding,
flag: options.flag,
},
userArgs,
})
.timeout(options.timeout)
.then(({ filePath, contents }) => {
consoleProps['File Path'] = filePath
consoleProps['Contents'] = contents
@@ -183,6 +225,12 @@ export default (Commands, Cypress, cy, state) => {
})
}
if (err.isNonSpec) {
return $errUtils.throwErrByPath('miscellaneous.non_spec_invocation', {
args: { cmd: 'writeFile' },
})
}
return $errUtils.throwErrByPath('files.unexpected_error', {
onFail: options._log,
args: { cmd: 'writeFile', action: 'write', file: fileName, filePath: err.filePath, error: err.message },
@@ -9,6 +9,7 @@ import { $Location } from '../../../cypress/location'
import { LogUtils } from '../../../cypress/log'
import logGroup from '../../logGroup'
import type { StateFunc } from '../../../cypress/state'
import { runPrivilegedCommand, trimUserArgs } from '../../../util/privileged_channel'
const reHttp = /^https?:\/\//
@@ -23,15 +24,32 @@ const normalizeOrigin = (urlOrDomain) => {
return $Location.normalize(origin)
}
type OptionsOrFn<T> = { args: T } | (() => {})
type Fn<T> = (args?: T) => {}
function stringifyFn (fn?: any) {
return _.isFunction(fn) ? fn.toString() : undefined
}
function getUserArgs<T> (urlOrDomain: string, optionsOrFn: OptionsOrFn<T>, fn?: Fn<T>) {
return trimUserArgs([
urlOrDomain,
fn && _.isObject(optionsOrFn) ? { ...optionsOrFn } : stringifyFn(optionsOrFn),
fn ? stringifyFn(fn) : undefined,
])
}
export default (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: StateFunc, config: Cypress.InternalConfig) => {
const communicator = Cypress.primaryOriginCommunicator
Commands.addAll({
origin<T> (urlOrDomain: string, optionsOrFn: { args: T } | (() => {}), fn?: (args?: T) => {}) {
origin<T> (urlOrDomain: string, optionsOrFn: OptionsOrFn<T>, fn?: Fn<T>) {
if (Cypress.isBrowser('webkit')) {
return $errUtils.throwErrByPath('webkit.origin')
}
const userArgs = getUserArgs<T>(urlOrDomain, optionsOrFn, fn)
const userInvocationStack = state('current').get('userInvocationStack')
// store the invocation stack in the case that `cy.origin` errors
@@ -185,9 +203,21 @@ export default (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: State
const fn = _.isFunction(callbackFn) ? callbackFn.toString() : callbackFn
const file = $stackUtils.getSourceDetailsForFirstLine(userInvocationStack, config('projectRoot'))?.absoluteFile
// once the secondary origin page loads, send along the
// user-specified callback to run in that origin
try {
// origin is a privileged command, meaning it has to be invoked
// from the spec or support file
await runPrivilegedCommand({
commandName: 'origin',
cy,
Cypress: (Cypress as unknown) as InternalCypress.Cypress,
options: {
specBridgeOrigin,
},
userArgs,
})
// once the secondary origin page loads, send along the
// user-specified callback to run in that origin
communicator.toSpecBridge(origin, 'run:origin:fn', {
args: options?.args || undefined,
fn,
@@ -212,6 +242,12 @@ export default (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: State
logCounter: LogUtils.getCounter(),
})
} catch (err: any) {
if (err.isNonSpec) {
return _reject($errUtils.errByPath('miscellaneous.non_spec_invocation', {
cmd: 'origin',
}))
}
const wrappedErr = $errUtils.errByPath('origin.run_origin_fn_errored', {
error: err.message,
})
+25 -7
View File
@@ -5,17 +5,23 @@ import $utils from '../../cypress/utils'
import $errUtils from '../../cypress/error_utils'
import $stackUtils from '../../cypress/stack_utils'
import type { Log } from '../../cypress/log'
import { runPrivilegedCommand, trimUserArgs } from '../../util/privileged_channel'
interface InternalTaskOptions extends Partial<Cypress.Loggable & Cypress.Timeoutable> {
_log?: Log
timeout: number
}
export default (Commands, Cypress, cy) => {
Commands.addAll({
task (task, arg, userOptions: Partial<Cypress.Loggable & Cypress.Timeoutable> = {}) {
task (task, arg, userOptions: Partial<Cypress.Loggable & Cypress.Timeoutable>) {
const userArgs = trimUserArgs([task, arg, _.isObject(userOptions) ? { ...userOptions } : undefined])
userOptions = userOptions || {}
const options: InternalTaskOptions = _.defaults({}, userOptions, {
log: true,
timeout: Cypress.config('taskTimeout'),
timeout: Cypress.config('taskTimeout') as number,
})
let consoleOutput
@@ -52,10 +58,16 @@ export default (Commands, Cypress, cy) => {
// because we're handling timeouts ourselves
cy.clearTimeout()
return Cypress.backend('task', {
task,
arg,
timeout: options.timeout,
return runPrivilegedCommand({
commandName: 'task',
cy,
Cypress: (Cypress as unknown) as InternalCypress.Cypress,
userArgs,
options: {
task,
arg,
timeout: options.timeout,
},
})
.timeout(options.timeout)
.then((result) => {
@@ -71,7 +83,7 @@ export default (Commands, Cypress, cy) => {
args: { task, timeout: options.timeout },
})
})
.catch({ timedOut: true }, (error) => {
.catch({ timedOut: true }, (error: any) => {
$errUtils.throwErrByPath('task.server_timed_out', {
onFail: options._log,
args: { task, timeout: options.timeout, error: error.message },
@@ -83,6 +95,12 @@ export default (Commands, Cypress, cy) => {
throw err
}
if (err.isNonSpec) {
$errUtils.throwErrByPath('miscellaneous.non_spec_invocation', {
args: { cmd: 'task' },
})
}
err.stack = $stackUtils.normalizedStack(err)
if (err?.isKnownError) {
+14 -1
View File
@@ -45,6 +45,7 @@ import { setupAutEventHandlers } from './cypress/aut_event_handlers'
import type { CachedTestState } from '@packages/types'
import * as cors from '@packages/network/lib/cors'
import { setSpecContentSecurityPolicy } from './util/privileged_channel'
import { telemetry } from '@packages/telemetry/src/browser'
@@ -56,6 +57,8 @@ declare global {
Cypress: Cypress.Cypress
Runner: any
cy: Cypress.cy
// eval doesn't exist on the built-in Window type for some reason
eval (expression: string): any
}
}
@@ -344,7 +347,17 @@ class $Cypress {
this.events.proxyTo(this.cy)
$scriptUtils.runScripts(specWindow, scripts)
$scriptUtils.runScripts({
browser: this.config('browser'),
scripts,
specWindow,
testingType: this.testingType,
})
.then(() => {
if (this.testingType === 'e2e') {
return setSpecContentSecurityPolicy(specWindow)
}
})
.catch((error) => {
this.runner.onSpecError('error')({ error })
})
+3 -1
View File
@@ -20,13 +20,15 @@ export class $Chainer {
static add (key, fn) {
$Chainer.prototype[key] = function (...args) {
const verificationPromise = Cypress.emitMap('command:invocation', { name: key, args })
const userInvocationStack = $stackUtils.normalizedUserInvocationStack(
(new this.specWindow.Error('command invocation stack')).stack,
)
// call back the original function with our new args
// pass args an as array and not a destructured invocation
fn(this, userInvocationStack, args)
fn(this, userInvocationStack, args, verificationPromise)
// return the chainer so additional calls
// are slurped up by the chainer instead of cy
+12 -2
View File
@@ -683,7 +683,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert
const cyFn = wrap(true)
const chainerFn = wrap(false)
const callback = (chainer, userInvocationStack, args, firstCall = false) => {
const callback = (chainer, userInvocationStack, args, verificationPromise, firstCall = false) => {
// dont enqueue / inject any new commands if
// onInjectCommand returns false
const onInjectCommand = cy.state('onInjectCommand')
@@ -699,6 +699,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert
chainerId: chainer.chainerId,
userInvocationStack,
fn: firstCall ? cyFn : chainerFn,
verificationPromise,
}))
}
@@ -707,6 +708,15 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert
cy[name] = function (...args) {
ensureRunnable(cy, name)
// for privileged commands, we send a message to the server that verifies
// them as coming from the spec. the fulfillment of this promise means
// the message was received. the implementation for those commands
// checks to make sure this promise is fulfilled before sending its
// websocket message for running the command to ensure prevent a race
// condition where running the command happens before the command is
// verified
const verificationPromise = Cypress.emitMap('command:invocation', { name, args })
// this is the first call on cypress
// so create a new chainer instance
const chainer = new $Chainer(cy.specWindow)
@@ -717,7 +727,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert
const userInvocationStack = $stackUtils.captureUserInvocationStack(cy.specWindow.Error)
callback(chainer, userInvocationStack, args, true)
callback(chainer, userInvocationStack, args, verificationPromise, true)
// if we are in the middle of a command
// and its return value is a promise
@@ -747,6 +747,7 @@ export default {
},
miscellaneous: {
non_spec_invocation: `${cmd('{{cmd}}')} must only be invoked from the spec file or support file.`,
returned_value_and_commands_from_custom_command (obj) {
return {
message: stripIndent`\
+39 -1
View File
@@ -42,9 +42,38 @@ const runScriptsFromUrls = (specWindow, scripts) => {
.then((scripts) => evalScripts(specWindow, scripts))
}
const appendScripts = (specWindow, scripts) => {
return Bluebird.each(scripts, (script: any) => {
const firstScript = specWindow.document.querySelector('script')
const specScript = specWindow.document.createElement('script')
return new Promise<void>((resolve) => {
specScript.addEventListener('load', () => {
resolve()
})
specScript.src = script.relativeUrl
firstScript.after(specScript)
})
})
}
interface Script {
absolute: string
relative: string
relativeUrl: string
}
interface RunScriptsOptions {
browser: Cypress.Browser
scripts: Script[]
specWindow: Window
testingType: Cypress.TestingType
}
// Supports either scripts as objects or as async import functions
export default {
runScripts: (specWindow, scripts) => {
runScripts: ({ browser, scripts, specWindow, testingType }: RunScriptsOptions) => {
// if scripts contains at least one promise
if (scripts.length && typeof scripts[0] === 'function') {
// chain the loading promises
@@ -54,6 +83,15 @@ export default {
return Bluebird.each(scripts, (script: any) => script())
}
// in webkit, stack traces for e2e are made pretty much useless if these
// scripts are eval'd, so we append them as script tags instead
if (browser.family === 'webkit' && testingType === 'e2e') {
return appendScripts(specWindow, scripts)
}
// for other browsers, we get the contents of the scripts so that we can
// extract and utilize the source maps for better errors and code frames.
// we then eval the script contents to run them
return runScriptsFromUrls(specWindow, scripts)
},
}
@@ -0,0 +1,40 @@
import _ from 'lodash'
import Bluebird from 'bluebird'
/**
* prevents further scripts outside of our own and the spec itself from being
* run in the spec frame
* @param specWindow: Window
*/
export function setSpecContentSecurityPolicy (specWindow) {
const metaEl = specWindow.document.createElement('meta')
metaEl.setAttribute('http-equiv', 'Content-Security-Policy')
metaEl.setAttribute('content', `script-src 'unsafe-eval'`)
specWindow.document.querySelector('head')!.appendChild(metaEl)
}
interface RunPrivilegedCommandOptions {
commandName: string
cy: Cypress.cy
Cypress: InternalCypress.Cypress
options: any
userArgs: any[]
}
export function runPrivilegedCommand ({ commandName, cy, Cypress, options, userArgs }: RunPrivilegedCommandOptions): Bluebird<any> {
return Bluebird.try(() => {
return cy.state('current').get('verificationPromise')[0]
})
.then(() => {
return Cypress.backend('run:privileged', {
commandName,
options,
userArgs,
})
})
}
export function trimUserArgs (args: any[]) {
return _.dropRightWhile(args, _.isUndefined)
}
+3 -1
View File
@@ -68,7 +68,9 @@ declare namespace Cypress {
}
declare namespace InternalCypress {
interface Cypress extends Cypress.Cypress, NodeEventEmitter {}
interface Cypress extends Cypress.Cypress, NodeEventEmitter {
backend: (eventName: string, ...args: any[]) => Promise<any>
}
interface LocalStorage extends Cypress.LocalStorage {
setStorages: (local, remote) => LocalStorage
+2
View File
@@ -5,5 +5,7 @@ declare namespace Cypress {
originLoadUtils(origin: string): Chainable
getAll(...aliases: string[]): Chainable
shouldWithTimeout(cb: (subj: {}) => void, timeout?: number): Chainable
runSpecFileCustomPrivilegedCommands(): Chainable
runSupportFileCustomPrivilegedCommands(): Chainable
}
}
+41 -24
View File
@@ -5,46 +5,54 @@ const debug = require('debug')('cypress:server:controllers')
const { escapeFilenameInUrl } = require('../util/escape_filename')
const { getCtx } = require('@packages/data-context')
const { cors } = require('@packages/network')
const { privilegedCommandsManager } = require('../privileged-commands/privileged-commands-manager')
module.exports = {
handleIframe (req, res, config, remoteStates, extraOptions) {
async handleIframe (req, res, config, remoteStates, extraOptions) {
const test = req.params[0]
const iframePath = cwd('lib', 'html', 'iframe.html')
const specFilter = _.get(extraOptions, 'specFilter')
debug('handle iframe %o', { test, specFilter })
return this.getSpecs(test, config, extraOptions)
.then((specs) => {
const supportFileJs = this.getSupportFile(config)
const allFilesToSend = specs
const specs = await this.getSpecs(test, config, extraOptions)
const supportFileJs = this.getSupportFile(config)
const allFilesToSend = specs
if (supportFileJs) {
allFilesToSend.unshift(supportFileJs)
}
if (supportFileJs) {
allFilesToSend.unshift(supportFileJs)
}
debug('all files to send %o', _.map(allFilesToSend, 'relative'))
debug('all files to send %o', _.map(allFilesToSend, 'relative'))
const superDomain = cors.shouldInjectDocumentDomain(req.proxiedUrl, {
skipDomainInjectionForDomains: config.experimentalSkipDomainInjection,
}) ?
remoteStates.getPrimary().domainName :
undefined
const superDomain = cors.shouldInjectDocumentDomain(req.proxiedUrl, {
skipDomainInjectionForDomains: config.experimentalSkipDomainInjection,
}) ?
remoteStates.getPrimary().domainName :
undefined
const iframeOptions = {
superDomain,
title: this.getTitle(test),
scripts: JSON.stringify(allFilesToSend),
}
debug('iframe %s options %o', test, iframeOptions)
return res.render(iframePath, iframeOptions)
const privilegedChannel = await privilegedCommandsManager.getPrivilegedChannel({
browserFamily: req.query.browserFamily,
isSpecBridge: false,
namespace: config.namespace,
scripts: allFilesToSend,
url: req.proxiedUrl,
})
const iframeOptions = {
superDomain,
title: this.getTitle(test),
scripts: JSON.stringify(allFilesToSend),
privilegedChannel,
}
debug('iframe %s options %o', test, iframeOptions)
res.render(iframePath, iframeOptions)
},
handleCrossOriginIframe (req, res, config) {
async handleCrossOriginIframe (req, res, config) {
const iframePath = cwd('lib', 'html', 'spec-bridge-iframe.html')
const superDomain = cors.shouldInjectDocumentDomain(req.proxiedUrl, {
skipDomainInjectionForDomains: config.experimentalSkipDomainInjection,
@@ -54,10 +62,19 @@ module.exports = {
const origin = cors.getOrigin(req.proxiedUrl)
const privilegedChannel = await privilegedCommandsManager.getPrivilegedChannel({
browserFamily: req.query.browserFamily,
isSpecBridge: true,
namespace: config.namespace,
scripts: [],
url: req.proxiedUrl,
})
const iframeOptions = {
superDomain,
title: `Cypress for ${origin}`,
namespace: config.namespace,
privilegedChannel,
}
debug('cross origin iframe with options %o', iframeOptions)
+24 -6
View File
@@ -1,9 +1,10 @@
const Bluebird = require('bluebird')
const path = require('path')
const { fs } = require('./util/fs')
module.exports = {
readFile (projectRoot, file, options = {}) {
const filePath = path.resolve(projectRoot, file)
readFile (projectRoot, options = {}) {
const filePath = path.resolve(projectRoot, options.file)
const readFn = (path.extname(filePath) === '.json' && options.encoding !== null) ? fs.readJsonAsync : fs.readFileAsync
// https://github.com/cypress-io/cypress/issues/1558
@@ -19,22 +20,39 @@ module.exports = {
}
})
.catch((err) => {
err.originalFilePath = options.file
err.filePath = filePath
throw err
})
},
writeFile (projectRoot, file, contents, options = {}) {
const filePath = path.resolve(projectRoot, file)
readFiles (projectRoot, options = {}) {
return Bluebird.map(options.files, (file) => {
return this.readFile(projectRoot, {
file: file.path,
encoding: file.encoding,
})
.then(({ contents, filePath }) => {
return {
...file,
filePath,
contents,
}
})
})
},
writeFile (projectRoot, options = {}) {
const filePath = path.resolve(projectRoot, options.fileName)
const writeOptions = {
encoding: options.encoding === undefined ? 'utf8' : options.encoding,
flag: options.flag || 'w',
}
return fs.outputFile(filePath, contents, writeOptions)
return fs.outputFile(filePath, options.contents, writeOptions)
.then(() => {
return {
contents,
contents: options.contents,
filePath,
}
})
+8 -5
View File
@@ -5,18 +5,21 @@
<title>{{title}}</title>
</head>
<body>
<script type="text/javascript">
{{if(options.superDomain)}}
document.domain = '{{superDomain}}';
{{/if}}
<script>
{{if(options.superDomain)}}
document.domain = '{{superDomain}}';
{{/if}}
(function(parent) {
var Cypress = window.Cypress = parent.Cypress;
if (!Cypress) {
throw new Error("Tests cannot run without a reference to Cypress!");
}
return Cypress.onSpecWindow(window, {{scripts | safe}});
})(window.opener || window.parent);
</script>
<script>{{privilegedChannel | safe}}</script>
<script>
window.Cypress.onSpecWindow(window, {{scripts | safe}});
</script>
</body>
</html>
@@ -5,11 +5,12 @@
<title>{{title}}</title>
</head>
<body>
<script type="text/javascript">
{{if(options.superDomain)}}
document.domain = '{{superDomain}}';
{{/if}}
<script>
{{if(options.superDomain)}}
document.domain = '{{superDomain}}';
{{/if}}
</script>
<script src="/{{namespace}}/runner/cypress_cross_origin_runner.js"></script>
<script>{{privilegedChannel | safe}}</script>
</body>
</html>
@@ -0,0 +1,159 @@
/* global window */
(({ browserFamily, isSpecBridge, key, namespace, scripts, url, win = window }) => {
/**
* This file is read as a string in the server and injected into the spec
* frame in order to create a privileged channel between the server and
* the spec frame. The values above are provided by the server, with the
* `key` being particularly important since it is used to validate
* any messages sent from this channel back to the server.
*
* This file does not get preprocessed, so it should not contain syntax that
* our minimum supported browsers do not support.
*/
const Err = win.Error
const captureStackTrace = win.Error.captureStackTrace
const filter = win.Array.prototype.filter
const arrayIncludes = win.Array.prototype.includes
const map = win.Array.prototype.map
const stringIncludes = win.String.prototype.includes
const replace = win.String.prototype.replace
const split = win.String.prototype.split
const functionToString = win.Function.prototype.toString
const fetch = win.fetch
const parse = win.JSON.parse
const stringify = win.JSON.stringify
const queryStringRegex = /\?.*$/
// since this function is eval'd, the scripts are included as stringified JSON
if (scripts) {
scripts = parse(scripts)
}
// when privileged commands are called within the cy.origin() callback,
// since the callback is eval'd in the spec bridge instead of being run
// directly in the spec frame, we need to use different criteria, namely
// that the stack includes the function where we eval the callback
const hasSpecBridgeInvocation = (err) => {
switch (browserFamily) {
case 'chromium':
return stringIncludes.call(err.stack, 'at invokeOriginFn')
case 'firefox':
return stringIncludes.call(err.stack, 'invokeOriginFn@')
// currently, this won't run in webkit since it doesn't
// support cy.origin()
default:
return false
}
}
// in chromium, stacks only include lines from the frame where the error is
// created, so to validate a function call was from the spec frame, we strip
// message lines and any eval calls (since they could be invoked from outside
// the spec frame) and if there are lines left, they must have been from
// the spec frame itself
const hasSpecFrameStackLines = (err) => {
const stackLines = split.call(err.stack, '\n')
const filteredLines = filter.call(stackLines, (line) => {
return (
!stringIncludes.call(line, err.message)
&& !stringIncludes.call(line, 'eval at <anonymous>')
)
})
return filteredLines.length > 0
}
// in non-chromium browsers, the stack will include either the spec file url
// or the support file
const hasStackLinesFromSpecOrSupportFile = (err) => {
return filter.call(scripts, (script) => {
// in webkit, stack line might not include the query string
if (browserFamily === 'webkit') {
script = replace.call(script, queryStringRegex, '')
}
return stringIncludes.call(err.stack, script)
}).length > 0
}
// privileged commands are commands that should only be called from the spec
// because they escape the browser sandbox and (generally) have access to node
const privilegedCommands = [
'exec',
// cy.origin() doesn't directly access node, but is a pathway for other
// commands to do so
'origin',
'readFile',
// cy.selectFile() accesses node when using the path argument to read a file
'selectFile',
'writeFile',
'task',
]
function stackIsFromSpecFrame (err) {
if (isSpecBridge) {
return hasSpecBridgeInvocation(err)
}
if (browserFamily === 'chromium') {
return hasSpecFrameStackLines(err)
}
return hasStackLinesFromSpecOrSupportFile(err)
}
async function onCommandInvocation (command) {
if (!arrayIncludes.call(privilegedCommands, command.name)) return
// message doesn't really matter since we're only interested in the stack
const err = new Err('command stack error')
// strips the stack for this function itself, so we get a more accurate
// look at where the command was called from
if (captureStackTrace) {
captureStackTrace.call(Err, err, onCommandInvocation)
}
// if stack is not validated as being from the spec frame, don't add
// it as a verified command
if (!stackIsFromSpecFrame(err)) return
const args = map.call([...command.args], (arg) => {
if (typeof arg === 'function') {
return functionToString.call(arg)
}
return arg
})
// if we verify a privileged command was invoked from the spec frame, we
// send it to the server, where it's stored in state. when the command is
// run and it sends its message to the server via websocket, we check
// that verified status before allowing the command to continue running
await fetch(`/${namespace}/add-verified-command`, {
body: stringify({
args,
name: command.name,
key,
url,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
}).catch(() => {
// this erroring is unlikely, but it's fine to ignore. if adding the
// verified command failed, the default behavior is NOT to allow
// the privileged command to run
})
}
win.Cypress.on('command:invocation', onCommandInvocation)
// returned for testing purposes only
return {
onCommandInvocation,
}
})
@@ -0,0 +1,126 @@
import _ from 'lodash'
import os from 'os'
import path from 'path'
import { v4 as uuidv4 } from 'uuid'
import exec from '../exec'
import files from '../files'
import { fs } from '../util/fs'
import task from '../task'
export interface SpecChannelOptions {
isSpecBridge: boolean
url: string
key: string
}
interface SpecOriginatedCommand {
name: string
args: any[]
}
type NonSpecError = Error & { isNonSpec: boolean | undefined }
type ChannelUrl = string
type ChannelKey = string
class PrivilegedCommandsManager {
channelKeys: Record<ChannelUrl, ChannelKey> = {}
verifiedCommands: SpecOriginatedCommand[] = []
async getPrivilegedChannel (options) {
// setting up a non-spec bridge channel means the beginning of running
// a spec and is a signal that we should reset state
if (!options.isSpecBridge) {
this.reset()
}
// no-op if already set up for url
if (this.channelKeys[options.url]) return
const key = uuidv4()
this.channelKeys[options.url] = key
const script = (await fs.readFileAsync(path.join(__dirname, 'privileged-channel.js'))).toString()
const specScripts = JSON.stringify(options.scripts.map(({ relativeUrl }) => {
if (os.platform() === 'win32') {
return relativeUrl.replaceAll('\\', '\\\\')
}
return relativeUrl
}))
return `${script}({
browserFamily: '${options.browserFamily}',
isSpecBridge: ${options.isSpecBridge || 'false'},
key: '${key}',
namespace: '${options.namespace}',
scripts: '${specScripts}',
url: '${options.url}'
})`
}
addVerifiedCommand ({ args, name, key, url }) {
// if the key isn't valid, don't add it as a verified command. once the
// command attempts to run, it will fail at that point
if (key !== this.channelKeys[url]) return
this.verifiedCommands.push({ name, args })
}
// finds and returns matching command from the verified commands array. it
// also removes that command from the verified commands array
hasVerifiedCommand (command) {
const matchingCommand = _.find(this.verifiedCommands, ({ name, args }) => {
return command.name === name && _.isEqual(command.args, _.dropRightWhile(args, _.isUndefined))
})
return !!matchingCommand
}
runPrivilegedCommand (config, { commandName, options, userArgs }) {
// the presence of the command within the verifiedCommands array indicates
// the command being run is verified
const hasCommand = this.hasVerifiedCommand({ name: commandName, args: userArgs })
if (config.testingType === 'e2e' && !hasCommand) {
// this error message doesn't really matter as each command will catch it
// in the driver based on err.isNonSpec and throw a different error
const err = new Error(`cy.${commandName}() must be invoked from the spec file or support file`) as NonSpecError
err.isNonSpec = true
throw err
}
switch (commandName) {
case 'exec':
return exec.run(config.projectRoot, options)
case 'origin':
// only need to verify that it's spec-originated above
return
case 'readFile':
return files.readFile(config.projectRoot, options)
case 'selectFile':
return files.readFiles(config.projectRoot, options)
case 'writeFile':
return files.writeFile(config.projectRoot, options)
case 'task': {
const configFile = config.configFile && config.configFile.includes(config.projectRoot)
? config.configFile
: path.join(config.projectRoot, config.configFile)
return task.run(configFile ?? null, options)
}
default:
throw new Error(`You requested a secure backend event for a command we cannot handle: ${commandName}`)
}
}
reset () {
this.channelKeys = {}
this.verifiedCommands = []
}
}
export const privilegedCommandsManager = new PrivilegedCommandsManager()
+7 -29
View File
@@ -1,7 +1,6 @@
import bodyParser from 'body-parser'
import Debug from 'debug'
import { Router } from 'express'
import fs from 'fs-extra'
import path from 'path'
import AppData from './util/app_data'
@@ -12,6 +11,7 @@ import client from './controllers/client'
import files from './controllers/files'
import type { InitializeRoutes } from './routes'
import * as plugins from './plugins'
import { privilegedCommandsManager } from './privileged-commands/privileged-commands-manager'
const debug = Debug('cypress:server:routes-e2e')
@@ -19,7 +19,6 @@ export const createRoutesE2E = ({
config,
networkProxy,
onError,
getSpec,
}: InitializeRoutes) => {
const routesE2E = Router()
@@ -32,26 +31,6 @@ export const createRoutesE2E = ({
specController.handle(test, req, res, config, next, onError)
})
routesE2E.get(`/${config.namespace}/get-file/:filePath`, async (req, res) => {
const { filePath } = req.params
debug('get file: %s', filePath)
try {
const contents = await fs.readFile(filePath)
res.json({ contents: contents.toString() })
} catch (err) {
const errorMessage = `Getting the file at the following path errored:\nPath: ${filePath}\nError: ${err.stack}`
debug(errorMessage)
res.json({
error: errorMessage,
})
}
})
routesE2E.post(`/${config.namespace}/process-origin-callback`, bodyParser.json(), async (req, res) => {
try {
const { file, fn, projectRoot } = req.body
@@ -96,13 +75,6 @@ export const createRoutesE2E = ({
networkProxy.handleSourceMapRequest(req, res)
})
// special fallback - serve local files from the project's root folder
routesE2E.get('/__root/*', (req, res) => {
const file = path.join(config.projectRoot, req.params[0])
res.sendFile(file, { etag: false })
})
// special fallback - serve dist'd (bundled/static) files from the project path folder
routesE2E.get(`/${config.namespace}/bundled/*`, (req, res) => {
const file = AppData.getBundledFilePath(config.projectRoot, path.join('src', req.params[0]))
@@ -131,5 +103,11 @@ export const createRoutesE2E = ({
files.handleCrossOriginIframe(req, res, config)
})
routesE2E.post(`/${config.namespace}/add-verified-command`, bodyParser.json(), (req, res) => {
privilegedCommandsManager.addVerifiedCommand(req.body)
res.sendStatus(204)
})
return routesE2E
}
+3 -17
View File
@@ -2,7 +2,6 @@ import Bluebird from 'bluebird'
import Debug from 'debug'
import EventEmitter from 'events'
import _ from 'lodash'
import path from 'path'
import { getCtx } from '@packages/data-context'
import { handleGraphQLSocketRequest } from '@packages/graphql/src/makeGraphQLServer'
import { onNetStubbingEvent } from '@packages/net-stubbing'
@@ -10,10 +9,7 @@ import * as socketIo from '@packages/socket'
import firefoxUtil from './browsers/firefox-util'
import * as errors from './errors'
import exec from './exec'
import files from './files'
import fixture from './fixture'
import task from './task'
import { ensureProp } from './util/class-helpers'
import { getUserEditor, setUserEditor } from './util/editors'
import { openFile, OpenFileDetails } from './util/file-opener'
@@ -31,6 +27,7 @@ import type { Socket } from '@packages/socket'
import type { RunState, CachedTestState } from '@packages/types'
import { cors } from '@packages/network'
import memory from './browsers/memory'
import { privilegedCommandsManager } from './privileged-commands/privileged-commands-manager'
type StartListeningCallbacks = {
onSocketConnection: (socket: any) => void
@@ -386,11 +383,6 @@ export class SocketBase {
debug('backend:request %o', { eventName, args })
const backendRequest = () => {
// TODO: standardize `configFile`; should it be absolute or relative to projectRoot?
const cfgFile = config.configFile && config.configFile.includes(config.projectRoot)
? config.configFile
: path.join(config.projectRoot, config.configFile)
switch (eventName) {
case 'preserve:run:state':
runState = args[0]
@@ -411,10 +403,6 @@ export class SocketBase {
return firefoxUtil.collectGarbage()
case 'get:fixture':
return getFixture(args[0], args[1])
case 'read:file':
return files.readFile(config.projectRoot, args[0], args[1])
case 'write:file':
return files.writeFile(config.projectRoot, args[0], args[1], args[2])
case 'net':
return onNetStubbingEvent({
eventName: args[0],
@@ -424,10 +412,6 @@ export class SocketBase {
getFixture,
args,
})
case 'exec':
return exec.run(config.projectRoot, args[0])
case 'task':
return task.run(cfgFile ?? null, args[0])
case 'save:session':
return session.saveSession(args[0])
case 'clear:sessions':
@@ -456,6 +440,8 @@ export class SocketBase {
return memory.endProfiling()
case 'check:memory:pressure':
return memory.checkMemoryPressure({ ...args[0], automation })
case 'run:privileged':
return privilegedCommandsManager.runPrivilegedCommand(config, args[0])
case 'telemetry':
return (telemetry.exporter() as OTLPTraceExporterCloud)?.send(args[0], () => {}, (err) => {
debug('error exporting telemetry data from browser %s', err)
+1
View File
@@ -9,6 +9,7 @@ type Promisified<T extends (...args: any) => any>
interface PromisifiedFsExtra {
statAsync: (path: string | Buffer) => Bluebird<ReturnType<typeof fsExtra.statSync>>
removeAsync: Promisified<typeof fsExtra.removeSync>
readFileAsync: Promisified<typeof fsExtra.readFileSync>
writeFileAsync: Promisified<typeof fsExtra.writeFileSync>
pathExistsAsync: Promisified<typeof fsExtra.pathExistsSync>
}
@@ -46,10 +46,7 @@ describe('lib/browsers/cri-client', function () {
})
getClient = () => {
return criClient.create({
target: DEBUGGER_URL,
onError,
})
return criClient.create(DEBUGGER_URL, onError)
}
})
@@ -109,9 +106,12 @@ describe('lib/browsers/cri-client', function () {
const client = await getClient()
client.send('Page.enable')
// @ts-ignore
client.send('Page.foo')
// @ts-ignore
client.send('Page.bar')
client.send('Network.enable')
// @ts-ignore
client.send('Network.baz')
// clear out previous calls before reconnect
@@ -124,5 +124,21 @@ describe('lib/browsers/cri-client', function () {
expect(criStub.send).to.be.calledWith('Page.enable')
expect(criStub.send).to.be.calledWith('Network.enable')
})
it('errors if reconnecting fails', async () => {
criStub._notifier.on = sinon.stub()
criStub.close.throws(new Error('could not reconnect'))
await getClient()
// @ts-ignore
await criStub._notifier.on.withArgs('disconnect').args[0][1]()
expect(onError).to.be.called
const error = onError.lastCall.args[0]
expect(error.messageMarkdown).to.equal('There was an error reconnecting to the Chrome DevTools protocol. Please restart the browser.')
expect(error.isFatalApiErr).to.be.true
})
})
})
@@ -0,0 +1,180 @@
require('../../spec_helper')
const path = require('path')
const { fs } = require('../../../lib/util/fs')
describe('privileged channel', () => {
let runPrivilegedChannel
let win
beforeEach(async () => {
const secureChannelScript = (await fs.readFileAsync(path.join(__dirname, '..', '..', '..', 'lib', 'privileged-commands', 'privileged-channel.js'))).toString()
const ErrorStub = function (message) {
return new Error(message)
}
ErrorStub.captureStackTrace = sinon.stub()
// need to pull out the methods like this so when they're overwritten
// in the tests, they don't mess up the actual globals since the test
// runner itself relies on them
win = {
Array: { prototype: {
filter: Array.prototype.filter,
includes: Array.prototype.includes,
map: Array.prototype.map,
} },
Error: ErrorStub,
Cypress: {
on () {},
},
fetch: sinon.stub().resolves(),
Function: { prototype: {
toString: Function.prototype.toString,
} },
JSON: {
parse: JSON.parse,
stringify: JSON.stringify,
},
String: { prototype: {
includes: String.prototype.includes,
replace: String.prototype.replace,
split: String.prototype.split,
} },
}
runPrivilegedChannel = () => {
return eval(`${secureChannelScript}`)({
browserFamily: 'chromium',
isSpecBridge: false,
key: '1234',
namespace: '__cypress',
scripts: JSON.stringify(['cypress/e2e/spec.cy.js']),
url: 'http://localhost:12345/__cypress/tests?p=cypress/integration/some-spec.js',
win,
})
}
})
describe('overwriting native objects and methods has no effect', () => {
it('Error', () => {
const { onCommandInvocation } = runPrivilegedChannel()
win.Error = sinon.stub()
onCommandInvocation({ name: 'task', args: [] })
expect(win.Error).not.to.be.called
})
it('Error.captureStackTrace', () => {
const { onCommandInvocation } = runPrivilegedChannel()
win.Error.captureStackTrace = sinon.stub()
onCommandInvocation({ name: 'task', args: [] })
expect(win.Error.captureStackTrace).not.to.be.called
})
it('Array.prototype.filter', () => {
const { onCommandInvocation } = runPrivilegedChannel()
win.Array.prototype.filter = sinon.stub()
onCommandInvocation({ name: 'task', args: [] })
expect(win.Array.prototype.filter).not.to.be.called
})
it('Array.prototype.includes', () => {
const { onCommandInvocation } = runPrivilegedChannel()
win.Array.prototype.includes = sinon.stub()
onCommandInvocation({ name: 'task', args: [] })
expect(win.Array.prototype.includes).not.to.be.called
})
it('Array.prototype.map', () => {
const { onCommandInvocation } = runPrivilegedChannel()
win.Array.prototype.map = sinon.stub()
onCommandInvocation({ name: 'task', args: [] })
expect(win.Array.prototype.map).not.to.be.called
})
it('String.prototype.split', () => {
const { onCommandInvocation } = runPrivilegedChannel()
win.String.prototype.split = sinon.stub()
onCommandInvocation({ name: 'task', args: [] })
expect(win.String.prototype.split).not.to.be.called
})
it('String.prototype.replace', () => {
const { onCommandInvocation } = runPrivilegedChannel()
win.String.prototype.replace = sinon.stub()
onCommandInvocation({ name: 'task', args: [] })
expect(win.String.prototype.replace).not.to.be.called
})
it('String.prototype.includes', () => {
const { onCommandInvocation } = runPrivilegedChannel()
win.String.prototype.includes = sinon.stub()
onCommandInvocation({ name: 'task', args: [] })
expect(win.String.prototype.includes).not.to.be.called
})
it('Function.prototype.toString', () => {
const { onCommandInvocation } = runPrivilegedChannel()
win.Function.prototype.toString = sinon.stub()
onCommandInvocation({ name: 'task', args: [] })
expect(win.Function.prototype.toString).not.to.be.called
})
it('fetch', () => {
const { onCommandInvocation } = runPrivilegedChannel()
win.fetch = sinon.stub().resolves()
onCommandInvocation({ name: 'task', args: [] })
expect(win.fetch).not.to.be.called
})
it('JSON.stringify', () => {
const { onCommandInvocation } = runPrivilegedChannel()
win.JSON.stringify = sinon.stub()
onCommandInvocation({ name: 'task', args: [] })
expect(win.JSON.stringify).not.to.be.called
})
it('JSON.parse', () => {
const { onCommandInvocation } = runPrivilegedChannel()
win.JSON.parse = sinon.stub()
onCommandInvocation({ name: 'task', args: [] })
expect(win.JSON.parse).not.to.be.called
})
})
})
+19 -19
View File
@@ -28,7 +28,7 @@ describe('lib/files', () => {
context('#readFile', () => {
it('returns contents and full file path', function () {
return files.readFile(this.projectRoot, 'tests/_fixtures/message.txt').then(({ contents, filePath }) => {
return files.readFile(this.projectRoot, { file: 'tests/_fixtures/message.txt' }).then(({ contents, filePath }) => {
expect(contents).to.eq('foobarbaz')
expect(filePath).to.include('/cy-projects/todos/tests/_fixtures/message.txt')
@@ -36,26 +36,26 @@ describe('lib/files', () => {
})
it('returns uses utf8 by default', function () {
return files.readFile(this.projectRoot, 'tests/_fixtures/ascii.foo').then(({ contents }) => {
return files.readFile(this.projectRoot, { file: 'tests/_fixtures/ascii.foo' }).then(({ contents }) => {
expect(contents).to.eq('\n')
})
})
it('uses encoding specified in options', function () {
return files.readFile(this.projectRoot, 'tests/_fixtures/ascii.foo', { encoding: 'ascii' }).then(({ contents }) => {
return files.readFile(this.projectRoot, { file: 'tests/_fixtures/ascii.foo', encoding: 'ascii' }).then(({ contents }) => {
expect(contents).to.eq('o#?\n')
})
})
// https://github.com/cypress-io/cypress/issues/1558
it('explicit null encoding is sent to driver as a Buffer', function () {
return files.readFile(this.projectRoot, 'tests/_fixtures/ascii.foo', { encoding: null }).then(({ contents }) => {
return files.readFile(this.projectRoot, { file: 'tests/_fixtures/ascii.foo', encoding: null }).then(({ contents }) => {
expect(contents).to.eql(Buffer.from('\n'))
})
})
it('parses json to valid JS object', function () {
return files.readFile(this.projectRoot, 'tests/_fixtures/users.json').then(({ contents }) => {
return files.readFile(this.projectRoot, { file: 'tests/_fixtures/users.json' }).then(({ contents }) => {
expect(contents).to.eql([
{
id: 1,
@@ -71,8 +71,8 @@ describe('lib/files', () => {
context('#writeFile', () => {
it('writes the file\'s contents and returns contents and full file path', function () {
return files.writeFile(this.projectRoot, '.projects/write_file.txt', 'foo').then(() => {
return files.readFile(this.projectRoot, '.projects/write_file.txt').then(({ contents, filePath }) => {
return files.writeFile(this.projectRoot, { fileName: '.projects/write_file.txt', contents: 'foo' }).then(() => {
return files.readFile(this.projectRoot, { file: '.projects/write_file.txt' }).then(({ contents, filePath }) => {
expect(contents).to.equal('foo')
expect(filePath).to.include('/cy-projects/todos/.projects/write_file.txt')
@@ -81,8 +81,8 @@ describe('lib/files', () => {
})
it('uses encoding specified in options', function () {
return files.writeFile(this.projectRoot, '.projects/write_file.txt', '', { encoding: 'ascii' }).then(() => {
return files.readFile(this.projectRoot, '.projects/write_file.txt').then(({ contents }) => {
return files.writeFile(this.projectRoot, { fileName: '.projects/write_file.txt', contents: '', encoding: 'ascii' }).then(() => {
return files.readFile(this.projectRoot, { file: '.projects/write_file.txt' }).then(({ contents }) => {
expect(contents).to.equal('')
})
})
@@ -90,20 +90,20 @@ describe('lib/files', () => {
// https://github.com/cypress-io/cypress/issues/1558
it('explicit null encoding is written exactly as received', function () {
return files.writeFile(this.projectRoot, '.projects/write_file.txt', Buffer.from(''), { encoding: null }).then(() => {
return files.readFile(this.projectRoot, '.projects/write_file.txt', { encoding: null }).then(({ contents }) => {
return files.writeFile(this.projectRoot, { fileName: '.projects/write_file.txt', contents: Buffer.from(''), encoding: null }).then(() => {
return files.readFile(this.projectRoot, { file: '.projects/write_file.txt', encoding: null }).then(({ contents }) => {
expect(contents).to.eql(Buffer.from(''))
})
})
})
it('overwrites existing file by default', function () {
return files.writeFile(this.projectRoot, '.projects/write_file.txt', 'foo').then(() => {
return files.readFile(this.projectRoot, '.projects/write_file.txt').then(({ contents }) => {
return files.writeFile(this.projectRoot, { fileName: '.projects/write_file.txt', contents: 'foo' }).then(() => {
return files.readFile(this.projectRoot, { file: '.projects/write_file.txt' }).then(({ contents }) => {
expect(contents).to.equal('foo')
return files.writeFile(this.projectRoot, '.projects/write_file.txt', 'bar').then(() => {
return files.readFile(this.projectRoot, '.projects/write_file.txt').then(({ contents }) => {
return files.writeFile(this.projectRoot, { fileName: '.projects/write_file.txt', contents: 'bar' }).then(() => {
return files.readFile(this.projectRoot, { file: '.projects/write_file.txt' }).then(({ contents }) => {
expect(contents).to.equal('bar')
})
})
@@ -112,12 +112,12 @@ describe('lib/files', () => {
})
it('appends content to file when specified', function () {
return files.writeFile(this.projectRoot, '.projects/write_file.txt', 'foo').then(() => {
return files.readFile(this.projectRoot, '.projects/write_file.txt').then(({ contents }) => {
return files.writeFile(this.projectRoot, { fileName: '.projects/write_file.txt', contents: 'foo' }).then(() => {
return files.readFile(this.projectRoot, { file: '.projects/write_file.txt' }).then(({ contents }) => {
expect(contents).to.equal('foo')
return files.writeFile(this.projectRoot, '.projects/write_file.txt', 'bar', { flag: 'a+' }).then(() => {
return files.readFile(this.projectRoot, '.projects/write_file.txt').then(({ contents }) => {
return files.writeFile(this.projectRoot, { fileName: '.projects/write_file.txt', contents: 'bar', flag: 'a+' }).then(() => {
return files.readFile(this.projectRoot, { file: '.projects/write_file.txt' }).then(({ contents }) => {
expect(contents).to.equal('foobar')
})
})
+3 -31
View File
@@ -11,7 +11,6 @@ const errors = require('../../lib/errors')
const { SocketE2E } = require('../../lib/socket-e2e')
const { ServerE2E } = require('../../lib/server-e2e')
const { Automation } = require('../../lib/automation')
const exec = require('../../lib/exec')
const preprocessor = require('../../lib/plugins/preprocessor')
const { fs } = require('../../lib/util/fs')
const session = require('../../lib/session')
@@ -108,11 +107,11 @@ describe('lib/socket', () => {
foo.bar.baz = foo
// going to stub exec here just so we have something that we can
// stubbing session#getSession here just so we have something that we can
// control the resolved value of
sinon.stub(exec, 'run').resolves(foo)
sinon.stub(session, 'getSession').resolves(foo)
return this.client.emit('backend:request', 'exec', 'quuz', (res) => {
return this.client.emit('backend:request', 'get:session', 'quuz', (res) => {
expect(res.response).to.deep.eq(foo)
return done()
@@ -512,33 +511,6 @@ describe('lib/socket', () => {
})
})
context('on(backend:request, exec)', () => {
it('calls exec#run with project root and options', function (done) {
const run = sinon.stub(exec, 'run').returns(Promise.resolve('Desktop Music Pictures'))
return this.client.emit('backend:request', 'exec', { cmd: 'ls' }, (resp) => {
expect(run).to.be.calledWith(this.cfg.projectRoot, { cmd: 'ls' })
expect(resp.response).to.eq('Desktop Music Pictures')
return done()
})
})
it('errors when execution fails, passing through timedOut', function (done) {
const error = new Error('command not found: lsd')
error.timedOut = true
sinon.stub(exec, 'run').rejects(error)
return this.client.emit('backend:request', 'exec', { cmd: 'lsd' }, (resp) => {
expect(resp.error.message).to.equal('command not found: lsd')
expect(resp.error.timedOut).to.be.true
return done()
})
})
})
context('on(backend:request, firefox:force:gc)', () => {
it('calls firefoxUtil#collectGarbage', function (done) {
sinon.stub(firefoxUtil, 'collectGarbage').resolves()
-26
View File
@@ -1,26 +0,0 @@
exports['e2e cdp / handles disconnections as expected'] = `
====================================================================================================
(Run Starting)
Cypress: 1.2.3
Browser: FooBrowser 88
Specs: 1 found (spec.cy.ts)
Searched: cypress/e2e/spec.cy.ts
Running: spec.cy.ts (1 of 1)
e2e remote debugging disconnect
reconnects as expected
There was an error reconnecting to the Chrome DevTools protocol. Please restart the browser.
Error: connect ECONNREFUSED 127.0.0.1:7777
[stack trace lines]
`
@@ -1,5 +0,0 @@
describe('cy.readFile', () => {
it('existence failure', () => {
cy.readFile('does-not-exist', { timeout: 100 })
})
})
@@ -31,14 +31,4 @@ describe('e2e remote debugging disconnect', () => {
currentConnectionCount: 1,
})
})
it('errors if CDP connection cannot be reestablished', () => {
cy.task('destroy:server')
cy.task('kill:active:connections')
cy.then(() => {
// this will cause a project-level error once we realize we can't talk to CDP anymore
return callAutomation()
})
})
})
@@ -81,12 +81,6 @@ module.exports = (on, config) => {
return null
},
'destroy:server' () {
console.error('closing server')
server.close()
return null
},
})
-14
View File
@@ -15,24 +15,10 @@ describe('e2e cdp', function () {
restoreEnv()
})
// NOTE: this test takes almost a minute and is largely redundant with protocol_spec
systemTests.it.skip('fails when remote debugging port cannot be connected to', {
project: 'remote-debugging-port-removed',
spec: 'spec.cy.ts',
browser: 'chrome',
expectedExitCode: 1,
})
// https://github.com/cypress-io/cypress/issues/5685
systemTests.it('handles disconnections as expected', {
project: 'remote-debugging-disconnect',
spec: 'spec.cy.ts',
browser: 'chrome',
expectedExitCode: 1,
snapshot: true,
onStdout: (stdout) => {
// the location of this warning is non-deterministic
return stdout.replace('The automation client disconnected. Cannot continue running tests.\n', '')
},
})
})
+1 -6
View File
@@ -144,16 +144,11 @@
resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-0.5.2.tgz#8c2d931ff927be0ebe740169874a3d4004ab414b"
integrity sha512-CQkeV+oJxUazwjlHD0/3ZD08QWKuGQkhnrKo3e6ly5pd48VUpXbb77q0xMU4+vc2CkJnDS02Eq/M9ugyX20XZA==
"@antfu/utils@^0.7.0":
"@antfu/utils@^0.7.0", "@antfu/utils@^0.7.2":
version "0.7.4"
resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-0.7.4.tgz#b1c11b95f89f13842204d3d83de01e10bb9257db"
integrity sha512-qe8Nmh9rYI/HIspLSTwtbMFPj6dISG6+dJnOguTlPNXtCvS2uezdxscVBb7/3DrmNbQK49TDqpkSQ1chbRGdpQ==
"@antfu/utils@^0.7.2":
version "0.7.2"
resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-0.7.2.tgz#3bb6f37a6b188056fe9e2f363b6aa735ed65d7ca"
integrity sha512-vy9fM3pIxZmX07dL+VX1aZe7ynZ+YyB0jY+jE6r3hOK6GNY2t6W8rzpFC4tgpbXUYABkFQwgJq2XYXlxbXAI0g==
"@ardatan/aggregate-error@0.0.6":
version "0.0.6"
resolved "https://registry.yarnpkg.com/@ardatan/aggregate-error/-/aggregate-error-0.0.6.tgz#fe6924771ea40fc98dc7a7045c2e872dc8527609"