Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 78 additions & 12 deletions packages/app/cypress/e2e/runner/reporter.hooks.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,91 @@ describe('hooks', {
cy.contains('after all (2)').closest('.collapsible').should('contain', 'afterHook 1')
})

it('creates open in IDE button', () => {
loadSpec({
filePath: 'hooks/basic.cy.js',
passCount: 2,
hasPreferredIde: true,
describe('open in IDE button', () => {
it('sends the correct invocation details for before hook', () => {
loadSpec({
filePath: 'hooks/basic.cy.js',
passCount: 2,
hasPreferredIde: true,
})

cy.contains('tests 1').click()

cy.get('.hook-open-in-ide').should('have.length', 4)

cy.withCtx((ctx, o) => {
o.sinon.stub(ctx.actions.file, 'openFile')
})

cy.contains('before all').closest('.hook-header').find('.hook-open-in-ide').invoke('show').click()

cy.withCtx((ctx, o) => {
expect(ctx.actions.file.openFile).to.have.been.calledWith(o.sinon.match(new RegExp(`hooks/basic\.cy\.js$`)), 2, 2)
})
})

cy.contains('tests 1').click()
it('sends the correct invocation details for basic test body', () => {
loadSpec({
filePath: 'hooks/basic.cy.js',
passCount: 2,
hasPreferredIde: true,
})

cy.contains('tests 1').click()

cy.get('.hook-open-in-ide').should('have.length', 4)

cy.get('.hook-open-in-ide').should('have.length', 4)
cy.withCtx((ctx, o) => {
o.sinon.stub(ctx.actions.file, 'openFile')
})

cy.withCtx((ctx, o) => {
o.sinon.stub(ctx.actions.file, 'openFile')
cy.contains('test body').closest('.hook-header').find('.hook-open-in-ide').invoke('show').click()

cy.withCtx((ctx, o) => {
expect(ctx.actions.file.openFile).to.have.been.calledWith(o.sinon.match(new RegExp(`hooks/basic\.cy\.js$`)), 10, 2)
})
})

cy.get('.hook-open-in-ide').first().invoke('show').click()
it('sends the correct invocation details for .only test body', () => {
loadSpec({
filePath: 'hooks/only.cy.js',
passCount: 2,
hasPreferredIde: true,
})

cy.contains('test 2').click()

cy.get('.hook-open-in-ide').should('have.length', 2)

cy.withCtx((ctx, o) => {
o.sinon.stub(ctx.actions.file, 'openFile')
})

cy.contains('test body').closest('.hook-header').find('.hook-open-in-ide').invoke('show').click()

cy.withCtx((ctx, o) => {
expect(ctx.actions.file.openFile).to.have.been.calledWith(o.sinon.match(new RegExp(`hooks/only\.cy\.js$`)), 13, 7)
})
})

it('sends the correct invocation details for wrapped it', () => {
loadSpec({
filePath: 'hooks/wrapped-it.cy.js',
passCount: 2,
hasPreferredIde: true,
})

cy.contains('test 1').click()

cy.withCtx((ctx, o) => {
o.sinon.stub(ctx.actions.file, 'openFile')
})

cy.contains('test body').closest('.hook-header').find('.hook-open-in-ide').invoke('show').click()

cy.withCtx((ctx, o) => {
expect(ctx.actions.file.openFile).to.have.been.calledWith(o.sinon.match(new RegExp(`hooks/basic\.cy\.js$`)), 2, 2)
cy.withCtx((ctx, o) => {
expect(ctx.actions.file.openFile).to.have.been.calledWith(o.sinon.match(new RegExp(`hooks/wrapped-it\.cy\.js$`)), 5, 1)
})
})
})

Expand Down
2 changes: 1 addition & 1 deletion packages/driver/src/cypress/mocha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,7 @@ const patchSuiteAddTest = (specWindow) => {
const test = args[0]

if (!test.invocationDetails) {
test.invocationDetails = $stackUtils.getInvocationDetails(specWindow, $sourceMapUtils.getSourceMapProjectRoot())
test.invocationDetails = $stackUtils.getInvocationDetails(specWindow, $sourceMapUtils.getSourceMapProjectRoot(), 'test-body')
}

const ret = suiteAddTest.apply(this, args)
Expand Down
54 changes: 53 additions & 1 deletion packages/driver/src/cypress/stack_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,53 @@ const stackWithLinesRemoved = (stack, cb) => {
return unsplitStack(messageLines, remainingStackLines)
}

const stackWithWrappingLinesRemoved = (stack) => {
const modifiedStack = stackWithLinesRemoved(stack, (lines) => {
if (Cypress.isBrowser('chrome')) {
// There are cases where there are other lines in the stack trace before the invocation (eg. `context.it.only`, `createRunnable`, etc)
// Remove lines from the start until the top line starts with 'at eval' or 'at Suite.eval' so that we only keep the actual invocation line.
while (
lines.length > 0 &&
!(
lines[0].trim().startsWith('at eval') ||
lines[0].trim().startsWith('at Suite.eval')
)
) {
lines.shift()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Fix Chrome stack normalization for Context.eval lines

The Chrome stack normalization logic only checks for lines starting with at eval or at Suite.eval, but test invocation lines can also start with at Context.eval. This causes the function to incorrectly remove valid test invocation lines, potentially returning an empty stack or the wrong line. The condition should also check for at Context.eval to handle all test body invocation patterns.

Fix in Cursor Fix in Web

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be missing something, but from my testing, even if we use context, the stack still contains Suite.eval. So I think this is fine

} else if (Cypress.isBrowser('firefox')) {
const isTestInvocationLine = (line: string) => {
const splitAtAt = line.split('@')

// firefox stacks traces look like:
// functionName@https://aicotravel.com/__cypress/tests?p=cypress/support/e2e.js:444:14
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe use a different url besides aicotravel in the docs here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oops - meant to change that. Will update that to something else

// @https://aicotravel.com/__cypress/tests?p=cypress/e2e/spec.cy.js:43:3
// @https://aicotravel.com/__cypress/tests?p=cypress/e2e/spec.cy.js:45:12
// evalScripts/<@cypress:///../driver/src/cypress/script_utils.ts:38:23
//
// the actual invocation details will be at the first line with no function name
return splitAtAt.length > 1 && splitAtAt[0].trim().length === 0
}

while (
lines.length > 0 &&
!isTestInvocationLine(lines[0])
) {
lines.shift()
}
}

return lines
})

// if we removed all the lines then something went wrong. return the original stack instead
if (modifiedStack.length === 0) {
return stack
}

return modifiedStack
}

const stackWithLinesDroppedFromMarker = (stack, marker, includeLast = false) => {
return stackWithLinesRemoved(stack, (lines) => {
// drop lines above the marker
Expand Down Expand Up @@ -124,7 +171,7 @@ type InvocationDetails = {
}

// used to determine codeframes for hook/test/etc definitions rather than command invocations
const getInvocationDetails = (specWindow, sourceMapProjectRoot: string): InvocationDetails | undefined => {
const getInvocationDetails = (specWindow, sourceMapProjectRoot: string, type?: 'test-body'): InvocationDetails | undefined => {
if (specWindow.Error) {
let stack = (new specWindow.Error()).stack

Expand All @@ -146,6 +193,11 @@ const getInvocationDetails = (specWindow, sourceMapProjectRoot: string): Invocat
}
}

// if the hook is the test body, we will try to remove the lines that are not the actual invocation of the test
if (type === 'test-body') {
stack = stackWithWrappingLinesRemoved(stack)
}

const details: Omit<InvocationDetails, 'stack'> = getSourceDetailsForFirstLine(stack, sourceMapProjectRoot) || {}

;(details as any).stack = stack
Expand Down
77 changes: 76 additions & 1 deletion packages/driver/test/unit/cypress/stack_utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ describe('stack_utils', () => {
// @ts-expect-error
global.Cypress = {
config: vi.fn(),
isBrowser: vi.fn(() => true),
}

vi.resetAllMocks()
Expand All @@ -37,7 +38,7 @@ describe('stack_utils', () => {
return stack
}
}
const config = () => projectRoot
const config = projectRoot

for (const scenario of scenarios) {
const { browser, build, specFrame, stack: scenarioStack } = scenario
Expand All @@ -64,6 +65,80 @@ describe('stack_utils', () => {
})
})
}

it('returns the correct invocation details for a grep stack trace', () => {
const stack = `Error\n at itGrep (http://localhost:3000/__cypress/tests?p=cypress/support/e2e.js:444:14)\n
at eval (http://localhost:3000/__cypress/tests?p=cypress/e2e/spec.cy.js:14:1)\n
at eval (http://localhost:3000/__cypress/tests?p=cypress/e2e/spec.cy.js:18:12)\n
at eval (<anonymous>)\n
at eval (cypress:///../driver/src/cypress/script_utils.ts:38:23)`

class GrepError {
get stack () {
return stack
}
}

stack_utils.getInvocationDetails(
{ Error: GrepError, Cypress: {} },
config,
'test-body',
)

expect(source_map_utils.getSourcePosition).toHaveBeenCalledWith('http://localhost:3000/__cypress/tests?p=cypress/e2e/spec.cy.js', expect.objectContaining({
column: 1,
line: 14,
file: 'http://localhost:3000/__cypress/tests?p=cypress/e2e/spec.cy.js',
}))
})

it('returns the correct invocation details for a grep stack trace for a test body', () => {
const stack = `Error at itGrep (http://localhost:3000/__cypress/tests?p=cypress/support/e2e.js:444:14)
at context.notIt.only (cypress:///../driver/node_modules/mocha/lib/interfaces/bdd.js:98:46)
at createRunnable (cypress:///../driver/src/cypress/mocha.ts:126:31)
at itGrep.eval [as only] (cypress:///../driver/src/cypress/mocha.ts:187:14)
at Suite.eval (http://localhost:3000/__cypress/tests?p=cypress/e2e/spec.cy.js:12:6)`

class GrepError {
get stack () {
return stack
}
}

stack_utils.getInvocationDetails(
{ Error: GrepError, Cypress: {} },
config,
'test-body',
)

expect(source_map_utils.getSourcePosition).toHaveBeenCalledWith('http://localhost:3000/__cypress/tests?p=cypress/e2e/spec.cy.js', expect.objectContaining({
column: 6,
line: 12,
file: 'http://localhost:3000/__cypress/tests?p=cypress/e2e/spec.cy.js',
}))
})

it('returns the original stack if it cannot be normalized for a test body', () => {
const stack = `Error at itGrep (http://localhost:3000/__cypress/tests?p=cypress/support/e2e.js:444:14)
at context.notIt.only (cypress:///../driver/node_modules/mocha/lib/interfaces/bdd.js:98:46)
at createRunnable (cypress:///../driver/src/cypress/mocha.ts:126:31)
at itGrep.eval [as only] (cypress:///../driver/src/cypress/mocha.ts:187:14)
at somethingElse (http://localhost:3000/__cypress/tests?p=cypress/e2e/spec.cy.js:12:6)`

class GrepError {
get stack () {
return stack
}
}

const result = stack_utils.getInvocationDetails(
{ Error: GrepError, Cypress: {} },
config,
'test-body',
)

expect(result.stack).toEqual(stack)
})
})

describe('normalizedUserInvocationStack', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
function myIt (name, fn) {
it(name, fn)
}

myIt('test 1', () => {
cy.log('testBody 1')
})

myIt('test 2', () => {
cy.log('testBody 2')
})
Loading