Skip to content
1 change: 1 addition & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions packages/driver/cypress/e2e/e2e/origin/commands/log.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
18 changes: 17 additions & 1 deletion packages/driver/src/cross-origin/origin_fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,7 +19,7 @@ interface RunOriginFnOptions {
file?: string
fn: string
skipConfigValidation: boolean
state: {}
state: RunOriginFnState
logCounter: number
}

Expand Down Expand Up @@ -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)
Expand All @@ -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(),
}, {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/driver/src/cy/commands/origin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export default (Commands, Cypress: InternalCypress.Cypress, cy: Cypress.cy, stat
}

let log
let originLogGroupLevel
let originLogGroupId

logGroup(Cypress, {
name: 'origin',
Expand All @@ -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({
Expand Down Expand Up @@ -198,6 +204,10 @@ 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 = (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({
Expand Down Expand Up @@ -231,6 +241,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,
Expand Down
130 changes: 112 additions & 18 deletions packages/driver/src/cypress/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -247,7 +334,7 @@ export class Log {
fireChangeEvent: DebouncedFunc<((log) => (void | undefined))>

_hasInitiallyLogged: boolean = false
private attributes: Record<string, any> = { }
private attributes: Record<string, any> = {}
private _emittedAttrs: Record<string, any> = {}

constructor (createSnapshot, state, config, fireChangeEvent) {
Expand Down Expand Up @@ -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
}

Expand All @@ -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()
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 () {
Expand Down
2 changes: 2 additions & 0 deletions packages/driver/src/cypress/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/driver/types/cypress/log.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down