Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
},
"dependencies": {
"@cucumber/ci-environment": "12.0.0",
"@cucumber/core": "0.5.1",
"@cucumber/core": "0.6.0",
"@cucumber/cucumber-expressions": "18.0.1",
"@cucumber/gherkin": "36.1.0",
"@cucumber/html-formatter": "22.1.0",
Expand Down
29 changes: 28 additions & 1 deletion src/bootstrap/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { Source, SourceMediaType } from '@cucumber/messages'

import { newId } from '../newId.js'
import { CompiledGherkin } from '../runner/index.js'

export const load: LoadHook = async (url, context, nextLoad) => {
if (url.endsWith('.feature.md') || url.endsWith('.feature')) {
Expand Down Expand Up @@ -41,8 +42,34 @@ export const load: LoadHook = async (url, context, nextLoad) => {
return {
format: 'module',
shortCircuit: true,
source: `import { run } from '@cucumber/node/runner'; run(${JSON.stringify({ source, gherkinDocument, pickles })})`,
source: generateCode({ source, gherkinDocument, pickles }),
}
}
return nextLoad(url)
}

function generateCode(gherkin: CompiledGherkin) {
return `import { suite, test } from 'node:test'
import { prepare } from '@cucumber/node/runner'

suite(${JSON.stringify(gherkin.gherkinDocument.feature?.name || gherkin.gherkinDocument.uri)}, async () => {
const plan = await prepare(${JSON.stringify(gherkin)})
${gherkin.pickles
.map((pickle, index) => {
return `const testCase${index} = plan.select(${JSON.stringify(pickle.id)})
await test(testCase${index}.name, async (ctx1) => {
await testCase${index}.setup(ctx1)
for (const testStep of testCase${index}.testSteps) {
await testStep.setup()
await ctx1.test(testStep.name, testStep.options, async (ctx2) => {
await testStep.execute(ctx2)
})
await testStep.teardown()
}
await testCase${index}.teardown()
})`
})
.join('\n')}
})
`
}
5 changes: 3 additions & 2 deletions src/reporters/generateEnvelopes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import path from 'node:path'
import { TestEvent } from 'node:test/reporters'

import {
Expand Down Expand Up @@ -92,7 +91,9 @@ export async function* generateEnvelopes(
}

function isFromHere(testLocationInfo: TestLocationInfo) {
return testLocationInfo.file?.startsWith(path.join(import.meta.dirname, '..'))
return (
testLocationInfo.file?.endsWith('.feature') || testLocationInfo.file?.endsWith('.feature.md')
)
}

function isEnvelope(data: string) {
Expand Down
54 changes: 0 additions & 54 deletions src/runner/ContextTracker.ts

This file was deleted.

57 changes: 57 additions & 0 deletions src/runner/ExecutableTestCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { TestContext } from 'node:test'

import { AssembledTestCase } from '@cucumber/core'

import { makeTimestamp } from '../makeTimestamp.js'
import { newId } from '../newId.js'
import { World } from '../types.js'
import { ExecutableTestStep } from './ExecutableTestStep.js'
import { messages } from './state.js'
import { WorldFactory } from './WorldFactory.js'

export class ExecutableTestCase {
public readonly id = newId()
public world: World
public outcomeKnown = false

constructor(
private readonly worldFactory: WorldFactory,
private readonly testCase: AssembledTestCase
) {}

get name(): string {
return this.testCase.name
}

get testSteps(): ReadonlyArray<ExecutableTestStep> {
return this.testCase.testSteps.map((ts) => {
return new ExecutableTestStep(this, ts)
})
}

async setup(nodeTestContext: TestContext) {
messages.connect(nodeTestContext)
messages.push({
testCaseStarted: {
id: this.id,
testCaseId: this.testCase.id,
attempt: 0,
timestamp: makeTimestamp(),
},
})

this.world = await this.worldFactory.create()
}

async teardown() {
await this.worldFactory.destroy(this.world)

messages.push({
testCaseFinished: {
testCaseStartedId: this.id,
willBeRetried: false,
timestamp: makeTimestamp(),
},
})
}
}
26 changes: 26 additions & 0 deletions src/runner/ExecutableTestPlan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { AssembledTestCase, AssembledTestPlan } from '@cucumber/core'
import { ensure } from '@cucumber/junit-xml-formatter/dist/src/helpers.js'

import { ExecutableTestCase } from './ExecutableTestCase.js'
import { WorldFactory } from './WorldFactory.js'

export class ExecutableTestPlan {
private readonly testCaseByPickleId: Map<string, AssembledTestCase> = new Map()

constructor(
private readonly worldFactory: WorldFactory,
private readonly plan: AssembledTestPlan
) {
for (const testCase of plan.testCases) {
this.testCaseByPickleId.set(testCase.pickleId, testCase)
}
}

select(pickleId: string): ExecutableTestCase {
const testCase = ensure(
this.testCaseByPickleId.get(pickleId),
'AssembledTestCase must exist for pickleId'
)
return new ExecutableTestCase(this.worldFactory, testCase)
}
}
100 changes: 100 additions & 0 deletions src/runner/ExecutableTestStep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { TestContext } from 'node:test'
import { styleText } from 'node:util'

import { AssembledTestStep } from '@cucumber/core'
import { TestStepResult } from '@cucumber/messages'

import { makeTimestamp } from '../makeTimestamp.js'
import { TestCaseContext } from '../types.js'
import { ExecutableTestCase } from './ExecutableTestCase.js'
import { makeAttachment, makeLink, makeLog } from './makeAttachment.js'
import { messages } from './state.js'

export class ExecutableTestStep {
constructor(
private readonly parent: ExecutableTestCase,
private readonly assembledStep: AssembledTestStep
) {}

get name(): string {
return [styleText('bold', this.assembledStep.name.prefix), this.assembledStep.name.body]
.filter(Boolean)
.join(' ')
}

get options() {
return { skip: this.parent.outcomeKnown && !this.assembledStep.always }
}

async setup() {
messages.push({
testStepStarted: {
testCaseStartedId: this.parent.id,
testStepId: this.assembledStep.id,
timestamp: makeTimestamp(),
},
})
}

async execute(nodeTestContext: TestContext) {
let success = false
try {
const { fn, args } = this.assembledStep.prepare(this.parent.world)
const context = this.makeContext(nodeTestContext)
await fn(context, ...args)
success = true
} finally {
if (!success) {
this.parent.outcomeKnown = true
}
}
}

private makeContext(nodeTestContext: TestContext): TestCaseContext {
return {
assert: nodeTestContext.assert,
mock: nodeTestContext.mock,
skip: () => {
nodeTestContext.skip()
this.parent.outcomeKnown = true
},
todo: () => {
nodeTestContext.todo()
this.parent.outcomeKnown = true
},
attach: async (data, options) => {
const attachment = await makeAttachment(data, options)
attachment.timestamp = makeTimestamp()
attachment.testCaseStartedId = this.parent.id
attachment.testStepId = this.assembledStep.id
messages.push({ attachment })
},
log: async (text) => {
const attachment = await makeLog(text)
attachment.timestamp = makeTimestamp()
attachment.testCaseStartedId = this.parent.id
attachment.testStepId = this.assembledStep.id
messages.push({ attachment })
},
link: async (url, title) => {
const attachment = await makeLink(url, title)
attachment.timestamp = makeTimestamp()
attachment.testCaseStartedId = this.parent.id
attachment.testStepId = this.assembledStep.id
messages.push({ attachment })
},
world: this.parent.world,
}
}

async teardown() {
messages.push({
testStepFinished: {
testCaseStartedId: this.parent.id,
testStepId: this.assembledStep.id,
timestamp: makeTimestamp(),
testStepResult: {} as TestStepResult,
},
})
}
}
Loading