Skip to content

Commit 4ea3227

Browse files
authored
alternative stack trace approach (#2119)
* reimpl getDefinitionLineAndUri * disable current stack trace filtering * filter stack traces without modifying error * rename file * rename file * add advice about source maps * WIP feature for transpiled stack traces * improve feature for transpiled stack traces * skip source-mapping scenarios when instrumenting with nyc * update CHANGELOG.md
1 parent e88c691 commit 4ea3227

File tree

15 files changed

+187
-101
lines changed

15 files changed

+187
-101
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
88
Please see [CONTRIBUTING.md](https://github.com/cucumber/cucumber/blob/master/CONTRIBUTING.md) on how to contribute to Cucumber.
99

1010
## [Unreleased]
11+
## Changed
12+
- Handle stack traces without V8-specific modification ([#2119](https://github.com/cucumber/cucumber-js/pull/2119))
1113

1214
## [8.7.0] - 2022-10-17
1315
### Deprecated

docs/transpiling.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,14 @@ If you are using babel with [@babel/preset-typescript](https://babeljs.io/docs/e
5757
### ESM
5858

5959
See [ESM](./esm.md) for general advice on using loaders for transpilation in ESM projects.
60+
61+
### Source maps
62+
63+
Source maps are used to ensure accurate source references and stack traces in Cucumber's reporting, by giving traceability from a transpiled piece of code back to the original source code.
64+
65+
Just-in-time transpilers like `ts-node` and `@babel/register` have sensible default configuration that emits source maps and enables them in the runtime environment, so you shouldn't have to do anything in order for source maps to work.
66+
67+
If you're using step definition code that's _already_ transpiled (maybe because it's a shared library) then you'll need to:
68+
69+
1. Ensure source maps are emitted by your transpiler. You can verify by checking for a comment starting with `//# sourceMappingURL=` at the end of your transpiled file(s).
70+
2. Ensure source maps are enabled at runtime. Node.js supports this natively via [the `--enable-source-maps` flag](https://nodejs.org/docs/latest/api/cli.html#--enable-source-maps).

features/stack_traces.feature

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
@spawn
2+
@source-mapping
3+
Feature: Stack traces
4+
Background:
5+
Given a file named "features/a.feature" with:
6+
"""
7+
Feature: some feature
8+
Scenario: some scenario
9+
Given a passing step
10+
And a failing step
11+
"""
12+
13+
Rule: Source maps are respected when dealing with transpiled support code
14+
15+
Just-in-time transpilers like `@babel/register` and `ts-node` emit source maps with
16+
the transpiled code. Cucumber users expect stack traces to point to the line and column
17+
in the original source file when there is an error.
18+
19+
Background:
20+
Given a file named "features/steps.ts" with:
21+
"""
22+
import { Given } from '@cucumber/cucumber'
23+
24+
interface Something {
25+
field1: string
26+
field2: string
27+
}
28+
29+
Given('a passing step', function() {})
30+
31+
Given('a failing step', function() {
32+
throw new Error('boom')
33+
})
34+
"""
35+
36+
Scenario: commonjs
37+
When I run cucumber-js with `--require-module ts-node/register --require features/steps.ts`
38+
Then the output contains the text:
39+
"""
40+
/features/steps.ts:11:9
41+
"""
42+
And it fails
43+
44+
Scenario: esm
45+
Given my env includes "{\"NODE_OPTIONS\":\"--loader ts-node/esm\"}"
46+
When I run cucumber-js with `--import features/steps.ts`
47+
Then the output contains the text:
48+
"""
49+
/features/steps.ts:11:9
50+
"""
51+
And it fails

features/support/world.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,14 @@ export class World {
4141
parseEnvString(str: string): NodeJS.ProcessEnv {
4242
const result: NodeJS.ProcessEnv = {}
4343
if (doesHaveValue(str)) {
44-
str
45-
.split(/\s+/)
46-
.map((keyValue) => keyValue.split('='))
47-
.forEach((pair) => (result[pair[0]] = pair[1]))
44+
try {
45+
Object.assign(result, JSON.parse(str))
46+
} catch {
47+
str
48+
.split(/\s+/)
49+
.map((keyValue) => keyValue.split('='))
50+
.forEach((pair) => (result[pair[0]] = pair[1]))
51+
}
4852
}
4953
return result
5054
}

package-lock.json

Lines changed: 36 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,6 @@
195195
"node": "12 || 14 || 16 || 17 || 18"
196196
},
197197
"dependencies": {
198-
"@cspotcode/source-map-support": "^0.8.0",
199198
"@cucumber/ci-environment": "9.1.0",
200199
"@cucumber/cucumber-expressions": "16.0.0",
201200
"@cucumber/gherkin": "24.1.0",
@@ -213,6 +212,7 @@
213212
"debug": "^4.3.4",
214213
"duration": "^0.2.2",
215214
"durations": "^3.4.2",
215+
"error-stack-parser": "^2.1.4",
216216
"figures": "^3.2.0",
217217
"glob": "^7.1.6",
218218
"has-ansi": "^4.0.1",
@@ -226,7 +226,6 @@
226226
"progress": "^2.0.3",
227227
"resolve-pkg": "^2.0.0",
228228
"semver": "7.3.8",
229-
"stack-chain": "^2.0.0",
230229
"string-argv": "^0.3.1",
231230
"strip-ansi": "6.0.1",
232231
"supports-color": "^8.1.1",
@@ -311,7 +310,7 @@
311310
"prepublishOnly": "rm -rf lib && npm run build-local",
312311
"pretest-coverage": "npm run build-local",
313312
"pretypes-test": "npm run build-local",
314-
"test-coverage": "nyc --silent mocha 'src/**/*_spec.ts' 'compatibility/**/*_spec.ts' && nyc --silent --no-clean node bin/cucumber.js && nyc report --reporter=lcov",
313+
"test-coverage": "nyc --silent mocha 'src/**/*_spec.ts' 'compatibility/**/*_spec.ts' && nyc --silent --no-clean node bin/cucumber.js --tags \"not @source-mapping\" && nyc report --reporter=lcov",
315314
"test": "npm run lint && npm run types-test && npm run unit-test && npm run cck-test && npm run feature-test",
316315
"types-test": "tsd",
317316
"unit-test": "mocha 'src/**/*_spec.ts'",

src/filter_stack_trace.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import path from 'path'
2+
import { valueOrDefault } from './value_checker'
3+
import { StackFrame } from 'error-stack-parser'
4+
5+
const projectRootPath = path.join(__dirname, '..')
6+
const projectChildDirs = ['src', 'lib', 'node_modules']
7+
8+
export function isFileNameInCucumber(fileName: string): boolean {
9+
return projectChildDirs.some((dir) =>
10+
fileName.startsWith(path.join(projectRootPath, dir))
11+
)
12+
}
13+
14+
export function filterStackTrace(frames: StackFrame[]): StackFrame[] {
15+
if (isErrorInCucumber(frames)) {
16+
return frames
17+
}
18+
const index = frames.findIndex((x) => isFrameInCucumber(x))
19+
if (index === -1) {
20+
return frames
21+
}
22+
return frames.slice(0, index)
23+
}
24+
25+
function isErrorInCucumber(frames: StackFrame[]): boolean {
26+
const filteredFrames = frames.filter((x) => !isFrameInNode(x))
27+
return filteredFrames.length > 0 && isFrameInCucumber(filteredFrames[0])
28+
}
29+
30+
function isFrameInCucumber(frame: StackFrame): boolean {
31+
const fileName = valueOrDefault(frame.getFileName(), '')
32+
return isFileNameInCucumber(fileName)
33+
}
34+
35+
function isFrameInNode(frame: StackFrame): boolean {
36+
const fileName = valueOrDefault(frame.getFileName(), '')
37+
return !fileName.includes(path.sep)
38+
}

src/runtime/format_error.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { format } from 'assertion-error-formatter'
2+
import errorStackParser from 'error-stack-parser'
3+
import { filterStackTrace } from '../filter_stack_trace'
4+
5+
export function formatError(error: Error, filterStackTraces: boolean): string {
6+
let filteredStack: string
7+
if (filterStackTraces) {
8+
try {
9+
filteredStack = filterStackTrace(errorStackParser.parse(error))
10+
.map((f) => f.source)
11+
.join('\n')
12+
} catch {
13+
// if we weren't able to parse and filter, we'll settle for the original
14+
}
15+
}
16+
return format(error, {
17+
colorFns: {
18+
errorStack: (stack: string) =>
19+
filteredStack ? `\n${filteredStack}` : stack,
20+
},
21+
})
22+
}

src/runtime/index.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import * as messages from '@cucumber/messages'
22
import { IdGenerator } from '@cucumber/messages'
33
import { EventEmitter } from 'events'
44
import { EventDataCollector } from '../formatter/helpers'
5-
import StackTraceFilter from '../stack_trace_filter'
65
import { ISupportCodeLibrary } from '../support_code_library_builder/types'
76
import { assembleTestCases } from './assemble_test_cases'
87
import { retriesForPickle, shouldCauseFailure } from './helpers'
@@ -40,7 +39,6 @@ export default class Runtime implements IRuntime {
4039
private readonly newId: IdGenerator.NewId
4140
private readonly options: IRuntimeOptions
4241
private readonly pickleIds: string[]
43-
private readonly stackTraceFilter: StackTraceFilter
4442
private readonly supportCodeLibrary: ISupportCodeLibrary
4543
private success: boolean
4644
private runTestRunHooks: RunsTestRunHooks
@@ -59,7 +57,6 @@ export default class Runtime implements IRuntime {
5957
this.newId = newId
6058
this.options = options
6159
this.pickleIds = pickleIds
62-
this.stackTraceFilter = new StackTraceFilter()
6360
this.supportCodeLibrary = supportCodeLibrary
6461
this.success = true
6562
this.runTestRunHooks = makeRunTestRunHooks(
@@ -85,6 +82,7 @@ export default class Runtime implements IRuntime {
8582
testCase,
8683
retries,
8784
skip,
85+
filterStackTraces: this.options.filterStacktraces,
8886
supportCodeLibrary: this.supportCodeLibrary,
8987
worldParameters: this.options.worldParameters,
9088
})
@@ -95,9 +93,6 @@ export default class Runtime implements IRuntime {
9593
}
9694

9795
async start(): Promise<boolean> {
98-
if (this.options.filterStacktraces) {
99-
this.stackTraceFilter.filter()
100-
}
10196
const testRunStarted: messages.Envelope = {
10297
testRunStarted: {
10398
timestamp: this.stopwatch.timestamp(),
@@ -132,9 +127,6 @@ export default class Runtime implements IRuntime {
132127
},
133128
}
134129
this.eventBroadcaster.emit('envelope', testRunFinished)
135-
if (this.options.filterStacktraces) {
136-
this.stackTraceFilter.unfilter()
137-
}
138130
return this.success
139131
}
140132
}

0 commit comments

Comments
 (0)