diff --git a/.circleci/workflows.yml b/.circleci/workflows.yml index e4856ddd9417..6494845bca71 100644 --- a/.circleci/workflows.yml +++ b/.circleci/workflows.yml @@ -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 >> diff --git a/cli/types/cypress-eventemitter.d.ts b/cli/types/cypress-eventemitter.d.ts index 86d2587f722b..adf6279c79cf 100644 --- a/cli/types/cypress-eventemitter.d.ts +++ b/cli/types/cypress-eventemitter.d.ts @@ -3,8 +3,8 @@ type EventEmitter2 = import("eventemitter2").EventEmitter2 interface CyEventEmitter extends Omit { 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 diff --git a/packages/app/.gitignore b/packages/app/.gitignore index 2fc8f018b154..d4a187f56ee5 100644 --- a/packages/app/.gitignore +++ b/packages/app/.gitignore @@ -1,4 +1,5 @@ cypress/videos/* cypress/screenshots/* +cypress/downloads/* -components.d.ts \ No newline at end of file +components.d.ts diff --git a/packages/app/cypress/e2e/runner/reporter-ct-generator.ts b/packages/app/cypress/e2e/runner/reporter-ct-generator.ts index 8575230604b8..f5a6e3778c0a 100644 --- a/packages/app/cypress/e2e/runner/reporter-ct-generator.ts +++ b/packages/app/cypress/e2e/runner/reporter-ct-generator.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', diff --git a/packages/app/cypress/e2e/runner/reporter.command_errors.cy.ts b/packages/app/cypress/e2e/runner/reporter.command_errors.cy.ts index a98045bdd543..a17b14057bee 100644 --- a/packages/app/cypress/e2e/runner/reporter.command_errors.cy.ts +++ b/packages/app/cypress/e2e/runner/reporter.command_errors.cy.ts @@ -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', diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index e8cb2180f81b..9b723b3f8f1b 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -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) }) diff --git a/packages/app/src/runner/index.ts b/packages/app/src/runner/index.ts index 69dd26557db8..dfe16a8a049a 100644 --- a/packages/app/src/runner/index.ts +++ b/packages/app/src/runner/index.ts @@ -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 = 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 /** diff --git a/packages/driver/.gitignore b/packages/driver/.gitignore index 944d461994e6..2c3d561d913c 100644 --- a/packages/driver/.gitignore +++ b/packages/driver/.gitignore @@ -1,2 +1,3 @@ cypress/videos cypress/screenshots +cypress/downloads diff --git a/packages/driver/cypress/e2e/commands/exec.cy.js b/packages/driver/cypress/e2e/commands/exec.cy.js index 0d20f786161f..9da38e3b1f7e 100644 --- a/packages/driver/cypress/e2e/commands/exec.cy.js +++ b/packages/driver/cypress/e2e/commands/exec.cy.js @@ -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') }) diff --git a/packages/driver/cypress/e2e/commands/files.cy.js b/packages/driver/cypress/e2e/commands/files.cy.js index 8648ee4bae28..e38b503c9502 100644 --- a/packages/driver/cypress/e2e/commands/files.cy.js +++ b/packages/driver/cypress/e2e/commands/files.cy.js @@ -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+', + }, }, ) }) diff --git a/packages/driver/cypress/e2e/commands/task.cy.js b/packages/driver/cypress/e2e/commands/task.cy.js index 85d92bdf69c0..cee3237adba3 100644 --- a/packages/driver/cypress/e2e/commands/task.cy.js +++ b/packages/driver/cypress/e2e/commands/task.cy.js @@ -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' }, }, }) }) diff --git a/packages/driver/cypress/e2e/cypress/script_utils.cy.js b/packages/driver/cypress/e2e/cypress/script_utils.cy.js index e473c6cb7c7a..1fa77e6a7b82 100644 --- a/packages/driver/cypress/e2e/cypress/script_utils.cy.js +++ b/packages/driver/cypress/e2e/cypress/script_utils.cy.js @@ -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) }) diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/files.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/files.cy.ts index d708b9b9fc94..5181fe038c39 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/files.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/files.cy.ts @@ -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', + }, }, ) }) diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/misc.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/misc.cy.ts index e3d03b1b6936..0e376fbd3d1f 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/misc.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/misc.cy.ts @@ -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 = [ diff --git a/packages/driver/cypress/e2e/e2e/origin/validation.cy.ts b/packages/driver/cypress/e2e/e2e/origin/validation.cy.ts index d5f957330cc4..dd525e57f437 100644 --- a/packages/driver/cypress/e2e/e2e/origin/validation.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/validation.cy.ts @@ -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) diff --git a/packages/driver/cypress/e2e/e2e/privileged_commands.cy.ts b/packages/driver/cypress/e2e/e2e/privileged_commands.cy.ts new file mode 100644 index 000000000000..0f8a59082b08 --- /dev/null +++ b/packages/driver/cypress/e2e/e2e/privileged_commands.cy.ts @@ -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 diff --git a/packages/driver/cypress/fixtures/aut-commands.js b/packages/driver/cypress/fixtures/aut-commands.js new file mode 100644 index 000000000000..806329d9175b --- /dev/null +++ b/packages/driver/cypress/fixtures/aut-commands.js @@ -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}`) + } +})() diff --git a/packages/driver/cypress/plugins/server.js b/packages/driver/cypress/plugins/server.js index f42ed60e466b..d30656234776 100644 --- a/packages/driver/cypress/plugins/server.js +++ b/packages/driver/cypress/plugins/server.js @@ -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) } - + + `) + }) + + app.get('/aut-commands', async (req, res) => { + const script = (await fs.readFileAsync(path.join(__dirname, '..', 'fixtures', 'aut-commands.js'))).toString() + + res.send(` + + + + + + `) }) diff --git a/packages/driver/cypress/support/defaults.js b/packages/driver/cypress/support/defaults.js index 332f3834cc22..400103b4eefe 100644 --- a/packages/driver/cypress/support/defaults.js +++ b/packages/driver/cypress/support/defaults.js @@ -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 diff --git a/packages/driver/cypress/support/utils.ts b/packages/driver/cypress/support/utils.ts index 13050d662e86..1e0283cef1b4 100644 --- a/packages/driver/cypress/support/utils.ts +++ b/packages/driver/cypress/support/utils.ts @@ -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) diff --git a/packages/driver/src/cross-origin/communicator.ts b/packages/driver/src/cross-origin/communicator.ts index 2bb6af60355c..550a6955f79c 100644 --- a/packages/driver/src/cross-origin/communicator.ts +++ b/packages/driver/src/cross-origin/communicator.ts @@ -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, diff --git a/packages/driver/src/cross-origin/events/socket.ts b/packages/driver/src/cross-origin/events/socket.ts index e74058cfb714..a159e1ea5554 100644 --- a/packages/driver/src/cross-origin/events/socket.ts +++ b/packages/driver/src/cross-origin/events/socket.ts @@ -8,6 +8,10 @@ export const handleSocketEvents = (Cypress) => { timeout: Cypress.config().defaultCommandTimeout, }) + if (response && response.error) { + return callback({ error: response.error }) + } + callback({ response }) } diff --git a/packages/driver/src/cross-origin/origin_fn.ts b/packages/driver/src/cross-origin/origin_fn.ts index ac466ad42a8c..35a4e0902e73 100644 --- a/packages/driver/src/cross-origin/origin_fn.ts +++ b/packages/driver/src/cross-origin/origin_fn.ts @@ -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) { diff --git a/packages/driver/src/cy/commands/actions/selectFile.ts b/packages/driver/src/cy/commands/actions/selectFile.ts index 72991131bb9c..3fccfc494880 100644 --- a/packages/driver/src/cy/commands/actions/selectFile.ts +++ b/packages/driver/src/cy/commands/actions/selectFile.ts @@ -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 => { + 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, files: Cypress.FileReference | Cypress.FileReference[], options: Partial): Promise { + 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, diff --git a/packages/driver/src/cy/commands/exec.ts b/packages/driver/src/cy/commands/exec.ts index a0131c604ef1..4a56f684529d 100644 --- a/packages/driver/src/cy/commands/exec.ts +++ b/packages/driver/src/cy/commands/exec.ts @@ -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 { _log?: Log cmd?: string + timeout: number } export default (Commands, Cypress, cy) => { Commands.addAll({ - exec (cmd: string, userOptions: Partial = {}) { + exec (cmd: string, userOptions: Partial) { + 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 + } + + if (err.isNonSpec) { + $errUtils.throwErrByPath('miscellaneous.non_spec_invocation', { + args: { cmd: 'exec' }, + }) } - return $errUtils.throwErrByPath('exec.failed', { + $errUtils.throwErrByPath('exec.failed', { onFail: options._log, - args: { cmd, error }, + args: { cmd, error: err }, }) }) }, diff --git a/packages/driver/src/cy/commands/files.ts b/packages/driver/src/cy/commands/files.ts index 02512836a209..b542f961fcda 100644 --- a/packages/driver/src/cy/commands/files.ts +++ b/packages/driver/src/cy/commands/files.ts @@ -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 { _log?: Log encoding: Cypress.Encodings + timeout: number } interface InternalWriteFileOptions extends Partial { _log?: Log + timeout: number } +type ReadFileOptions = Partial +type WriteFileOptions = Partial + export default (Commands, Cypress, cy, state) => { Commands.addAll({ - readFile (file, encoding, userOptions: Partial = {}) { + 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 = {}) { + 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 }, diff --git a/packages/driver/src/cy/commands/origin/index.ts b/packages/driver/src/cy/commands/origin/index.ts index e71132398b9f..9a22d71de5eb 100644 --- a/packages/driver/src/cy/commands/origin/index.ts +++ b/packages/driver/src/cy/commands/origin/index.ts @@ -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 = { args: T } | (() => {}) +type Fn = (args?: T) => {} + +function stringifyFn (fn?: any) { + return _.isFunction(fn) ? fn.toString() : undefined +} + +function getUserArgs (urlOrDomain: string, optionsOrFn: OptionsOrFn, fn?: Fn) { + 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 (urlOrDomain: string, optionsOrFn: { args: T } | (() => {}), fn?: (args?: T) => {}) { + origin (urlOrDomain: string, optionsOrFn: OptionsOrFn, fn?: Fn) { if (Cypress.isBrowser('webkit')) { return $errUtils.throwErrByPath('webkit.origin') } + const userArgs = getUserArgs(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, }) diff --git a/packages/driver/src/cy/commands/task.ts b/packages/driver/src/cy/commands/task.ts index 7223b95084e7..a3c68af1aa54 100644 --- a/packages/driver/src/cy/commands/task.ts +++ b/packages/driver/src/cy/commands/task.ts @@ -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 { _log?: Log + timeout: number } export default (Commands, Cypress, cy) => { Commands.addAll({ - task (task, arg, userOptions: Partial = {}) { + task (task, arg, userOptions: Partial) { + 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) { diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index 1afd7f2b9444..6ca4eade6750 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -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 }) }) diff --git a/packages/driver/src/cypress/chainer.ts b/packages/driver/src/cypress/chainer.ts index 939489a7d4d5..a54a1a1de643 100644 --- a/packages/driver/src/cypress/chainer.ts +++ b/packages/driver/src/cypress/chainer.ts @@ -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 diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index 4c9b08b40994..a01a7d90965e 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -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 diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index d2b85692e8b9..a2b055b8e607 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -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`\ diff --git a/packages/driver/src/cypress/script_utils.ts b/packages/driver/src/cypress/script_utils.ts index 801bcee4551c..e624b0fa0f2d 100644 --- a/packages/driver/src/cypress/script_utils.ts +++ b/packages/driver/src/cypress/script_utils.ts @@ -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((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) }, } diff --git a/packages/driver/src/util/privileged_channel.ts b/packages/driver/src/util/privileged_channel.ts new file mode 100644 index 000000000000..3a3e9367bdb7 --- /dev/null +++ b/packages/driver/src/util/privileged_channel.ts @@ -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 { + 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) +} diff --git a/packages/driver/types/internal-types.d.ts b/packages/driver/types/internal-types.d.ts index 10e56fb97042..56745656b901 100644 --- a/packages/driver/types/internal-types.d.ts +++ b/packages/driver/types/internal-types.d.ts @@ -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 + } interface LocalStorage extends Cypress.LocalStorage { setStorages: (local, remote) => LocalStorage diff --git a/packages/driver/types/spec-types.d.ts b/packages/driver/types/spec-types.d.ts index 59219725150b..048c91280a74 100644 --- a/packages/driver/types/spec-types.d.ts +++ b/packages/driver/types/spec-types.d.ts @@ -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 } } diff --git a/packages/server/lib/controllers/files.js b/packages/server/lib/controllers/files.js index 81d34c19ee49..97ac1d2f6713 100644 --- a/packages/server/lib/controllers/files.js +++ b/packages/server/lib/controllers/files.js @@ -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), - } + const privilegedChannel = await privilegedCommandsManager.getPrivilegedChannel({ + browserFamily: req.query.browserFamily, + isSpecBridge: false, + namespace: config.namespace, + scripts: allFilesToSend, + url: req.proxiedUrl, + }) - debug('iframe %s options %o', test, iframeOptions) + const iframeOptions = { + superDomain, + title: this.getTitle(test), + scripts: JSON.stringify(allFilesToSend), + privilegedChannel, + } - return res.render(iframePath, iframeOptions) - }) + 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) diff --git a/packages/server/lib/files.js b/packages/server/lib/files.js index 3add13e81ed9..ea497c1c4003 100644 --- a/packages/server/lib/files.js +++ b/packages/server/lib/files.js @@ -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, } }) diff --git a/packages/server/lib/html/iframe.html b/packages/server/lib/html/iframe.html index 8ea4099bbfe0..5225a02f191d 100644 --- a/packages/server/lib/html/iframe.html +++ b/packages/server/lib/html/iframe.html @@ -5,18 +5,21 @@ {{title}} - + + diff --git a/packages/server/lib/html/spec-bridge-iframe.html b/packages/server/lib/html/spec-bridge-iframe.html index 7eda53a49711..80f92b3ee6f5 100644 --- a/packages/server/lib/html/spec-bridge-iframe.html +++ b/packages/server/lib/html/spec-bridge-iframe.html @@ -5,11 +5,12 @@ {{title}} - + diff --git a/packages/server/lib/privileged-commands/privileged-channel.js b/packages/server/lib/privileged-commands/privileged-channel.js new file mode 100644 index 000000000000..f9534c4f0b3d --- /dev/null +++ b/packages/server/lib/privileged-commands/privileged-channel.js @@ -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 ') + ) + }) + + 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, + } +}) diff --git a/packages/server/lib/privileged-commands/privileged-commands-manager.ts b/packages/server/lib/privileged-commands/privileged-commands-manager.ts new file mode 100644 index 000000000000..90c8130de149 --- /dev/null +++ b/packages/server/lib/privileged-commands/privileged-commands-manager.ts @@ -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 = {} + 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() diff --git a/packages/server/lib/routes-e2e.ts b/packages/server/lib/routes-e2e.ts index 426c62ee56a8..78d0b517774d 100644 --- a/packages/server/lib/routes-e2e.ts +++ b/packages/server/lib/routes-e2e.ts @@ -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 } diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 5585bd69dc37..c1b4affbae40 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -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) diff --git a/packages/server/lib/util/fs.ts b/packages/server/lib/util/fs.ts index edee60b5ebb3..5846acecc837 100644 --- a/packages/server/lib/util/fs.ts +++ b/packages/server/lib/util/fs.ts @@ -9,6 +9,7 @@ type Promisified any> interface PromisifiedFsExtra { statAsync: (path: string | Buffer) => Bluebird> removeAsync: Promisified + readFileAsync: Promisified writeFileAsync: Promisified pathExistsAsync: Promisified } diff --git a/packages/server/test/unit/browsers/cri-client_spec.ts b/packages/server/test/unit/browsers/cri-client_spec.ts index 1007ea2c6528..896d7fbe6876 100644 --- a/packages/server/test/unit/browsers/cri-client_spec.ts +++ b/packages/server/test/unit/browsers/cri-client_spec.ts @@ -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 + }) }) }) diff --git a/packages/server/test/unit/browsers/privileged-channel_spec.js b/packages/server/test/unit/browsers/privileged-channel_spec.js new file mode 100644 index 000000000000..0337072a3eba --- /dev/null +++ b/packages/server/test/unit/browsers/privileged-channel_spec.js @@ -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 + }) + }) +}) diff --git a/packages/server/test/unit/files_spec.js b/packages/server/test/unit/files_spec.js index 59a37bac6547..13e47d79e3e1 100644 --- a/packages/server/test/unit/files_spec.js +++ b/packages/server/test/unit/files_spec.js @@ -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') }) }) diff --git a/packages/server/test/unit/socket_spec.js b/packages/server/test/unit/socket_spec.js index 9a558a059cb6..ca88243e8fc3 100644 --- a/packages/server/test/unit/socket_spec.js +++ b/packages/server/test/unit/socket_spec.js @@ -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() diff --git a/system-tests/__snapshots__/cdp_spec.ts.js b/system-tests/__snapshots__/cdp_spec.ts.js deleted file mode 100644 index ae59f97236cc..000000000000 --- a/system-tests/__snapshots__/cdp_spec.ts.js +++ /dev/null @@ -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] -` diff --git a/system-tests/project-fixtures/runner-specs/cypress/e2e/errors/readfile.cy.js b/system-tests/project-fixtures/runner-specs/cypress/e2e/errors/readfile.cy.js deleted file mode 100644 index e91de8ac8387..000000000000 --- a/system-tests/project-fixtures/runner-specs/cypress/e2e/errors/readfile.cy.js +++ /dev/null @@ -1,5 +0,0 @@ -describe('cy.readFile', () => { - it('existence failure', () => { - cy.readFile('does-not-exist', { timeout: 100 }) - }) -}) diff --git a/system-tests/projects/remote-debugging-disconnect/cypress/e2e/spec.cy.ts b/system-tests/projects/remote-debugging-disconnect/cypress/e2e/spec.cy.ts index 4cbd7c14e902..6f0bcb5bbddd 100644 --- a/system-tests/projects/remote-debugging-disconnect/cypress/e2e/spec.cy.ts +++ b/system-tests/projects/remote-debugging-disconnect/cypress/e2e/spec.cy.ts @@ -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() - }) - }) }) diff --git a/system-tests/projects/remote-debugging-disconnect/plugins.js b/system-tests/projects/remote-debugging-disconnect/plugins.js index 14e150c01323..de1f7515deba 100644 --- a/system-tests/projects/remote-debugging-disconnect/plugins.js +++ b/system-tests/projects/remote-debugging-disconnect/plugins.js @@ -81,12 +81,6 @@ module.exports = (on, config) => { return null }, - 'destroy:server' () { - console.error('closing server') - server.close() - - return null - }, }) diff --git a/system-tests/test/cdp_spec.ts b/system-tests/test/cdp_spec.ts index 72f92fc768a2..82f7e1f81734 100644 --- a/system-tests/test/cdp_spec.ts +++ b/system-tests/test/cdp_spec.ts @@ -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', '') - }, }) }) diff --git a/yarn.lock b/yarn.lock index 43769776ff13..e33e5f28b463 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"