Skip to content
Draft
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
20 changes: 17 additions & 3 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@cucumber/html-formatter": "23.0.0",
"@cucumber/junit-xml-formatter": "0.9.0",
"@cucumber/messages": "32.0.1",
"@cucumber/pretty-formatter": "3.0.0",
"@cucumber/query": "15.0.1",
"@cucumber/tag-expressions": "9.0.0",
"@teppeis/multimaps": "3.0.0",
Expand Down
26 changes: 26 additions & 0 deletions src/reporters/builtin/pretty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { TestEvent } from 'node:test/reporters'

import { type PrettyOptions, PrettyPrinter } from '@cucumber/pretty-formatter'

import { enrichMessages } from '../enrichMessages.js'
import { proxyStream } from '../proxyStream.js'

const options: PrettyOptions = {
summarise: true,
}

export default async function* (events: AsyncIterable<TestEvent>): AsyncGenerator<string> {
const buffer: Array<string> = []
const stream = proxyStream(process.stdout, (content: string) => buffer.push(content))
const printer = new PrettyPrinter({ stream, options })
const envelopes = enrichMessages(events)
for await (const envelope of envelopes) {
printer.update(envelope)
if (buffer.length) {
const togo = buffer.splice(0)
for (const content of togo) {
yield content
}
}
}
}
22 changes: 22 additions & 0 deletions src/reporters/builtin/progress-bar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { TestEvent } from 'node:test/reporters'

import { type ProgressBarOptions, ProgressBarPrinter } from '@cucumber/pretty-formatter'

import { enrichMessages } from '../enrichMessages.js'

const options: ProgressBarOptions = {}

export default async function* (events: AsyncIterable<TestEvent>): AsyncGenerator<string> {
const buffer: Array<string> = []
const printer = new ProgressBarPrinter({ stream: process.stdout, options })
const envelopes = enrichMessages(events)
for await (const envelope of envelopes) {
printer.update(envelope)
if (buffer.length) {
const togo = buffer.splice(0)
for (const content of togo) {
yield content
}
}
}
}
26 changes: 26 additions & 0 deletions src/reporters/builtin/progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { TestEvent } from 'node:test/reporters'

import { type ProgressOptions, ProgressPrinter } from '@cucumber/pretty-formatter'

import { enrichMessages } from '../enrichMessages.js'
import { proxyStream } from '../proxyStream.js'

const options: ProgressOptions = {
summarise: true,
}

export default async function* (events: AsyncIterable<TestEvent>): AsyncGenerator<string> {
const buffer: Array<string> = []
const stream = proxyStream(process.stdout, (content: string) => buffer.push(content))
const printer = new ProgressPrinter({ stream, options })
const envelopes = enrichMessages(events)
for await (const envelope of envelopes) {
printer.update(envelope)
if (buffer.length) {
const togo = buffer.splice(0)
for (const content of togo) {
yield content
}
}
}
}
21 changes: 21 additions & 0 deletions src/reporters/proxyStream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { WriteStream } from 'node:tty'

/**
* Captures attempts to write to a stream, but otherwise passes through all accessors. Useful for
* exposing the underlying stream for feature detection while still controlling writes.
* @param stream
* @param write
*/
export function proxyStream(stream: WriteStream, write: (chunk: string) => void): WriteStream {
return new Proxy(stream, {
get(target, prop, receiver) {
if (prop === 'write') {
return (chunk: string) => {
write(chunk)
return true
}
}
return Reflect.get(target, prop, receiver)
},
})
}
93 changes: 73 additions & 20 deletions test/integration/reporters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,6 @@ describe('Reporters', () => {
expect(sanitised).to.include(`test at ${path.join('features', 'foo.feature')}:2:3`)
})

it('does not emit messages as diagnostics if no cucumber reporters', async () => {
const harness = await makeTestHarness()
await harness.writeFile(
'features/first.feature',
`Feature:
Scenario:
Given a step
`
)
await harness.writeFile(
'features/steps.js',
`import { Given } from '@cucumber/node'
Given('a step', () => {})
`
)
const [output] = await harness.run('spec')
const sanitised = stripVTControlCharacters(output.trim())
expect(sanitised).not.to.include('@cucumber/messages:')
})

