diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 5807c3201592..b3881e9bf717 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -5,6 +5,7 @@ _Released 07/18/2023 (PENDING)_ **Bugfixes:** +- Fixed an issue where commands would fail with the error `must only be invoked from the spec file or support file` if their arguments were mutated. Fixes [#27200](https://github.com/cypress-io/cypress/issues/27200). - Fixed an issue where `cy.writeFile()` would erroneously fail with the error `cy.writeFile() must only be invoked from the spec file or support file`. Fixes [#27097](https://github.com/cypress-io/cypress/issues/27097). - Fixed an issue where web workers could not be created within a spec. Fixes [#27298](https://github.com/cypress-io/cypress/issues/27298). diff --git a/packages/driver/cypress/e2e/commands/exec.cy.js b/packages/driver/cypress/e2e/commands/exec.cy.js index 9dbcce705dab..61e622f8906b 100644 --- a/packages/driver/cypress/e2e/commands/exec.cy.js +++ b/packages/driver/cypress/e2e/commands/exec.cy.js @@ -17,8 +17,8 @@ describe('src/cy/commands/exec', () => { cy.exec('ls').then(() => { expect(Cypress.backend).to.be.calledWith('run:privileged', { + args: ['8374177128052794'], commandName: 'exec', - userArgs: ['8374177128052794'], options: { cmd: 'ls', timeout: 2500, @@ -33,8 +33,8 @@ describe('src/cy/commands/exec', () => { cy.exec('ls', { env: { FOO: 'foo' } }).then(() => { expect(Cypress.backend).to.be.calledWith('run:privileged', { + args: ['8374177128052794', '6419589148408857'], commandName: 'exec', - userArgs: ['8374177128052794', '6419589148408857'], options: { cmd: 'ls', timeout: 2500, diff --git a/packages/driver/cypress/e2e/commands/files.cy.js b/packages/driver/cypress/e2e/commands/files.cy.js index 7e04ef45be4c..d72094e83456 100644 --- a/packages/driver/cypress/e2e/commands/files.cy.js +++ b/packages/driver/cypress/e2e/commands/files.cy.js @@ -21,8 +21,8 @@ describe('src/cy/commands/files', () => { expect(Cypress.backend).to.be.calledWith( 'run:privileged', { + args: ['6998637248317671'], commandName: 'readFile', - userArgs: ['6998637248317671'], options: { file: 'foo.json', encoding: 'utf8', @@ -39,8 +39,8 @@ describe('src/cy/commands/files', () => { expect(Cypress.backend).to.be.calledWith( 'run:privileged', { + args: ['6998637248317671', '2573904513237804'], commandName: 'readFile', - userArgs: ['6998637248317671', '2573904513237804'], options: { file: 'foo.json', encoding: 'ascii', @@ -61,8 +61,8 @@ describe('src/cy/commands/files', () => { expect(Cypress.backend).to.be.calledWith( 'run:privileged', { + args: ['6998637248317671', '6158203196586298'], commandName: 'readFile', - userArgs: ['6998637248317671', '6158203196586298'], options: { file: 'foo.json', encoding: null, @@ -451,8 +451,8 @@ describe('src/cy/commands/files', () => { expect(Cypress.backend).to.be.calledWith( 'run:privileged', { + args: ['2916834115813688', '4891975990226114'], commandName: 'writeFile', - userArgs: ['2916834115813688', '4891975990226114'], options: { fileName: 'foo.txt', contents: 'contents', @@ -471,8 +471,8 @@ describe('src/cy/commands/files', () => { expect(Cypress.backend).to.be.calledWith( 'run:privileged', { + args: ['2916834115813688', '4891975990226114', '2573904513237804'], commandName: 'writeFile', - userArgs: ['2916834115813688', '4891975990226114', '2573904513237804'], options: { fileName: 'foo.txt', contents: 'contents', @@ -494,8 +494,8 @@ describe('src/cy/commands/files', () => { expect(Cypress.backend).to.be.calledWith( 'run:privileged', { + args: ['2916834115813688', '6309890104324788', '6158203196586298'], commandName: 'writeFile', - userArgs: ['2916834115813688', '6309890104324788', '6158203196586298'], options: { fileName: 'foo.txt', contents: buffer, @@ -514,8 +514,8 @@ describe('src/cy/commands/files', () => { expect(Cypress.backend).to.be.calledWith( 'run:privileged', { + args: ['2916834115813688', '4891975990226114', '4694939291947123'], commandName: 'writeFile', - userArgs: ['2916834115813688', '4891975990226114', '4694939291947123'], options: { fileName: 'foo.txt', contents: 'contents', @@ -569,8 +569,8 @@ describe('src/cy/commands/files', () => { expect(Cypress.backend).to.be.calledWith( 'run:privileged', { + args: ['2916834115813688', '4891975990226114', '2343101193011749'], commandName: 'writeFile', - userArgs: ['2916834115813688', '4891975990226114', '2343101193011749'], options: { fileName: 'foo.txt', contents: 'contents', diff --git a/packages/driver/cypress/e2e/commands/task.cy.js b/packages/driver/cypress/e2e/commands/task.cy.js index dd00ecddc752..4675016e5772 100644 --- a/packages/driver/cypress/e2e/commands/task.cy.js +++ b/packages/driver/cypress/e2e/commands/task.cy.js @@ -14,8 +14,8 @@ describe('src/cy/commands/task', () => { cy.task('foo').then(() => { expect(Cypress.backend).to.be.calledWith('run:privileged', { + args: ['338657716278786'], commandName: 'task', - userArgs: ['338657716278786'], options: { task: 'foo', timeout: 2500, @@ -30,8 +30,8 @@ describe('src/cy/commands/task', () => { cy.task('foo', { foo: 'foo' }).then(() => { expect(Cypress.backend).to.be.calledWith('run:privileged', { + args: ['338657716278786', '4940328425038888'], commandName: 'task', - userArgs: ['338657716278786', '4940328425038888'], options: { task: 'foo', timeout: 2500, 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 495e4725cdfb..771aa27edf8c 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/files.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/files.cy.ts @@ -37,8 +37,8 @@ context('cy.origin files', { browser: '!webkit' }, () => { expect(Cypress.backend).to.be.calledWith( 'run:privileged', { + args: ['6998637248317671', '4581875909943693'], commandName: 'writeFile', - userArgs: ['6998637248317671', '4581875909943693'], options: { fileName: 'foo.json', contents, diff --git a/packages/driver/cypress/e2e/e2e/privileged_commands.cy.ts b/packages/driver/cypress/e2e/e2e/privileged_commands.cy.ts index 6dd9c19332da..6dc09d2bcceb 100644 --- a/packages/driver/cypress/e2e/e2e/privileged_commands.cy.ts +++ b/packages/driver/cypress/e2e/e2e/privileged_commands.cy.ts @@ -115,6 +115,17 @@ describe('privileged commands', () => { cy.get('#basic').selectFile(Uint8Array.from([98, 97, 122])) }) + it('handles args being mutated', () => { + const obj = { foo: 'bar' } + + cy.wait(10).then(() => { + obj.foo = 'baz' + }) + + cy.task('return:arg', obj) + cy.writeFile('cypress/_test-output/written.json', obj) + }) + it('handles evaled code', () => { window.eval(` cy.task('return:arg', 'eval arg') diff --git a/packages/driver/src/cy/commands/actions/selectFile.ts b/packages/driver/src/cy/commands/actions/selectFile.ts index e47c6ec354e9..c7ace22945d1 100644 --- a/packages/driver/src/cy/commands/actions/selectFile.ts +++ b/packages/driver/src/cy/commands/actions/selectFile.ts @@ -163,7 +163,7 @@ export default (Commands, Cypress, cy, state, config) => { } } - const readFiles = async (filePaths, options, userArgs) => { + const readFiles = async (filePaths, options) => { if (!filePaths.length) return [] // This reads the file with privileged access in the same manner as @@ -177,7 +177,6 @@ export default (Commands, Cypress, cy, state, config) => { options: { files: filePaths, }, - userArgs, }) .then((results) => { return results.map((result) => { @@ -268,11 +267,11 @@ export default (Commands, Cypress, cy, state, config) => { } } - async function collectFiles (files, options, userArgs) { + async function collectFiles (files, options) { 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) + const filePathResults = await readFiles(filePaths, options) // stitch them back into the collection filePathResults.forEach((filePathResult) => { @@ -284,11 +283,6 @@ export default (Commands, Cypress, cy, state, config) => { Commands.addAll({ prevSubject: 'element' }, { async selectFile (subject: JQuery, files: Cypress.FileReference | Cypress.FileReference[], options: Partial, ...extras: never[]): Promise { - // privileged commands need to send any and all args, even if not part - // of their API, so they can be compared to the args collected when the - // command is invoked - const userArgs = [files, _.isObject(options) ? { ...options } : undefined, ...extras] - options = _.defaults({}, options, { action: 'select', log: true, @@ -351,7 +345,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 collectFiles(files, options, userArgs) + const filesArray = await collectFiles(files, options) 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 ec64de3e40c7..55efcd8bc07a 100644 --- a/packages/driver/src/cy/commands/exec.ts +++ b/packages/driver/src/cy/commands/exec.ts @@ -14,11 +14,6 @@ interface InternalExecOptions extends Partial { export default (Commands, Cypress, cy) => { Commands.addAll({ exec (cmd: string, userOptions: Partial, ...extras: never[]) { - // privileged commands need to send any and all args, even if not part - // of their API, so they can be compared to the args collected when the - // command is invoked - const userArgs = [cmd, userOptions, ...extras] - userOptions = userOptions || {} const options: InternalExecOptions = _.defaults({}, userOptions, { @@ -60,7 +55,6 @@ export default (Commands, Cypress, cy) => { cy, Cypress: (Cypress as unknown) as InternalCypress.Cypress, options: _.pick(options, 'cmd', 'timeout', 'env'), - userArgs, }) .timeout(options.timeout) .then((result) => { diff --git a/packages/driver/src/cy/commands/files.ts b/packages/driver/src/cy/commands/files.ts index 34c7ed7a9597..ec94f6220fda 100644 --- a/packages/driver/src/cy/commands/files.ts +++ b/packages/driver/src/cy/commands/files.ts @@ -22,11 +22,6 @@ type WriteFileOptions = Partial export default (Commands, Cypress, cy, state) => { Commands.addAll({ readFile (file: string, encoding: Cypress.Encodings | ReadFileOptions | undefined, userOptions?: ReadFileOptions, ...extras: never[]) { - // privileged commands need to send any and all args, even if not part - // of their API, so they can be compared to the args collected when the - // command is invoked - const userArgs = [file, encoding, _.isObject(userOptions) ? { ...userOptions } : undefined, ...extras] - if (_.isObject(encoding)) { userOptions = encoding encoding = undefined @@ -77,7 +72,6 @@ export default (Commands, Cypress, cy, state) => { file, encoding: options.encoding, }, - userArgs, }) .timeout(options.timeout) .catch((err) => { @@ -146,11 +140,6 @@ export default (Commands, Cypress, cy, state) => { }, writeFile (fileName: string, contents: string, encoding: Cypress.Encodings | WriteFileOptions | undefined, userOptions: WriteFileOptions, ...extras: never[]) { - // privileged commands need to send any and all args, even if not part - // of their API, so they can be compared to the args collected when the - // command is invoked - const userArgs = [fileName, contents, encoding, _.isObject(userOptions) ? { ...userOptions } : undefined, ...extras] - if (_.isObject(encoding)) { userOptions = encoding encoding = undefined @@ -214,7 +203,6 @@ export default (Commands, Cypress, cy, state) => { encoding: options.encoding, flag: options.flag, }, - userArgs, }) .timeout(options.timeout) .then(({ filePath, contents }) => { diff --git a/packages/driver/src/cy/commands/origin/index.ts b/packages/driver/src/cy/commands/origin/index.ts index 262a9912e45e..8426dcd237c8 100644 --- a/packages/driver/src/cy/commands/origin/index.ts +++ b/packages/driver/src/cy/commands/origin/index.ts @@ -27,15 +27,6 @@ const normalizeOrigin = (urlOrDomain) => { type OptionsOrFn = { args: T } | (() => {}) type Fn = (args?: T) => {} -function getUserArgs (urlOrDomain: string, optionsOrFn: OptionsOrFn, extras: never[], fn?: Fn) { - return [ - urlOrDomain, - fn && _.isObject(optionsOrFn) ? { ...optionsOrFn } : optionsOrFn, - fn ? fn : undefined, - ...extras, - ] -} - export default (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: StateFunc, config: Cypress.InternalConfig) => { const communicator = Cypress.primaryOriginCommunicator @@ -45,11 +36,6 @@ export default (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: State return $errUtils.throwErrByPath('webkit.origin') } - // privileged commands need to send any and all args, even if not part - // of their API, so they can be compared to the args collected when the - // command is invoked - const userArgs = getUserArgs(urlOrDomain, optionsOrFn, extras, fn) - const userInvocationStack = state('current').get('userInvocationStack') // store the invocation stack in the case that `cy.origin` errors @@ -213,7 +199,6 @@ export default (Commands, Cypress: Cypress.Cypress, cy: Cypress.cy, state: State options: { specBridgeOrigin, }, - userArgs, }) // once the secondary origin page loads, send along the diff --git a/packages/driver/src/cy/commands/task.ts b/packages/driver/src/cy/commands/task.ts index e6cb6e56d356..9c20cb906171 100644 --- a/packages/driver/src/cy/commands/task.ts +++ b/packages/driver/src/cy/commands/task.ts @@ -15,11 +15,6 @@ interface InternalTaskOptions extends Partial { Commands.addAll({ task (task, arg, userOptions: Partial, ...extras: never[]) { - // privileged commands need to send any and all args, even if not part - // of their API, so they can be compared to the args collected when the - // command is invoked - const userArgs = [task, arg, _.isObject(userOptions) ? { ...userOptions } : undefined, ...extras] - userOptions = userOptions || {} const options: InternalTaskOptions = _.defaults({}, userOptions, { @@ -65,7 +60,6 @@ export default (Commands, Cypress, cy) => { commandName: 'task', cy, Cypress: (Cypress as unknown) as InternalCypress.Cypress, - userArgs, options: { task, arg, diff --git a/packages/driver/src/cypress/chainer.ts b/packages/driver/src/cypress/chainer.ts index a54a1a1de643..869d57901b91 100644 --- a/packages/driver/src/cypress/chainer.ts +++ b/packages/driver/src/cypress/chainer.ts @@ -20,7 +20,7 @@ export class $Chainer { static add (key, fn) { $Chainer.prototype[key] = function (...args) { - const verificationPromise = Cypress.emitMap('command:invocation', { name: key, args }) + const privilegeVerification = Cypress.emitMap('command:invocation', { name: key, args }) const userInvocationStack = $stackUtils.normalizedUserInvocationStack( (new this.specWindow.Error('command invocation stack')).stack, @@ -28,7 +28,7 @@ export class $Chainer { // call back the original function with our new args // pass args an as array and not a destructured invocation - fn(this, userInvocationStack, args, verificationPromise) + fn(this, userInvocationStack, args, privilegeVerification) // 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 a01a7d90965e..4c6b6c02c497 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, verificationPromise, firstCall = false) => { + const callback = (chainer, userInvocationStack, args, privilegeVerification, firstCall = false) => { // dont enqueue / inject any new commands if // onInjectCommand returns false const onInjectCommand = cy.state('onInjectCommand') @@ -699,7 +699,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert chainerId: chainer.chainerId, userInvocationStack, fn: firstCall ? cyFn : chainerFn, - verificationPromise, + privilegeVerification, })) } @@ -715,7 +715,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert // 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 }) + const privilegeVerification = Cypress.emitMap('command:invocation', { name, args }) // this is the first call on cypress // so create a new chainer instance @@ -727,7 +727,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert const userInvocationStack = $stackUtils.captureUserInvocationStack(cy.specWindow.Error) - callback(chainer, userInvocationStack, args, verificationPromise, true) + callback(chainer, userInvocationStack, args, privilegeVerification, true) // if we are in the middle of a command // and its return value is a promise diff --git a/packages/driver/src/util/privileged_channel.ts b/packages/driver/src/util/privileged_channel.ts index 97f61060724e..b30987b77804 100644 --- a/packages/driver/src/util/privileged_channel.ts +++ b/packages/driver/src/util/privileged_channel.ts @@ -1,4 +1,3 @@ -import _ from 'lodash' import Bluebird from 'bluebird' /** @@ -19,54 +18,18 @@ interface RunPrivilegedCommandOptions { cy: Cypress.cy Cypress: InternalCypress.Cypress options: any - userArgs: any[] } -// hashes a string in the same manner as is in the privileged channel. -// unfortunately this can't be shared because we want to reduce the surface -// area in the privileged channel, which uses closured references to -// globally-accessible functions -// source: https://github.com/bryc/code/blob/d0dac1c607a005679799024ff66166e13601d397/jshash/experimental/cyrb53.js -function hash (str) { - const seed = 0 - let h1 = 0xdeadbeef ^ seed - let h2 = 0x41c6ce57 ^ seed +export function runPrivilegedCommand ({ commandName, cy, Cypress, options }: RunPrivilegedCommandOptions): Bluebird { + const { args, promise } = (cy.state('current').get('privilegeVerification') || [])[0] || {} - for (let i = 0, ch; i < str.length; i++) { - ch = str.charCodeAt(i) - h1 = Math.imul(h1 ^ ch, 2654435761) - h2 = Math.imul(h2 ^ ch, 1597334677) - } - h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) - h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909) - h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) - h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909) - - return `${4294967296 * (2097151 & h2) + (h1 >>> 0)}` -} - -export function runPrivilegedCommand ({ commandName, cy, Cypress, options, userArgs }: RunPrivilegedCommandOptions): Bluebird { - const hashedArgs = _.dropRightWhile(userArgs || [], _.isUndefined) - .map((arg) => { - if (arg === undefined) { - arg = null - } - - if (typeof arg === 'function') { - arg = arg.toString() - } - - return hash(JSON.stringify(arg)) - }) - - return Bluebird.try(() => { - return cy.state('current').get('verificationPromise')[0] - }) + return Bluebird + .try(() => promise) .then(() => { return Cypress.backend('run:privileged', { commandName, options, - userArgs: hashedArgs, + args, }) }) } diff --git a/packages/server/lib/privileged-commands/privileged-channel.js b/packages/server/lib/privileged-commands/privileged-channel.js index 5f908d0d28ad..b89b882d4e98 100644 --- a/packages/server/lib/privileged-commands/privileged-channel.js +++ b/packages/server/lib/privileged-commands/privileged-channel.js @@ -160,7 +160,7 @@ return slice.call(array, 0, index + 1) } - async function onCommandInvocation (command) { + function onCommandInvocation (command) { // message doesn't really matter since we're only interested in the stack const err = new Err('command stack error') @@ -199,7 +199,7 @@ // 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`, { + const promise = fetch(`/${namespace}/add-verified-command`, { body: stringify({ args, name: command.name, @@ -215,6 +215,11 @@ // verified command failed, the default behavior is NOT to allow // the privileged command to run }) + + return { + args, + promise, + } } win.Cypress.on('command:invocation', onCommandInvocation) diff --git a/packages/server/lib/privileged-commands/privileged-commands-manager.ts b/packages/server/lib/privileged-commands/privileged-commands-manager.ts index 9d6845fdc68f..5f6ddea24352 100644 --- a/packages/server/lib/privileged-commands/privileged-commands-manager.ts +++ b/packages/server/lib/privileged-commands/privileged-commands-manager.ts @@ -78,10 +78,10 @@ class PrivilegedCommandsManager { return !!matchingCommand } - runPrivilegedCommand (config, { commandName, options, userArgs }) { + runPrivilegedCommand (config, { commandName, options, args }) { // the presence of the command within the verifiedCommands array indicates // the command being run is verified - const hasCommand = this.hasVerifiedCommand({ name: commandName, args: userArgs }) + const hasCommand = this.hasVerifiedCommand({ name: commandName, args }) if (config.testingType === 'e2e' && !hasCommand) { // this error message doesn't really matter as each command will catch it