From f3448c9660631727a15e24ee350353207c992bd3 Mon Sep 17 00:00:00 2001 From: David Goss Date: Thu, 7 May 2026 09:08:42 +0100 Subject: [PATCH] Adopt Cucumber terminal formatters --- package-lock.json | 20 +++++- package.json | 1 + src/reporters/builtin/pretty.ts | 26 ++++++++ src/reporters/builtin/progress-bar.ts | 22 +++++++ src/reporters/builtin/progress.ts | 26 ++++++++ src/reporters/proxyStream.ts | 21 ++++++ test/integration/reporters.spec.ts | 93 +++++++++++++++++++++------ test/utils.ts | 91 ++++++++++++++++---------- 8 files changed, 242 insertions(+), 58 deletions(-) create mode 100644 src/reporters/builtin/pretty.ts create mode 100644 src/reporters/builtin/progress-bar.ts create mode 100644 src/reporters/builtin/progress.ts create mode 100644 src/reporters/proxyStream.ts diff --git a/package-lock.json b/package-lock.json index 98bd0e04..97f63bff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,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", @@ -392,6 +393,19 @@ "reflect-metadata": "0.2.2" } }, + "node_modules/@cucumber/pretty-formatter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/pretty-formatter/-/pretty-formatter-3.0.0.tgz", + "integrity": "sha512-Whw5ZpdSJGgmMvK1wOvhOZaomv5sFNEUvEQlqP0XOE1E4h5Hd/omXgdu/sK5DVBm/MLwO9A7wLfK86f1BvRCEA==", + "license": "MIT", + "dependencies": { + "@cucumber/query": "15.0.1", + "luxon": "^3.7.2" + }, + "peerDependencies": { + "@cucumber/messages": "*" + } + }, "node_modules/@cucumber/query": { "version": "15.0.1", "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-15.0.1.tgz", @@ -2565,9 +2579,9 @@ "license": "MIT" }, "node_modules/luxon": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", - "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", "license": "MIT", "engines": { "node": ">=12" diff --git a/package.json b/package.json index db856299..cc98cd72 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/reporters/builtin/pretty.ts b/src/reporters/builtin/pretty.ts new file mode 100644 index 00000000..617f9431 --- /dev/null +++ b/src/reporters/builtin/pretty.ts @@ -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): AsyncGenerator { + const buffer: Array = [] + 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 + } + } + } +} diff --git a/src/reporters/builtin/progress-bar.ts b/src/reporters/builtin/progress-bar.ts new file mode 100644 index 00000000..4ced8db1 --- /dev/null +++ b/src/reporters/builtin/progress-bar.ts @@ -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): AsyncGenerator { + const buffer: Array = [] + 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 + } + } + } +} diff --git a/src/reporters/builtin/progress.ts b/src/reporters/builtin/progress.ts new file mode 100644 index 00000000..7b03f216 --- /dev/null +++ b/src/reporters/builtin/progress.ts @@ -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): AsyncGenerator { + const buffer: Array = [] + 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 + } + } + } +} diff --git a/src/reporters/proxyStream.ts b/src/reporters/proxyStream.ts new file mode 100644 index 00000000..bacbc599 --- /dev/null +++ b/src/reporters/proxyStream.ts @@ -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) + }, + }) +} diff --git a/test/integration/reporters.spec.ts b/test/integration/reporters.spec.ts index bcbba095..1b9f064a 100644 --- a/test/integration/reporters.spec.ts +++ b/test/integration/reporters.spec.ts @@ -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( @@ -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() diff --git a/test/utils.ts b/test/utils.ts index cf819f5b..529a4d6f 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -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) {} @@ -40,40 +41,60 @@ class TestHarness { async run( reporter: string | Query = 'spec', ...extraArgs: string[] - ): Promise { + ): Promise { 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((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] } }