it('provides a useful error for an ambiguous step', async () => {
const harness = await makeTestHarness()
await harness.writeFile(
Expand Down Expand Up @@ -95,6 +75,79 @@ Given('a step', () => {})`
})
})

describe('pretty', () => {
it('outputs the pretty format', async () => {
const harness = await makeTestHarness()
await harness.writeFile(
'features/first.feature',
`Feature:
Scenario:
Given a step
And a step

Scenario:
Given a step
But a step
`
)
await harness.writeFile(
'features/steps.js',
`import { Given } from '@cucumber/node'
Given('a step', () => {})
`
)

const [output] = await harness.run('@cucumber/node/reporters/pretty')
const sanitised = stripVTControlCharacters(output.trim())

const featurePath = path.join('features', 'first.feature')
const stepsPath = path.join('features', 'steps.js')
expect(sanitised).to.include(`Feature: ${''}

Scenario: # ${featurePath}:2
✔ Given a step # ${stepsPath}:2
✔ And a step # ${stepsPath}:2

Scenario: # ${featurePath}:6
✔ Given a step # ${stepsPath}:2
✔ But a step # ${stepsPath}:2

2 scenarios (2 passed)
4 steps (4 passed)`)
})
})

describe('progress', () => {
it('outputs the progress format', async () => {
const harness = await makeTestHarness()
await harness.writeFile(
'features/first.feature',
`Feature:
Scenario:
Given a step
And a step

Scenario:
Given a step
But a step
`
)
await harness.writeFile(
'features/steps.js',
`import { Given } from '@cucumber/node'
Given('a step', () => {})
`
)

const [output] = await harness.run('@cucumber/node/reporters/progress')
const sanitised = stripVTControlCharacters(output.trim())

expect(sanitised).to.include(
'....\n' + '\n' + '2 scenarios (2 passed)\n' + '4 steps (4 passed)\n'
)
})
})

describe('junit', () => {
it('outputs a junit xml report', async () => {
const harness = await makeTestHarness()
Expand Down
91 changes: 56 additions & 35 deletions test/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { exec } from 'node:child_process'
import { copyFile, cp, mkdir, mkdtemp, symlink, writeFile } from 'node:fs/promises'
import { exec, spawn } from 'node:child_process'
import { copyFile, cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'

import type { Envelope } from '@cucumber/messages'
import type { Query } from '@cucumber/query'

let messageFileSeq = 0

class TestHarness {
constructor(private readonly tempDir: string) {}

Expand Down Expand Up @@ -40,40 +41,60 @@ class TestHarness {
async run(
reporter: string | Query = 'spec',
...extraArgs: string[]
): Promise<readonly [string, string, unknown]> {
): Promise<readonly [string, unknown]> {
const query = typeof reporter === 'object' ? reporter : undefined
return new Promise((resolve) => {
exec(
[
'node',
`--enable-source-maps`,
`--import`,
`@cucumber/node/bootstrap`,
`--test-reporter=${query ? '@cucumber/node/reporters/message' : reporter}`,
`--test-reporter-destination=stdout`,
...extraArgs,
`--test`,
`"*.test.mjs"`,
`"features/**/*.feature"`,
`"features/**/*.feature.md"`,
].join(' '),
{
cwd: this.tempDir,
},
(error, stdout, stderr) => {
if (query) {
stdout
.trim()
.split('\n')
.map((line) => JSON.parse(line) as Envelope)
.forEach((envelope) => {
query.update(envelope)
})
}
resolve([stdout, stderr, error])
}
)
// when collecting envelopes for a query, route the message reporter to a file so long
// ndjson payloads bypass stdout (which on Windows is line-buffered and would interleave
// awkwardly with anything else the runner emits)
const messageFile = query
? path.join(tmpdir(), `cucumber-node-messages-${process.pid}-${++messageFileSeq}.ndjson`)
: undefined

const args = [
'--enable-source-maps',
'--import',
'@cucumber/node/bootstrap',
`--test-reporter=${query ? '@cucumber/node/reporters/message' : reporter}`,
`--test-reporter-destination=${messageFile ?? 'stdout'}`,
...extraArgs,
'--test',
'*.test.mjs',
'features/**/*.feature',
'features/**/*.feature.md',
]

const [output, error] = await new Promise<readonly [string, unknown]>((resolve) => {
const child = spawn(process.execPath, args, {
cwd: this.tempDir,
// FORCE_COLOR makes node:util.styleText emit ANSI escapes even though stdout
// isn't a TTY here, so we exercise the formatters' styled-output path
env: { ...process.env, FORCE_COLOR: '1' },
})

let captured = ''
child.stdout.on('data', (chunk) => {
captured += chunk.toString()
})
child.stderr.on('data', (chunk) => {
captured += chunk.toString()
})

child.on('close', (code) => {
resolve([captured, code !== 0 ? { code } : null])
})
})

if (query && messageFile) {
const ndjson = await readFile(messageFile, 'utf-8')
await rm(messageFile, { force: true })
for (const line of ndjson.split('\n')) {
if (line.trim().startsWith('{')) {
query.update(JSON.parse(line) as Envelope)
}
}
}

return [output, error]
}
}

Expand Down
Loading