From ef301e11b339bcdbd816ef307ad797f8acb2c3a2 Mon Sep 17 00:00:00 2001 From: Blue F Date: Wed, 3 Aug 2022 15:32:18 -0700 Subject: [PATCH] chore: Reapply state refactor (#23092) * Revert "chore: reverting #22742 (#23047)" This reverts commit 51ef99ac5b370596167e7bb87650c16330eedcd7. * Fix for aliases when .then() is in the chain * Run all tests on branch * Fix silly mistake * Fix broken test (again) * Update packages/driver/cypress/e2e/cypress/cy.cy.js Co-authored-by: Matt Henkes Co-authored-by: Matt Henkes --- circle.yml | 4 +- .../cypress/e2e/commands/aliasing.cy.js | 5 + .../cypress/e2e/commands/assertions.cy.js | 2 +- .../cypress/e2e/commands/commands.cy.js | 3 +- .../cypress/e2e/commands/connectors.cy.js | 8 +- packages/driver/cypress/e2e/cypress/cy.cy.js | 10 + packages/driver/src/cross-origin/origin_fn.ts | 2 +- packages/driver/src/cy/commands/connectors.ts | 94 ++------ packages/driver/src/cy/commands/misc.ts | 2 +- .../driver/src/cy/commands/querying/within.ts | 8 +- packages/driver/src/cy/logGroup.ts | 1 + packages/driver/src/cypress.ts | 21 +- packages/driver/src/cypress/chainer.ts | 9 - packages/driver/src/cypress/command_queue.ts | 13 +- packages/driver/src/cypress/commands.ts | 77 +------ packages/driver/src/cypress/cy.ts | 206 +++++++++++++----- packages/driver/src/cypress/error_messages.ts | 3 + packages/driver/src/cypress/log.ts | 4 +- .../driver/src/util/serialization/index.ts | 2 +- scripts/run-webpack.js | 4 +- 20 files changed, 231 insertions(+), 247 deletions(-) diff --git a/circle.yml b/circle.yml index bef16af51297..48182b1cc0e3 100644 --- a/circle.yml +++ b/circle.yml @@ -27,7 +27,7 @@ mainBuildFilters: &mainBuildFilters branches: only: - develop - - revert-22742 + - reapply-state-refactor # usually we don't build Mac app - it takes a long time # but sometimes we want to really confirm we are doing the right thing @@ -881,7 +881,7 @@ commands: fi curl -L https://raw.githubusercontent.com/cypress-io/cypress/$branch/scripts/ensure-node.sh --output ci-ensure-node.sh - else + else # if no .node-version file exists, we no-op the node script and use the global yarn echo '' > ci-ensure-node.sh fi diff --git a/packages/driver/cypress/e2e/commands/aliasing.cy.js b/packages/driver/cypress/e2e/commands/aliasing.cy.js index 8fa05133ae92..0e0a2544ff41 100644 --- a/packages/driver/cypress/e2e/commands/aliasing.cy.js +++ b/packages/driver/cypress/e2e/commands/aliasing.cy.js @@ -29,6 +29,11 @@ describe('src/cy/commands/aliasing', () => { }) }) + it('stores the lookup as an alias when .then() is an intermediate', () => { + cy.get('body').then(() => {}).as('body') + cy.get('@body') + }) + it('stores the resulting subject as the alias', () => { const $body = cy.$$('body') diff --git a/packages/driver/cypress/e2e/commands/assertions.cy.js b/packages/driver/cypress/e2e/commands/assertions.cy.js index 74a48e597732..f69f28289212 100644 --- a/packages/driver/cypress/e2e/commands/assertions.cy.js +++ b/packages/driver/cypress/e2e/commands/assertions.cy.js @@ -806,7 +806,7 @@ describe('src/cy/commands/assertions', () => { }) cy.get('body').then(() => { - expect(cy.state('subject')).to.match('body') + expect(cy.currentSubject()).to.match('body') }) }) diff --git a/packages/driver/cypress/e2e/commands/commands.cy.js b/packages/driver/cypress/e2e/commands/commands.cy.js index f873b502f58e..3ce58a8f55c2 100644 --- a/packages/driver/cypress/e2e/commands/commands.cy.js +++ b/packages/driver/cypress/e2e/commands/commands.cy.js @@ -58,7 +58,8 @@ describe('src/cy/commands/commands', () => { cy .get('input:first') .parent() - .command('login', 'brian@foo.com').then(($input) => { + .command('login', 'brian@foo.com') + .then(($input) => { expect($input.get(0)).to.eq(input.get(0)) }) }) diff --git a/packages/driver/cypress/e2e/commands/connectors.cy.js b/packages/driver/cypress/e2e/commands/connectors.cy.js index fd4d23e3c5e0..9d04419a777f 100644 --- a/packages/driver/cypress/e2e/commands/connectors.cy.js +++ b/packages/driver/cypress/e2e/commands/connectors.cy.js @@ -158,7 +158,7 @@ describe('src/cy/commands/connectors', () => { }) }) - it('should pass the eventual resolved thenable value downstream', () => { + it('should pass the eventually resolved thenable value downstream', () => { cy .wrap({ foo: 'bar' }) .then((obj) => { @@ -166,7 +166,8 @@ describe('src/cy/commands/connectors', () => { .wait(10) .then(() => { return obj.foo - }).then((value) => { + }) + .then((value) => { expect(value).to.eq('bar') return value @@ -309,7 +310,7 @@ describe('src/cy/commands/connectors', () => { return $div }) .then(function () { - expect(cy.state('subject')).not.to.be.instanceof(this.remoteWindow.$) + expect(cy.currentSubject()).not.to.be.instanceof(this.remoteWindow.$) }) }) }) @@ -423,7 +424,6 @@ describe('src/cy/commands/connectors', () => { cy.get('div:first').invoke('parent').then(function ($parent) { expect($parent).to.be.instanceof(this.remoteWindow.$) - expect(cy.state('subject')).to.match(parent) }) }) }) diff --git a/packages/driver/cypress/e2e/cypress/cy.cy.js b/packages/driver/cypress/e2e/cypress/cy.cy.js index 8caac130fd2d..9ae804558131 100644 --- a/packages/driver/cypress/e2e/cypress/cy.cy.js +++ b/packages/driver/cypress/e2e/cypress/cy.cy.js @@ -133,6 +133,16 @@ describe('driver/src/cypress/cy', () => { expect(userInvocationStack).to.include('.cy.js') }) }) + + it('supports cy.state(\'subject\') for backwards compatability', () => { + cy.stub(Cypress.utils, 'warning') + const subject = {} + + cy.wrap(subject).then(() => { + expect(cy.state('subject')).to.equal(subject) + expect(Cypress.utils.warning).to.be.calledWith('`cy.state(\'subject\')` has been deprecated and will be removed in a future release. Consider migrating to `cy.currentSubject()` instead.') + }) + }) }) context('custom commands', () => { diff --git a/packages/driver/src/cross-origin/origin_fn.ts b/packages/driver/src/cross-origin/origin_fn.ts index fbb4416cb9d0..505284c4a4cf 100644 --- a/packages/driver/src/cross-origin/origin_fn.ts +++ b/packages/driver/src/cross-origin/origin_fn.ts @@ -105,7 +105,7 @@ export const handleOriginFn = (Cypress: Cypress.Cypress, cy: $Cy) => { queueFinished = true setRunnableStateToPassed() Cypress.specBridgeCommunicator.toPrimary('queue:finished', { - subject: cy.state('subject'), + subject: cy.currentSubject(), }, { syncGlobals: true, }) diff --git a/packages/driver/src/cy/commands/connectors.ts b/packages/driver/src/cy/commands/connectors.ts index 22dd18dfb5e0..2fcdf235e227 100644 --- a/packages/driver/src/cy/commands/connectors.ts +++ b/packages/driver/src/cy/commands/connectors.ts @@ -100,47 +100,9 @@ export default function (Commands, Cypress, cy, state) { cy.once('command:enqueued', enqueuedCommand) - // this code helps juggle subjects forward - // the same way that promises work - const current = state('current') - const next = current.get('next') - - // TODO: this code may no longer be necessary - // if the next command is chained to us then when it eventually - // runs we need to reset the subject to be the return value of the - // previous command so the subject is continuously juggled forward - if (next && next.get('chainerId') === current.get('chainerId')) { - const checkSubject = (newSubject, args) => { - if (state('current') !== next) { - return - } - - // get whatever the previous commands return - // value is. this likely does not match the 'var current' - // command in the case of nested cy commands - const s = next.get('prev').get('subject') - - // find the new subject and splice it out - // with our existing subject - const index = _.indexOf(args, newSubject) - - if (index > -1) { - args.splice(index, 1, s) - } - - return cy.removeListener('next:subject:prepared', checkSubject) - } - - cy.on('next:subject:prepared', checkSubject) - } - const getRet = () => { let ret = fn.apply(ctx, args) - if (cy.isCy(ret)) { - ret = undefined - } - if (ret && invokedCyCommand && !ret.then) { $errUtils.throwErrByPath('then.callback_mixes_sync_and_async', { onFail: options._log, @@ -161,6 +123,11 @@ export default function (Commands, Cypress, cy, state) { return subject } + // If the user callback returned a non-null value, we break cypress' subject chaining + // logic, so that we can use this subject as-is rather than the subject generated by + // any chainers inside the callback (if any exist). + cy.breakSubjectLinksToCurrentChainer() + return ret }).catch(Promise.TimeoutError, () => { return $errUtils.throwErrByPath('invoke_its.timed_out', { @@ -498,54 +465,16 @@ export default function (Commands, Cypress, cy, state) { $errUtils.throwErrByPath('each.invalid_argument') } - const nonArray = () => { + if (subject?.length === undefined) { return $errUtils.throwErrByPath('each.non_array', { args: { subject: $utils.stringify(subject) }, }) } - try { - if (!('length' in subject)) { - nonArray() - } - } catch (e) { - nonArray() - } - if (subject.length === 0) { return subject } - // if we have a next command then we need to - // slice in this existing subject as its subject - // due to the way we queue promises - const next = state('current').get('next') - - if (next) { - const checkSubject = (newSubject, args, firstCall) => { - if (state('current') !== next) { - return - } - - // https://github.com/cypress-io/cypress/issues/4921 - // When dual commands like contains() is used as the firstCall (cy.contains() style), - // we should not prepend subject. - if (!firstCall) { - // find the new subject and splice it out - // with our existing subject - const index = _.indexOf(args, newSubject) - - if (index > -1) { - args.splice(index, 1, subject) - } - } - - return cy.removeListener('next:subject:prepared', checkSubject) - } - - cy.on('next:subject:prepared', checkSubject) - } - let endEarly = false const yieldItem = (el, index) => { @@ -573,11 +502,16 @@ export default function (Commands, Cypress, cy, state) { // generate a real array since bluebird is finicky and // doesnt want an 'array-like' structure like jquery instances - // need to take into account regular arrays here by first checking - // if its an array instance return Promise .each(_.toArray(subject), yieldItem) - .return(subject) + .then(() => { + // cy.each does *not* want to use any subjects that the user's callback generated - therefore we break + // cypress' subject chaining logic, which by default would override this with any subjects generated by + // the callback function. + cy.breakSubjectLinksToCurrentChainer() + + return subject + }) }, }) diff --git a/packages/driver/src/cy/commands/misc.ts b/packages/driver/src/cy/commands/misc.ts index 54d862663cc6..cc6a616979de 100644 --- a/packages/driver/src/cy/commands/misc.ts +++ b/packages/driver/src/cy/commands/misc.ts @@ -32,7 +32,7 @@ export default (Commands, Cypress, cy, state) => { const restoreCmdIndex = state('index') + 1 cy.queue.insert(restoreCmdIndex, $Command.create({ - args: [state('subject')], + args: [cy.currentSubject()], name: 'log-restore', fn: (subject) => subject, })) diff --git a/packages/driver/src/cy/commands/querying/within.ts b/packages/driver/src/cy/commands/querying/within.ts index a2f3092040bc..7b894e0f0066 100644 --- a/packages/driver/src/cy/commands/querying/within.ts +++ b/packages/driver/src/cy/commands/querying/within.ts @@ -35,7 +35,9 @@ export default (Commands, Cypress, cy, state) => { fn.call(cy.state('ctx'), subject) - const cleanup = () => cy.removeListener('command:start', setWithinSubject) + const cleanup = () => { + cy.removeListener('command:start', setWithinSubject) + } // we need a mechanism to know when we should remove // our withinSubject so we dont accidentally keep it @@ -76,6 +78,10 @@ export default (Commands, Cypress, cy, state) => { }) } + // TODO: Rework cy.within to use chainer-based subject chaining, rather than its custom withinSubject state. + // For now, we leave this logic in place and just ensure that the new rules don't interfere with it. + cy.breakSubjectLinksToCurrentChainer() + return subject } diff --git a/packages/driver/src/cy/logGroup.ts b/packages/driver/src/cy/logGroup.ts index 908ea08dad12..0382e2c03178 100644 --- a/packages/driver/src/cy/logGroup.ts +++ b/packages/driver/src/cy/logGroup.ts @@ -27,6 +27,7 @@ export default (Cypress, userOptions: Cypress.LogGroup.Config, fn: Cypress.LogGr const endLogGroupCmd = $Command.create({ name: 'end-logGroup', injected: true, + args: [], }) const forwardYieldedSubject = () => { diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index f5e74110bdcc..ea1ba2ccfbf4 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -218,6 +218,24 @@ class $Cypress { _.extend(this, browserInfo(config)) this.state = $SetterGetter.create({}) as unknown as StateFunc + + /* + * As part of the Detached DOM effort, we're changing the way subjects are determined in Cypress. + * While we usually consider cy.state() to be internal, in the case of cy.state('subject'), + * cypress-testing-library, one of our most popular plugins, relies on it. + * https://github.com/testing-library/cypress-testing-library/blob/1af9f2f28b2ca62936da8a8acca81fc87e2192f7/src/utils.js#L9 + * + * Therefore, we've added this shim to continue to support them. The library is actively maintained, so this + * shouldn't need to stick around too long (written 07/22). + */ + Object.defineProperty(this.state(), 'subject', { + get: () => { + $errUtils.warnByPath('subject.state_subject_deprecated') + + return cy.currentSubject() + }, + }) + this.originalConfig = _.cloneDeep(config) this.config = $SetterGetter.create(config, (config) => { if (this.isCrossOriginSpecBridge ? !window.__cySkipValidateConfig : !window.top!.__cySkipValidateConfig) { @@ -640,9 +658,6 @@ class $Cypress { case 'cy:url:changed': return this.emit('url:changed', args[0]) - case 'cy:next:subject:prepared': - return this.emit('next:subject:prepared', ...args) - case 'cy:collect:run:state': return this.emitThen('collect:run:state') diff --git a/packages/driver/src/cypress/chainer.ts b/packages/driver/src/cypress/chainer.ts index ecd89a385640..96c63dcf05f6 100644 --- a/packages/driver/src/cypress/chainer.ts +++ b/packages/driver/src/cypress/chainer.ts @@ -4,7 +4,6 @@ import $stackUtils from './stack_utils' export class $Chainer { specWindow: Window chainerId: string - firstCall: boolean constructor (specWindow) { this.specWindow = specWindow @@ -12,14 +11,6 @@ export class $Chainer { // collisions when chainers created in a secondary origin are passed // to the primary origin for the command log, etc. this.chainerId = _.uniqueId(`ch-${window.location.origin}-`) - - // firstCall is used to throw a useful error if the user leads off with a - // parent command. - - // TODO: Refactor firstCall out of the chainer and into the command function, - // since cy.ts already has all the necessary information to throw this error - // without an instance variable, in one localized place in the code. - this.firstCall = true } static remove (key) { diff --git a/packages/driver/src/cypress/command_queue.ts b/packages/driver/src/cypress/command_queue.ts index 166a7e2ea7a2..4275b797a51d 100644 --- a/packages/driver/src/cypress/command_queue.ts +++ b/packages/driver/src/cypress/command_queue.ts @@ -15,8 +15,8 @@ import type { ITimeouts } from '../cy/timeouts' const debugErrors = Debug('cypress:driver:errors') -const __stackReplacementMarker = (fn, ctx, args) => { - return fn.apply(ctx, args) +const __stackReplacementMarker = (fn, args) => { + return fn(...args) } const commandRunningFailed = (Cypress, state, err) => { @@ -165,9 +165,11 @@ export class CommandQueue extends Queue<$Command> { Cypress.once('command:enqueued', commandEnqueued) } + args = [command.get('chainerId'), ...args] + // run the command's fn with runnable's context try { - ret = __stackReplacementMarker(command.get('fn'), this.state('ctx'), args) + ret = __stackReplacementMarker(command.get('fn'), args) } catch (err) { throw err } finally { @@ -247,7 +249,7 @@ export class CommandQueue extends Queue<$Command> { // we're finished with the current command so set it back to null this.state('current', null) - this.state('subject', subject) + cy.setSubjectForChainer(command.get('chainerId'), subject) return subject }) @@ -278,7 +280,8 @@ export class CommandQueue extends Queue<$Command> { }) this.state('index', index + 1) - this.state('subject', command.get('subject')) + + cy.setSubjectForChainer(command.get('chainerId'), command.get('subject')) Cypress.action('cy:skipped:command:end', command) diff --git a/packages/driver/src/cypress/commands.ts b/packages/driver/src/cypress/commands.ts index 261c20b9c5f8..c99e3c52567e 100644 --- a/packages/driver/src/cypress/commands.ts +++ b/packages/driver/src/cypress/commands.ts @@ -13,75 +13,6 @@ const builtInCommands = [ addNetstubbingCommand, ] -const reservedCommandNames = { - addAlias: true, - addCommand: true, - addCommandSync: true, - aliasNotFoundFor: true, - assert: true, - clearTimeout: true, - config: true, - createSnapshot: true, - detachDom: true, - devices: true, - documentHasFocus: true, - ensureAttached: true, - ensureDescendents: true, - ensureDocument: true, - ensureElDoesNotHaveCSS: true, - ensureElExistence: true, - ensureElement: true, - ensureElementIsNotAnimating: true, - ensureNotDisabled: true, - ensureNotHiddenByAncestors: true, - ensureNotReadonly: true, - ensureRunnable: true, - ensureScrollability: true, - ensureStrictVisibility: true, - ensureSubjectByType: true, - ensureValidPosition: true, - ensureVisibility: true, - ensureWindow: true, - expect: true, - fail: true, - fireBlur: true, - fireFocus: true, - getFocused: true, - getIndexedXhrByAlias: true, - getNextAlias: true, - getRemoteLocation: true, - getRemotejQueryInstance: true, - getRequestsByAlias: true, - getStyles: true, - getXhrTypeByAlias: true, - id: true, - initialize: true, - interceptBlur: true, - interceptFocus: true, - isCy: true, - isStable: true, - isStopped: true, - needsFocus: true, - now: true, - onBeforeAppWindowLoad: true, - onBeforeWindowLoad: true, - onCssModified: true, - onUncaughtException: true, - pauseTimers: true, - queue: true, - replayCommandsFrom: true, - reset: true, - resetTimer: true, - retry: true, - setRunnable: true, - state: true, - stop: true, - timeout: true, - validateAlias: true, - verifyUpcomingAssertions: true, - whenStable: true, -} - const getTypeByPrevSubject = (prevSubject) => { if (prevSubject === 'optional') { return 'dual' @@ -96,6 +27,7 @@ const getTypeByPrevSubject = (prevSubject) => { export default { create: (Cypress, cy, state, config) => { + const reservedCommandNames = new Set(Object.keys(cy)) // create a single instance // of commands const commands = {} @@ -214,7 +146,7 @@ export default { }) } - if (reservedCommandNames[name]) { + if (reservedCommandNames.has(name)) { $errUtils.throwErrByPath('miscellaneous.reserved_command', { args: { name, @@ -254,11 +186,6 @@ export default { }) }, - addSelector (name, fn) { - // TODO: Add overriding stuff. - return cy.addSelector(name, fn) - }, - overwrite (name, fn) { return storeOverride(name, fn) }, diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index 0341c82ca3fe..c30590411ec8 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -695,37 +695,33 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert addCommand ({ name, fn, type, prevSubject }) { const cy = this - // TODO: prob don't need this anymore this.commandFns[name] = fn const wrap = function (firstCall) { - fn = cy.commandFns[name] - const wrapped = wrapByType(fn, firstCall) - - wrapped.originalFn = fn - - return wrapped - } - - const wrapByType = function (fn, firstCall) { if (type === 'parent') { - return fn + return (chainerId, ...args) => fn.apply(cy.runnableCtx(name), args) } - // child, dual, assertion, utility command - // pushes the previous subject into them - // after verifying its of the correct type - return function (...args) { + const wrapped = function (chainerId, ...args) { // push the subject into the args - args = cy.pushSubjectAndValidate(name, args, firstCall, prevSubject) + if (firstCall) { + cy.validateFirstCall(name, args, prevSubject) + } + + args = cy.pushSubject(name, args, prevSubject, chainerId) return fn.apply(cy.runnableCtx(name), args) } + + wrapped.originalFn = fn + + return wrapped } - const callback = (chainer, userInvocationStack, args) => { - const { firstCall, chainerId } = chainer + const cyFn = wrap(true) + const chainerFn = wrap(false) + const callback = (chainer, userInvocationStack, args, firstCall = false) => { // dont enqueue / inject any new commands if // onInjectCommand returns false const onInjectCommand = cy.state('onInjectCommand') @@ -741,13 +737,11 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert name, args, type, - chainerId, + chainerId: chainer.chainerId, userInvocationStack, injected, - fn: wrap(firstCall), + fn: firstCall ? cyFn : chainerFn, }) - - chainer.firstCall = false } $Chainer.add(name, callback) @@ -759,9 +753,13 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert // so create a new chainer instance const chainer = new $Chainer(cy.specWindow) + if (cy.state('chainerId')) { + cy.linkSubject(chainer.chainerId, cy.state('chainerId')) + } + const userInvocationStack = $stackUtils.captureUserInvocationStack(cy.specWindow.Error) - callback(chainer, userInvocationStack, args) + callback(chainer, userInvocationStack, args, true) // if we are in the middle of a command // and its return value is a promise @@ -809,18 +807,6 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert replayCommandsFrom (current) { const cy = this - // reset each chainerId to the - // current value - const chainerId = this.state('chainerId') - - const insert = function (command) { - command.set('chainerId', chainerId) - - // clone the command to prevent - // mutating its properties - return cy.enqueue(command.clone()) - } - // - starting with the aliased command // - walk up to each prev command // - until you reach a parent command @@ -860,8 +846,15 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert return memo }, [initialCommand]) + const chainerId = this.state('chainerId') + for (let c of commandsToInsert) { - insert(c) + // clone the command to prevent + // mutating its properties + const command = c.clone() + + command.set('chainerId', chainerId) + cy.enqueue(command) } } @@ -1222,32 +1215,36 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert return memo } - return this.getCommandsUntilFirstParentOrValidSubject(command.get('prev'), memo) - } + // A workaround to ensure that when looking back, aliases don't extend beyond the current + // chainer. This whole area (`replayCommandsFrom` for aliases) will be replaced with subject chains as + // part of the detached DOM work. + const prev = command.get('prev') - // TODO: make string[] more - private pushSubjectAndValidate (name, args, firstCall, prevSubject: string[]) { - if (firstCall) { - // if we have a prevSubject then error - // since we're invoking this improperly - if (prevSubject && !([] as string[]).concat(prevSubject).includes('optional')) { - const stringifiedArg = $utils.stringifyActual(args[0]) - - $errUtils.throwErrByPath('miscellaneous.invoking_child_without_parent', { - args: { - cmd: name, - args: _.isString(args[0]) ? `\"${stringifiedArg}\"` : stringifiedArg, - }, - }) - } + if (prev.get('chainerId') !== command.get('chainerId')) { + return memo + } - // else if this is the very first call - // on the chainer then make the first - // argument undefined (we have no subject) - this.state('subject', undefined) + return this.getCommandsUntilFirstParentOrValidSubject(prev, memo) + } + + private validateFirstCall (name, args, prevSubject: string[]) { + // if we have a prevSubject then error + // since we're invoking this improperly + if (prevSubject && !([] as string[]).concat(prevSubject).includes('optional')) { + const stringifiedArg = $utils.stringifyActual(args[0]) + + $errUtils.throwErrByPath('miscellaneous.invoking_child_without_parent', { + args: { + cmd: name, + args: _.isString(args[0]) ? `\"${stringifiedArg}\"` : stringifiedArg, + }, + }) } + } - const subject = this.state('subject') + // TODO: make string[] more + private pushSubject (name, args, prevSubject: string[], chainerId) { + const subject = this.currentSubject(chainerId) if (prevSubject) { // make sure our current subject is valid for @@ -1257,11 +1254,100 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert args.unshift(subject) - this.Cypress.action('cy:next:subject:prepared', subject, args, firstCall) - return args } + /* + * Use `currentSubject()` to get the subject. It reads from cy.state('subjects'), but the format and details of + * determining this should be considered an internal implementation detail of Cypress, subject to change at any time. + * + * Currently, state('subjects') is an object, mapping chainerIds to the current subject for that chainer. For + * example, it might look like: + * + * { + * 'chainer2': 'foobar', + * 'chainer4': , + * } + * + * Do not read this directly; Prefer currentSubject() instead. + */ + currentSubject (chainerId = this.state('chainerId')) { + return (this.state('subjects') || {})[chainerId] + } + + /* + * Cypress executes commands asynchronously, and those commands can contain other commands - this means that there + * are times when an outer chainer might have as its subject the (as of yet unresolved) return value of the inner + * chain of commands. + * + * cy.state('subjectLinks') is where we store that connection. The exact contents should be considered an internal + * implementation detail - do not read or alter it directly, but prefer the public interface (linkSubject and + * breakSubjectLinksToCurrentChainer). + * + * In the current implementation, subjectLinks might look like: + * { + * 'chainer4': 'chainer2', + * } + * + * indicating that when we eventually resolve the subject of chainer4, it should *also* be used as the subject for + * chainer2 - for example, `cy.then(() => { return cy.get('foo') }).log()`. The inner chainer (chainer4, + * `cy.get('foo')`) is linked to the outer chainer (chainer2) - when we eventually .get('foo'), the resolved value + * becomes the new subject for the outer chainer. + * + * Whenever we are in the middle of resolving one chainer and a new one is created, Cypress links the inner chainer + * to the outer one. This is *usually* desireable, allowing simple constructs like + * `cy.then(() => { return cy.get('foo') }).log()` to function intuitively. + * + * But you don't always want to use the inner chainer's subject for the outer chainer. Consider: + * `cy.then(() => { cy.get('foo').click(); return 'success' }).log()` + * + * In this case, we want to break the connection between the inner chainer and the outer one, so that we can + * instead use the return value as the new subject. Is this case, you'll want cy.breakSubjectLinksToCurrentChainer(). + */ + linkSubject (fromChainerId, toChainerId) { + const links = this.state('subjectLinks') || {} + + links[fromChainerId] = toChainerId + this.state('subjectLinks', links) + } + + /* + * You should call breakSubjectLinksToCurrentChainer() when the following are *both* true: + * 1. A command callback may contain cypress commands. + * 2. You do not want to use the subject of those commands as the new subject of the parent command chain. + * + * In this case, call the function directly before returning the new subject, after any new cypress commands have + * been added to the queue. See `cy.linkSubject()` for more details about how links are created. + */ + breakSubjectLinksToCurrentChainer () { + const chainerId = this.state('chainerId') + const links = this.state('subjectLinks') || {} + + this.state('subjectLinks', _.omitBy(links, (l) => l === chainerId)) + } + + /* + * setSubjectForChainer should be considered an internal implementation detail of Cypress. Do not use it directly + * outside of the Cypress codebase. It is currently used only by the command_queue, and if you think it's needed + * elsewhere, consider carefully before adding additional uses. + * + * The command_queue calls setSubjectForChainer after a command has finished resolving, when we have the final + * (non-$Chainer, non-promise) return value. This value becomes the current $Chainer's new subject - and the new + * subject for any chainers it's linked to (see cy.linkSubject for details on that process). + */ + setSubjectForChainer (chainerId: string, subject: any) { + const cySubject = this.state('subjects') || {} + + cySubject[chainerId] = subject + this.state('subjects', cySubject) + + const links = this.state('subjectLinks') || {} + + if (links[chainerId]) { + this.setSubjectForChainer(links[chainerId], subject) + } + } + private doneEarly () { this.queue.stop() diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index 63479cc6a7e3..380f835bf3a9 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -1858,6 +1858,9 @@ export default { > ${cmd(obj.previous)}` }, + state_subject_deprecated: { + message: `${cmd('state', '\'subject\'')} has been deprecated and will be removed in a future release. Consider migrating to ${cmd('currentSubject')} instead.`, + }, }, submit: { diff --git a/packages/driver/src/cypress/log.ts b/packages/driver/src/cypress/log.ts index 6bec5bf54100..7346dbf178fa 100644 --- a/packages/driver/src/cypress/log.ts +++ b/packages/driver/src/cypress/log.ts @@ -166,7 +166,9 @@ const defaults = function (state: StateFunc, config, obj) { // so it can conditionally return either // parent or child (useful in assertions) if (_.isFunction(obj.type)) { - obj.type = obj.type(current, state('subject')) + const chainerId = current && current.get('chainerId') + + obj.type = obj.type(current, (state('subjects') || {})[chainerId]) } } diff --git a/packages/driver/src/util/serialization/index.ts b/packages/driver/src/util/serialization/index.ts index 2d4bce4cfb0f..16a2a6cface0 100644 --- a/packages/driver/src/util/serialization/index.ts +++ b/packages/driver/src/util/serialization/index.ts @@ -72,7 +72,7 @@ const convertObjectToSerializableLiteral = (obj): typeof obj => { }) currentObjectRef = Object.getPrototypeOf(currentObjectRef) - } while (currentObjectRef) + } while (currentObjectRef && currentObjectRef !== Object.prototype && currentObjectRef !== Date.prototype) const objectAsLiteral = {} diff --git a/scripts/run-webpack.js b/scripts/run-webpack.js index 1d179c4525e8..6cf39d48bd1b 100644 --- a/scripts/run-webpack.js +++ b/scripts/run-webpack.js @@ -21,10 +21,10 @@ if (process.versions && semver.satisfies(process.versions.node, '>=17.0.0') && s } function buildCommand () { - const file = process.argv.slice(2).join(' ') ?? '' + const file = process.argv.slice(2) let program = `node "${webpackCli}"` - return file ? `${program } "${file}"` : program + return file.length ? `${program } "${file.join('" "')}"` : program } const program = buildCommand()