diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 9b2bbbe8947..a45a1996dcc 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -6,6 +6,7 @@ _Released 02/08/2026 (PENDING)_ **Bugfixes:** - Fixed an issue on Windows where extracting the Studio or Prompt bundle could fail with `EPERM: operation not permitted` when renaming extracted files. The extract step now retries on EPERM/EACCES with a short delay to handle transient file locks. Addressed in [#33330](https://github.com/cypress-io/cypress/pull/33330). +- Reset command log nesting after grouped commands in `cy.origin()`. Fixed in [#33289](https://github.com/cypress-io/cypress/pull/33289). ## 15.10.0 diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/log.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/log.cy.ts index c5294baa42d..30543d49622 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/log.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/log.cy.ts @@ -67,6 +67,38 @@ context('cy.origin log', { browser: '!webkit' }, () => { }) }) + it('resets group level after grouped commands in cy.origin', () => { + cy.origin('http://www.foobar.com:3500', () => { + cy.get('body').within(() => { + cy.get('#dom').should('exist') + }) + + cy.get('body').within(() => { + cy.get('#dom').should('exist') + }) + }) + .then(() => { + const withinLogs = logs.filter((log) => log.get('isCrossOriginLog') && log.get('name') === 'within') + const getBodyLogs = logs.filter((log) => { + return log.get('isCrossOriginLog') + && log.get('name') === 'get' + && log.get('message') === 'body' + }) + + expect(withinLogs, 'within logs').to.have.length(2) + expect( + withinLogs[1].get('groupLevel'), + 'second within group level', + ).to.eq(withinLogs[0].get('groupLevel')) + + expect(getBodyLogs, 'get body logs').to.have.length(2) + expect( + getBodyLogs[1].get('groupLevel'), + 'second get body group level', + ).to.eq(getBodyLogs[0].get('groupLevel')) + }) + }) + it('primary origin does not override secondary origins timestamps', () => { logs = [] const secondaryLogs = { diff --git a/packages/driver/src/cross-origin/origin_fn.ts b/packages/driver/src/cross-origin/origin_fn.ts index e13546b9239..b8ae5716410 100644 --- a/packages/driver/src/cross-origin/origin_fn.ts +++ b/packages/driver/src/cross-origin/origin_fn.ts @@ -6,6 +6,11 @@ import { syncConfigToCurrentOrigin, syncEnvToCurrentOrigin, syncExposeToCurrentO import type { Runnable, Test } from 'mocha' import { LogUtils } from '../cypress/log' +interface RunOriginFnState extends Cypress.ObjectLike { + originLogGroupLevel?: number + originLogGroupId?: string +} + interface RunOriginFnOptions { config: Cypress.Config args: any @@ -14,7 +19,7 @@ interface RunOriginFnOptions { file?: string fn: string skipConfigValidation: boolean - state: {} + state: RunOriginFnState logCounter: number } @@ -149,6 +154,8 @@ export const handleOriginFn = (Cypress: Cypress.Cypress, cy: $Cy) => { let queueFinished = false reset(state) + cy.state('originLogGroupLevel', state?.originLogGroupLevel) + cy.state('originLogGroupId', state?.originLogGroupId) // Set the counter for log ids LogUtils.setCounter(logCounter) @@ -163,9 +170,15 @@ export const handleOriginFn = (Cypress: Cypress.Cypress, cy: $Cy) => { syncEnvToCurrentOrigin(env) } + const clearOriginLogGroupState = () => { + cy.state('originLogGroupLevel', undefined) + cy.state('originLogGroupId', undefined) + } + cy.state('onQueueEnd', () => { queueFinished = true setRunnableStateToPassed() + clearOriginLogGroupState() Cypress.specBridgeCommunicator.toPrimary('queue:finished', { subject: cy.subject(), }, { @@ -175,6 +188,7 @@ export const handleOriginFn = (Cypress: Cypress.Cypress, cy: $Cy) => { cy.state('onFail', (err) => { setRunnableStateToPassed() + clearOriginLogGroupState() if (queueFinished) { // If the queue is already finished, send this event instead because // the primary won't be listening for 'queue:finished' anymore @@ -228,12 +242,14 @@ export const handleOriginFn = (Cypress: Cypress.Cypress, cy: $Cy) => { if (!hasCommands) { queueFinished = true setRunnableStateToPassed() + clearOriginLogGroupState() return } } } catch (err) { setRunnableStateToPassed() + clearOriginLogGroupState() Cypress.specBridgeCommunicator.toPrimary('ran:origin:fn', { err }, { syncGlobals: true }) return diff --git a/packages/driver/src/cy/commands/origin/index.ts b/packages/driver/src/cy/commands/origin/index.ts index 256f369923a..441d1aedc1e 100644 --- a/packages/driver/src/cy/commands/origin/index.ts +++ b/packages/driver/src/cy/commands/origin/index.ts @@ -62,6 +62,8 @@ export default (Commands, Cypress: InternalCypress.Cypress, cy: Cypress.cy, stat } let log + let originLogGroupLevel + let originLogGroupId logGroup(Cypress, { name: 'origin', @@ -71,6 +73,10 @@ export default (Commands, Cypress: InternalCypress.Cypress, cy: Cypress.cy, stat // @ts-ignore TODO: revisit once log-grouping has more implementations }, (_log) => { log = _log + originLogGroupId = _log?.get('id') + originLogGroupLevel = (Cypress.state('logGroupIds') || []).length || 1 + Cypress.state('originLogGroupId', originLogGroupId) + Cypress.state('originLogGroupLevel', originLogGroupLevel) }) const validator = new Validator({ @@ -198,6 +204,11 @@ export default (Commands, Cypress: InternalCypress.Cypress, cy: Cypress.cy, stat const file = $stackUtils.getSourceDetailsForFirstLine(userInvocationStack, $sourceMapUtils.getSourceMapProjectRoot())?.absoluteFile try { + originLogGroupId = originLogGroupId ?? log?.get('id') + originLogGroupLevel = originLogGroupLevel ?? ((Cypress.state('logGroupIds') || []).length || 1) + + Cypress.state('originLogGroupId', originLogGroupId) + Cypress.state('originLogGroupLevel', originLogGroupLevel) // origin is a privileged command, meaning it has to be invoked // from the spec or support file await runPrivilegedCommand({ @@ -231,6 +242,8 @@ export default (Commands, Cypress: InternalCypress.Cypress, cy: Cypress.cy, stat crossOriginCookies: Cypress.state('crossOriginCookies'), isProtocolEnabled: Cypress.state('isProtocolEnabled'), originUserInvocationStack: userInvocationStack, + originLogGroupLevel, + originLogGroupId, }, config: preprocessConfig(Cypress.config()), env: Cypress.config('allowCypressEnv') ? preprocessEnv(Cypress.env()) : undefined, diff --git a/packages/driver/src/cypress/log.ts b/packages/driver/src/cypress/log.ts index bff0d62d636..fa31a859d0e 100644 --- a/packages/driver/src/cypress/log.ts +++ b/packages/driver/src/cypress/log.ts @@ -22,6 +22,29 @@ const BLACKLIST_PROPS = 'snapshots'.split(' ') const PROTOCOL_MESSAGE_TRUNCATION_LENGTH = 3000 let counter = 0 +const LOG_ID_ORIGIN_RE = /^log-(.+)-\d+$/ + +const getLogOrigin = (id?: string) => { + if (!id) { + return window.location.origin + } + + const match = LOG_ID_ORIGIN_RE.exec(id) + + return match ? match[1] : window.location.origin +} + +const getCurrentOriginLogGroupIds = (state: StateFunc) => { + const logGroupIds = state('logGroupIds') || [] + const currentOrigin = window.location.origin + const effectiveLogGroupIds = logGroupIds.filter((id) => getLogOrigin(id) === currentOrigin) + + if (effectiveLogGroupIds.length !== logGroupIds.length) { + state('logGroupIds', effectiveLogGroupIds) + } + + return effectiveLogGroupIds +} export const LogUtils = { // mutate attrs by nulling out @@ -102,7 +125,7 @@ export const LogUtils = { return _ .chain(tests) .flatMap((test) => test.prevAttempts ? [test, ...test.prevAttempts] : [test]) - .flatMap<{id: string}>((tests) => [].concat(tests.agents, tests.routes, tests.commands)) + .flatMap<{ id: string }>((tests) => [].concat(tests.agents, tests.routes, tests.commands)) .compact() .union([{ id: '0' }]) // id is a string in the form of 'log-origin-#', grab the number off the end. @@ -222,19 +245,83 @@ const defaults = function (state: StateFunc, config, obj) { }, }) - const logGroupIds = state('logGroupIds') || [] - - if (logGroupIds.length) { - obj.group = _.last(logGroupIds) - obj.groupLevel = logGroupIds.length + if (Cypress.isCrossOriginSpecBridge) { + obj.originLogGroupLevel = state('originLogGroupLevel') + obj.originLogGroupId = state('originLogGroupId') } - if (obj.groupEnd) { - state('logGroupIds', _.slice(logGroupIds, 0, -1)) - } + const logGroupIds = getCurrentOriginLogGroupIds(state) + const logOrigin = getLogOrigin(obj.id) + const isDifferentOriginLog = logOrigin !== window.location.origin + const isCrossOriginId = typeof obj.id === 'string' && !obj.id.startsWith(`log-${window.location.origin}-`) + const isPrimaryReceivingCrossOriginLog = !Cypress.isCrossOriginSpecBridge + && (obj.isCrossOriginLog || isDifferentOriginLog || isCrossOriginId) + let originLogGroupLevel = obj.originLogGroupLevel ?? state('originLogGroupLevel') + let originLogGroupId = obj.originLogGroupId ?? state('originLogGroupId') + + const resolvedOriginLogGroupLevel = originLogGroupLevel ?? 0 + const effectiveOriginLogGroupLevel = Cypress.isCrossOriginSpecBridge ? resolvedOriginLogGroupLevel : 0 + + if (isPrimaryReceivingCrossOriginLog) { + if (originLogGroupLevel != null) { + obj.originLogGroupLevel = originLogGroupLevel + } + + if (originLogGroupId) { + obj.originLogGroupId = originLogGroupId + } + + if (obj.groupLevel == null) { + if (originLogGroupLevel != null) { + obj.groupLevel = originLogGroupLevel + } else if (logGroupIds.length) { + obj.groupLevel = logGroupIds.length + } + } + + if (!obj.group) { + if (originLogGroupId) { + obj.group = originLogGroupId + } else if (logGroupIds.length) { + obj.group = _.last(logGroupIds) + } + } + + // cross-origin group start/end should not mutate primary log group state + if (obj.groupStart) { + obj.groupStart = false + } - if (obj.groupStart) { - state('logGroupIds', (logGroupIds).concat(obj.id)) + if (obj.groupEnd) { + obj.groupEnd = false + } + + // Never let cross-origin logs pollute primary log group state. + getCurrentOriginLogGroupIds(state) + } else { + if (logGroupIds.length) { + obj.group = _.last(logGroupIds) + obj.groupLevel = logGroupIds.length + effectiveOriginLogGroupLevel + } else if (effectiveOriginLogGroupLevel) { + obj.groupLevel = effectiveOriginLogGroupLevel + + if (originLogGroupId) { + obj.group = originLogGroupId + } + } + + const shouldTrackGroup = !isDifferentOriginLog && !isCrossOriginId + && (!obj.isCrossOriginLog || Cypress.isCrossOriginSpecBridge) + + if (shouldTrackGroup) { + if (obj.groupEnd) { + state('logGroupIds', _.slice(logGroupIds, 0, -1)) + } + + if (obj.groupStart) { + state('logGroupIds', (logGroupIds).concat(obj.id)) + } + } } return obj @@ -247,7 +334,7 @@ export class Log { fireChangeEvent: DebouncedFunc<((log) => (void | undefined))> _hasInitiallyLogged: boolean = false - private attributes: Record = { } + private attributes: Record = {} private _emittedAttrs: Record = {} constructor (createSnapshot, state, config, fireChangeEvent) { @@ -404,7 +491,7 @@ export class Log { if ( (!Cypress.isCrossOriginSpecBridge && this.get('isCrossOriginLog')) || (!this.config('isInteractive') - || (this.config('numTestsKeptInMemory') === 0)) && !this.state('isProtocolEnabled')) { + || (this.config('numTestsKeptInMemory') === 0)) && !this.state('isProtocolEnabled')) { return this } @@ -421,10 +508,11 @@ export class Log { } error (err) { - const logGroupIds = this.state('logGroupIds') || [] + const logGroupIds = getCurrentOriginLogGroupIds(this.state) + const logOrigin = getLogOrigin(this.attributes.id) // current log was responsible for creating the current log group so end the current group - if (_.last(logGroupIds) === this.attributes.id) { + if (logOrigin === window.location.origin && _.last(logGroupIds) === this.attributes.id) { this.endGroup() } @@ -458,7 +546,13 @@ export class Log { } endGroup () { - this.state('logGroupIds', _.slice(this.state('logGroupIds'), 0, -1)) + if (getLogOrigin(this.attributes.id) !== window.location.origin) { + return + } + + const logGroupIds = getCurrentOriginLogGroupIds(this.state) + + this.state('logGroupIds', _.slice(logGroupIds, 0, -1)) } getError (err) { @@ -533,8 +627,8 @@ export class Log { // and a command return (this.get('autoEnd') !== false) && (this.get('ended') !== true) && - (this.get('event') === false) && - (this.get('instrument') === 'command') + (this.get('event') === false) && + (this.get('instrument') === 'command') } finish () { diff --git a/packages/driver/src/cypress/state.ts b/packages/driver/src/cypress/state.ts index 73e599b30b2..715157bf46a 100644 --- a/packages/driver/src/cypress/state.ts +++ b/packages/driver/src/cypress/state.ts @@ -25,6 +25,8 @@ export interface StateFunc { (k: 'autLocation', v?: LocationObject): LocationObject (k: 'originCommandBaseUrl', v?: string): string (k: 'currentActiveOrigin', v?: string): string + (k: 'originLogGroupLevel', v?: number): number | undefined + (k: 'originLogGroupId', v?: string): string | undefined (k: 'duringUserTestExecution', v?: boolean): boolean (k: 'onQueueEnd', v?: () => void): () => void (k: 'onFail', v?: (err: Error) => void): (err: Error) => void diff --git a/packages/driver/types/cypress/log.d.ts b/packages/driver/types/cypress/log.d.ts index 27fd4b37d1c..8dd6ada2705 100644 --- a/packages/driver/types/cypress/log.d.ts +++ b/packages/driver/types/cypress/log.d.ts @@ -94,6 +94,10 @@ declare namespace Cypress { functionName?: string // whether or not to start a new log group groupStart?: boolean + // cross-origin log group level offset from the primary origin + originLogGroupLevel?: number + // cross-origin log group id from the primary origin + originLogGroupId?: string // whether or not the log should display in the reporter hidden?: boolean hookId?: number