From b02a90a36e7ee73488bbe1f78245e3045620abd7 Mon Sep 17 00:00:00 2001 From: davidjgoss Date: Sat, 4 May 2024 07:22:38 +0100 Subject: [PATCH] Add formatter plugins and start reimplementing builtins (#2400) --- exports/root/report.api.md | 2 +- features/custom_formatter.feature | 21 ++++ src/api/formatters.ts | 106 +++++++++--------- src/api/plugins.ts | 6 +- src/api/run_cucumber.ts | 3 +- src/configuration/argv_parser.ts | 8 +- src/formatter/builder.ts | 29 +++-- src/formatter/builder_spec.ts | 6 +- src/formatter/builtin/html.ts | 25 +++++ src/formatter/builtin/index.ts | 30 +++++ src/formatter/builtin/message.ts | 9 ++ src/formatter/create_stream.ts | 31 +++++ src/formatter/find_class_or_plugin.ts | 22 ++++ .../fixtures/{esm.mjs => legacy_esm.mjs} | 0 ...ult.cjs => legacy_exports_dot_default.cjs} | 0 ...orts.cjs => legacy_module_dot_exports.cjs} | 0 src/formatter/fixtures/plugin_esm.mjs | 4 + .../fixtures/plugin_exports_dot_default.cjs | 4 + .../fixtures/plugin_module_dot_exports.cjs | 4 + src/formatter/helpers/formatters.ts | 13 --- src/formatter/html_formatter.ts | 31 ----- src/formatter/import_code.ts | 18 +++ src/formatter/index.ts | 2 + src/formatter/message_formatter.ts | 12 -- src/formatter/resolve_implementation.ts | 20 ++++ src/formatter/resolve_implementation_spec.ts | 60 ++++++++++ src/plugin/plugin_manager.ts | 20 +++- src/plugin/plugin_manager_spec.ts | 16 +-- src/plugin/types.ts | 16 +++ 29 files changed, 372 insertions(+), 146 deletions(-) create mode 100644 src/formatter/builtin/html.ts create mode 100644 src/formatter/builtin/index.ts create mode 100644 src/formatter/builtin/message.ts create mode 100644 src/formatter/create_stream.ts create mode 100644 src/formatter/find_class_or_plugin.ts rename src/formatter/fixtures/{esm.mjs => legacy_esm.mjs} (100%) rename src/formatter/fixtures/{exports_dot_default.cjs => legacy_exports_dot_default.cjs} (100%) rename src/formatter/fixtures/{module_dot_exports.cjs => legacy_module_dot_exports.cjs} (100%) create mode 100644 src/formatter/fixtures/plugin_esm.mjs create mode 100644 src/formatter/fixtures/plugin_exports_dot_default.cjs create mode 100644 src/formatter/fixtures/plugin_module_dot_exports.cjs delete mode 100644 src/formatter/html_formatter.ts create mode 100644 src/formatter/import_code.ts delete mode 100644 src/formatter/message_formatter.ts create mode 100644 src/formatter/resolve_implementation.ts create mode 100644 src/formatter/resolve_implementation_spec.ts diff --git a/exports/root/report.api.md b/exports/root/report.api.md index 23ba283ea..71ec60da8 100644 --- a/exports/root/report.api.md +++ b/exports/root/report.api.md @@ -132,7 +132,7 @@ export class Formatter { // @public (undocumented) export const FormatterBuilder: { - build(type: string, options: IBuildOptions): Promise; + build(FormatterConstructor: string | typeof Formatter, options: IBuildOptions): Promise; getConstructorByType(type: string, cwd: string): Promise; getStepDefinitionSnippetBuilder({ cwd, snippetInterface, snippetSyntax, supportCodeLibrary, }: IGetStepDefinitionSnippetBuilderOptions): Promise; loadCustomClass(type: 'formatter' | 'syntax', descriptor: string, cwd: string): Promise; diff --git a/features/custom_formatter.feature b/features/custom_formatter.feature index 8acb1959f..9666d6b5f 100644 --- a/features/custom_formatter.feature +++ b/features/custom_formatter.feature @@ -120,6 +120,27 @@ Feature: custom formatter """ + Scenario: formatter plugins + Given a file named "simple_formatter.js" with: + """ + module.exports = { + type: 'formatter', + formatter({ on, write }) { + on('message', (message) => { + if (message.testRunFinished) { + write('Test run finished!') + } + }) + } + } + """ + When I run cucumber-js with `--format ./simple_formatter.js` + Then it fails + And it outputs the text: + """ + Test run finished! + """ + Scenario Outline: supported module formats Given a file named "features/step_definitions/cucumber_steps.js" with: """ diff --git a/src/api/formatters.ts b/src/api/formatters.ts index 432a6f700..7bc2582ff 100644 --- a/src/api/formatters.ts +++ b/src/api/formatters.ts @@ -1,14 +1,14 @@ import { EventEmitter } from 'node:events' import { promisify } from 'node:util' import { WriteStream as TtyWriteStream } from 'node:tty' -import path from 'node:path' -import fs from 'mz/fs' -import { mkdirp } from 'mkdirp' -import Formatter, { IFormatterStream } from '../formatter' +import { IFormatterStream } from '../formatter' import { EventDataCollector } from '../formatter/helpers' import { SupportCodeLibrary } from '../support_code_library_builder/types' import FormatterBuilder from '../formatter/builder' import { ILogger } from '../logger' +import { createStream } from '../formatter/create_stream' +import { resolveImplementation } from '../formatter/resolve_implementation' +import { PluginManager } from '../plugin' import { IRunOptionsFormats } from './types' export async function initializeFormatters({ @@ -21,6 +21,7 @@ export async function initializeFormatters({ eventDataCollector, configuration, supportCodeLibrary, + pluginManager, }: { env: NodeJS.ProcessEnv cwd: string @@ -32,69 +33,64 @@ export async function initializeFormatters({ eventDataCollector: EventDataCollector configuration: IRunOptionsFormats supportCodeLibrary: SupportCodeLibrary + pluginManager: PluginManager }): Promise<() => Promise> { + const cleanupFns: Array<() => Promise> = [] + async function initializeFormatter( stream: IFormatterStream, target: string, - type: string - ): Promise { - stream.on('error', (error: Error) => { - logger.error(error.message) - onStreamError() - }) - const typeOptions = { - env, - cwd, - eventBroadcaster, - eventDataCollector, - log: stream.write.bind(stream), - parsedArgvOptions: configuration.options, - stream, - cleanup: - stream === stdout - ? async () => await Promise.resolve() - : promisify(stream.end.bind(stream)), - supportCodeLibrary, - } - if (type === 'progress-bar' && !(stream as TtyWriteStream).isTTY) { + specifier: string + ): Promise { + if (specifier === 'progress-bar' && !(stream as TtyWriteStream).isTTY) { logger.warn( `Cannot use 'progress-bar' formatter for output to '${target}' as not a TTY. Switching to 'progress' formatter.` ) - type = 'progress' + specifier = 'progress' + } + const implementation = await resolveImplementation(specifier, cwd) + if (typeof implementation === 'function') { + const typeOptions = { + env, + cwd, + eventBroadcaster, + eventDataCollector, + log: stream.write.bind(stream), + parsedArgvOptions: configuration.options, + stream, + cleanup: + stream === stdout + ? async () => await Promise.resolve() + : promisify(stream.end.bind(stream)), + supportCodeLibrary, + } + const formatter = await FormatterBuilder.build( + implementation, + typeOptions + ) + cleanupFns.push(async () => formatter.finished()) + } else { + await pluginManager.initFormatter( + implementation, + configuration.options, + stream.write.bind(stream) + ) + if (stream !== stdout) { + cleanupFns.push(promisify(stream.end.bind(stream))) + } } - return await FormatterBuilder.build(type, typeOptions) } - const formatters: Formatter[] = [] - - formatters.push( - await initializeFormatter(stdout, 'stdout', configuration.stdout) - ) - - const streamPromises: Promise[] = [] - - Object.entries(configuration.files).forEach(([target, type]) => { - streamPromises.push( - (async (target, type) => { - const absoluteTarget = path.resolve(cwd, target) - - try { - await mkdirp(path.dirname(absoluteTarget)) - } catch (error) { - logger.warn('Failed to ensure directory for formatter target exists') - } - - const stream: IFormatterStream = fs.createWriteStream(null, { - fd: await fs.open(absoluteTarget, 'w'), - }) - formatters.push(await initializeFormatter(stream, target, type)) - })(target, type) + await initializeFormatter(stdout, 'stdout', configuration.stdout) + for (const [target, specifier] of Object.entries(configuration.files)) { + await initializeFormatter( + await createStream(target, onStreamError, cwd, logger), + target, + specifier ) - }) - - await Promise.all(streamPromises) + } return async function () { - await Promise.all(formatters.map(async (f) => await f.finished())) + await Promise.all(cleanupFns.map((cleanupFn) => cleanupFn())) } } diff --git a/src/api/plugins.ts b/src/api/plugins.ts index 2b45deb27..3a6ecaa4a 100644 --- a/src/api/plugins.ts +++ b/src/api/plugins.ts @@ -15,7 +15,7 @@ export async function initializeForLoadSources( ): Promise { // eventually we'll load plugin packages here const pluginManager = new PluginManager() - await pluginManager.init( + await pluginManager.initCoordinator( 'loadSources', filterPlugin, coordinates, @@ -37,14 +37,14 @@ export async function initializeForRunCucumber( ): Promise { // eventually we'll load plugin packages here const pluginManager = new PluginManager() - await pluginManager.init( + await pluginManager.initCoordinator( 'runCucumber', publishPlugin, configuration.formats.publish, logger, environment ) - await pluginManager.init( + await pluginManager.initCoordinator( 'runCucumber', filterPlugin, configuration.sources, diff --git a/src/api/run_cucumber.ts b/src/api/run_cucumber.ts index 138777c83..599f49c65 100644 --- a/src/api/run_cucumber.ts +++ b/src/api/run_cucumber.ts @@ -95,6 +95,7 @@ export async function runCucumber( eventDataCollector, configuration: options.formats, supportCodeLibrary, + pluginManager, }) await emitMetaMessage(eventBroadcaster, env) @@ -150,8 +151,8 @@ export async function runCucumber( options: options.runtime, }) const success = await runtime.start() - await cleanupFormatters() await pluginManager.cleanup() + await cleanupFormatters() return { success: success && !formatterStreamError, diff --git a/src/configuration/argv_parser.ts b/src/configuration/argv_parser.ts index f7405a460..d4eea51ab 100644 --- a/src/configuration/argv_parser.ts +++ b/src/configuration/argv_parser.ts @@ -1,8 +1,8 @@ import { Command } from 'commander' import merge from 'lodash.merge' import { dialects } from '@cucumber/gherkin' -import Formatters from '../formatter/helpers/formatters' import { version } from '../version' +import builtin from '../formatter/builtin' import { IConfiguration } from './types' export interface IParsedArgvOptions { @@ -81,7 +81,11 @@ const ArgvParser = { .option( '-f, --format ', 'specify the output format, optionally supply PATH to redirect formatter output (repeatable). Available formats:\n' + - Formatters.buildFormattersDocumentationString(), + Object.entries(builtin).reduce( + (previous, [key, formatter]) => + previous + ` ${key}: ${formatter.documentation}\n`, + '' + ), ArgvParser.collect ) .option( diff --git a/src/formatter/builder.ts b/src/formatter/builder.ts index 7c7f2f8b0..5ee857101 100644 --- a/src/formatter/builder.ts +++ b/src/formatter/builder.ts @@ -1,7 +1,5 @@ -import path from 'node:path' import { EventEmitter } from 'node:events' import { Writable as WritableStream } from 'node:stream' -import { pathToFileURL } from 'node:url' import { doesHaveValue, doesNotHaveValue } from '../value_checker' import { SupportCodeLibrary } from '../support_code_library_builder/types' import { SnippetInterface } from './step_definition_snippet_builder/snippet_syntax' @@ -10,6 +8,7 @@ import StepDefinitionSnippetBuilder from './step_definition_snippet_builder' import JavascriptSnippetSyntax from './step_definition_snippet_builder/javascript_snippet_syntax' import getColorFns from './get_color_fns' import Formatters from './helpers/formatters' +import { importCode } from './import_code' import Formatter, { FormatOptions, IFormatterCleanupFn, @@ -36,11 +35,16 @@ export interface IBuildOptions { } const FormatterBuilder = { - async build(type: string, options: IBuildOptions): Promise { - const FormatterConstructor = await FormatterBuilder.getConstructorByType( - type, - options.cwd - ) + async build( + FormatterConstructor: string | typeof Formatter, + options: IBuildOptions + ): Promise { + if (typeof FormatterConstructor === 'string') { + FormatterConstructor = await FormatterBuilder.getConstructorByType( + FormatterConstructor, + options.cwd + ) + } const colorFns = getColorFns( options.stream, options.env, @@ -100,14 +104,9 @@ const FormatterBuilder = { descriptor: string, cwd: string ) { - let normalized: URL | string = descriptor - if (descriptor.startsWith('.')) { - normalized = pathToFileURL(path.resolve(cwd, descriptor)) - } else if (descriptor.startsWith('file://')) { - normalized = new URL(descriptor) - } - let CustomClass = await FormatterBuilder.loadFile(normalized) - CustomClass = FormatterBuilder.resolveConstructor(CustomClass) + const CustomClass = FormatterBuilder.resolveConstructor( + await importCode(descriptor, cwd) + ) if (doesHaveValue(CustomClass)) { return CustomClass } else { diff --git a/src/formatter/builder_spec.ts b/src/formatter/builder_spec.ts index d4878ea84..3bbac9978 100644 --- a/src/formatter/builder_spec.ts +++ b/src/formatter/builder_spec.ts @@ -5,9 +5,9 @@ import FormatterBuilder from './builder' describe('custom class loading', () => { const varieties = [ - 'esm.mjs', - 'exports_dot_default.cjs', - 'module_dot_exports.cjs', + 'legacy_esm.mjs', + 'legacy_exports_dot_default.cjs', + 'legacy_module_dot_exports.cjs', ] varieties.forEach((filename) => { describe(filename, () => { diff --git a/src/formatter/builtin/html.ts b/src/formatter/builtin/html.ts new file mode 100644 index 000000000..42f808fdd --- /dev/null +++ b/src/formatter/builtin/html.ts @@ -0,0 +1,25 @@ +import { promisify } from 'node:util' +import { finished } from 'node:stream' +import CucumberHtmlStream from '@cucumber/html-formatter' +import resolvePkg from 'resolve-pkg' +import { FormatterPlugin } from '../../plugin' + +export default { + type: 'formatter', + formatter({ on, write }) { + const htmlStream = new CucumberHtmlStream( + resolvePkg('@cucumber/html-formatter', { cwd: __dirname }) + + '/dist/main.css', + resolvePkg('@cucumber/html-formatter', { cwd: __dirname }) + + '/dist/main.js' + ) + on('message', (message) => htmlStream.write(message)) + htmlStream.on('data', (chunk) => write(chunk)) + + return async () => { + htmlStream.end() + await promisify(finished)(htmlStream) + } + }, + documentation: 'Outputs a HTML report', +} satisfies FormatterPlugin diff --git a/src/formatter/builtin/index.ts b/src/formatter/builtin/index.ts new file mode 100644 index 000000000..ead63559f --- /dev/null +++ b/src/formatter/builtin/index.ts @@ -0,0 +1,30 @@ +import { FormatterImplementation } from '../index' +import JsonFormatter from '../json_formatter' +import ProgressFormatter from '../progress_formatter' +import ProgressBarFormatter from '../progress_bar_formatter' +import RerunFormatter from '../rerun_formatter' +import SnippetsFormatter from '../snippets_formatter' +import SummaryFormatter from '../summary_formatter' +import UsageFormatter from '../usage_formatter' +import UsageJsonFormatter from '../usage_json_formatter' +import JunitFormatter from '../junit_formatter' +import messageFormatter from './message' +import htmlFormatter from './html' + +const builtin: Record = { + // new plugin-based formatters + html: htmlFormatter, + message: messageFormatter, + // legacy class-based formatters + json: JsonFormatter, + progress: ProgressFormatter, + 'progress-bar': ProgressBarFormatter, + rerun: RerunFormatter, + snippets: SnippetsFormatter, + summary: SummaryFormatter, + usage: UsageFormatter, + 'usage-json': UsageJsonFormatter, + junit: JunitFormatter, +} + +export default builtin diff --git a/src/formatter/builtin/message.ts b/src/formatter/builtin/message.ts new file mode 100644 index 000000000..398136c55 --- /dev/null +++ b/src/formatter/builtin/message.ts @@ -0,0 +1,9 @@ +import { FormatterPlugin } from '../../plugin' + +export default { + type: 'formatter', + formatter({ on, write }) { + on('message', (message) => write(JSON.stringify(message) + '\n')) + }, + documentation: 'Emits Cucumber messages in NDJSON format', +} satisfies FormatterPlugin diff --git a/src/formatter/create_stream.ts b/src/formatter/create_stream.ts new file mode 100644 index 000000000..cd9a24ffd --- /dev/null +++ b/src/formatter/create_stream.ts @@ -0,0 +1,31 @@ +import path from 'node:path' +import { Writable } from 'node:stream' +import { mkdirp } from 'mkdirp' +import fs from 'mz/fs' +import { ILogger } from '../logger' + +export async function createStream( + target: string, + onStreamError: () => void, + cwd: string, + logger: ILogger +): Promise { + const absoluteTarget = path.resolve(cwd, target) + + try { + await mkdirp(path.dirname(absoluteTarget)) + } catch (error) { + logger.warn('Failed to ensure directory for formatter target exists') + } + + const stream: Writable = fs.createWriteStream(null, { + fd: await fs.open(absoluteTarget, 'w'), + }) + + stream.on('error', (error: Error) => { + logger.error(error.message) + onStreamError() + }) + + return stream +} diff --git a/src/formatter/find_class_or_plugin.ts b/src/formatter/find_class_or_plugin.ts new file mode 100644 index 000000000..3d3dd1abb --- /dev/null +++ b/src/formatter/find_class_or_plugin.ts @@ -0,0 +1,22 @@ +import { doesNotHaveValue } from '../value_checker' + +export function findClassOrPlugin(imported: any) { + return findRecursive(imported, 3) +} + +function findRecursive(thing: any, depth: number): any { + if (doesNotHaveValue(thing)) { + return null + } + if (typeof thing === 'function') { + return thing + } + if (typeof thing === 'object' && thing.type === 'formatter') { + return thing + } + depth-- + if (depth > 0) { + return findRecursive(thing.default, depth) + } + return null +} diff --git a/src/formatter/fixtures/esm.mjs b/src/formatter/fixtures/legacy_esm.mjs similarity index 100% rename from src/formatter/fixtures/esm.mjs rename to src/formatter/fixtures/legacy_esm.mjs diff --git a/src/formatter/fixtures/exports_dot_default.cjs b/src/formatter/fixtures/legacy_exports_dot_default.cjs similarity index 100% rename from src/formatter/fixtures/exports_dot_default.cjs rename to src/formatter/fixtures/legacy_exports_dot_default.cjs diff --git a/src/formatter/fixtures/module_dot_exports.cjs b/src/formatter/fixtures/legacy_module_dot_exports.cjs similarity index 100% rename from src/formatter/fixtures/module_dot_exports.cjs rename to src/formatter/fixtures/legacy_module_dot_exports.cjs diff --git a/src/formatter/fixtures/plugin_esm.mjs b/src/formatter/fixtures/plugin_esm.mjs new file mode 100644 index 000000000..1f011f8b0 --- /dev/null +++ b/src/formatter/fixtures/plugin_esm.mjs @@ -0,0 +1,4 @@ +export default { + type: 'formatter', + formatter() {}, +} diff --git a/src/formatter/fixtures/plugin_exports_dot_default.cjs b/src/formatter/fixtures/plugin_exports_dot_default.cjs new file mode 100644 index 000000000..80821ce32 --- /dev/null +++ b/src/formatter/fixtures/plugin_exports_dot_default.cjs @@ -0,0 +1,4 @@ +exports.default = { + type: 'formatter', + formatter() {}, +} diff --git a/src/formatter/fixtures/plugin_module_dot_exports.cjs b/src/formatter/fixtures/plugin_module_dot_exports.cjs new file mode 100644 index 000000000..47584fab3 --- /dev/null +++ b/src/formatter/fixtures/plugin_module_dot_exports.cjs @@ -0,0 +1,4 @@ +module.exports = { + type: 'formatter', + formatter() {}, +} diff --git a/src/formatter/helpers/formatters.ts b/src/formatter/helpers/formatters.ts index 90f26f63d..dde4b999a 100644 --- a/src/formatter/helpers/formatters.ts +++ b/src/formatter/helpers/formatters.ts @@ -1,6 +1,5 @@ import Formatter from '../.' import JsonFormatter from '../json_formatter' -import MessageFormatter from '../message_formatter' import ProgressBarFormatter from '../progress_bar_formatter' import ProgressFormatter from '../progress_formatter' import RerunFormatter from '../rerun_formatter' @@ -8,15 +7,12 @@ import SnippetsFormatter from '../snippets_formatter' import SummaryFormatter from '../summary_formatter' import UsageFormatter from '../usage_formatter' import UsageJsonFormatter from '../usage_json_formatter' -import HtmlFormatter from '../html_formatter' import JunitFormatter from '../junit_formatter' const Formatters = { getFormatters(): Record { return { json: JsonFormatter, - message: MessageFormatter, - html: HtmlFormatter, progress: ProgressFormatter, 'progress-bar': ProgressBarFormatter, rerun: RerunFormatter, @@ -27,15 +23,6 @@ const Formatters = { junit: JunitFormatter, } }, - buildFormattersDocumentationString(): string { - let concatenatedFormattersDocumentation: string = '' - const formatters = this.getFormatters() - for (const formatterName in formatters) { - concatenatedFormattersDocumentation += ` ${formatterName}: ${formatters[formatterName].documentation}\n` - } - - return concatenatedFormattersDocumentation - }, } export default Formatters diff --git a/src/formatter/html_formatter.ts b/src/formatter/html_formatter.ts deleted file mode 100644 index edd697d36..000000000 --- a/src/formatter/html_formatter.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { finished } from 'node:stream' -import { promisify } from 'node:util' -import * as messages from '@cucumber/messages' -import resolvePkg from 'resolve-pkg' -import CucumberHtmlStream from '@cucumber/html-formatter' -import Formatter, { IFormatterOptions } from '.' - -export default class HtmlFormatter extends Formatter { - private readonly _htmlStream: CucumberHtmlStream - public static readonly documentation: string = 'Outputs HTML report' - - constructor(options: IFormatterOptions) { - super(options) - this._htmlStream = new CucumberHtmlStream( - resolvePkg('@cucumber/html-formatter', { cwd: __dirname }) + - '/dist/main.css', - resolvePkg('@cucumber/html-formatter', { cwd: __dirname }) + - '/dist/main.js' - ) - options.eventBroadcaster.on('envelope', (envelope: messages.Envelope) => { - this._htmlStream.write(envelope) - }) - this._htmlStream.on('data', (chunk) => this.log(chunk)) - } - - async finished(): Promise { - this._htmlStream.end() - await promisify(finished)(this._htmlStream) - await super.finished() - } -} diff --git a/src/formatter/import_code.ts b/src/formatter/import_code.ts new file mode 100644 index 000000000..133446fc8 --- /dev/null +++ b/src/formatter/import_code.ts @@ -0,0 +1,18 @@ +import { pathToFileURL } from 'node:url' +import path from 'node:path' + +export async function importCode(specifier: string, cwd: string): Promise { + try { + let normalized: URL | string = specifier + if (specifier.startsWith('.')) { + normalized = pathToFileURL(path.resolve(cwd, specifier)) + } else if (specifier.startsWith('file://')) { + normalized = new URL(specifier) + } + return await import(normalized.toString()) + } catch (e) { + throw new Error(`Failed to import formatter ${specifier}`, { + cause: e, + }) + } +} diff --git a/src/formatter/index.ts b/src/formatter/index.ts index 40ae1bd0c..90b299a29 100644 --- a/src/formatter/index.ts +++ b/src/formatter/index.ts @@ -2,6 +2,7 @@ import { Writable } from 'node:stream' import { EventEmitter } from 'node:events' import { SupportCodeLibrary } from '../support_code_library_builder/types' import { valueOrDefault } from '../value_checker' +import { FormatterPlugin } from '../plugin' import { IColorFns } from './get_color_fns' import { EventDataCollector } from './helpers' import StepDefinitionSnippetBuilder from './step_definition_snippet_builder' @@ -20,6 +21,7 @@ export interface FormatOptions { [customKey: string]: any } +export type FormatterImplementation = typeof Formatter | FormatterPlugin export type IFormatterStream = Writable export type IFormatterLogFn = (buffer: string | Uint8Array) => void export type IFormatterCleanupFn = () => Promise diff --git a/src/formatter/message_formatter.ts b/src/formatter/message_formatter.ts deleted file mode 100644 index 1390261e1..000000000 --- a/src/formatter/message_formatter.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as messages from '@cucumber/messages' -import Formatter, { IFormatterOptions } from '.' - -export default class MessageFormatter extends Formatter { - public static readonly documentation: string = 'Outputs protobuf messages' - constructor(options: IFormatterOptions) { - super(options) - options.eventBroadcaster.on('envelope', (envelope: messages.Envelope) => - this.log(JSON.stringify(envelope) + '\n') - ) - } -} diff --git a/src/formatter/resolve_implementation.ts b/src/formatter/resolve_implementation.ts new file mode 100644 index 000000000..9faa48653 --- /dev/null +++ b/src/formatter/resolve_implementation.ts @@ -0,0 +1,20 @@ +import builtin from './builtin' +import { importCode } from './import_code' +import { findClassOrPlugin } from './find_class_or_plugin' +import { FormatterImplementation } from './index' + +export async function resolveImplementation( + specifier: string, + cwd: string +): Promise { + if (builtin[specifier]) { + return builtin[specifier] + } else { + const imported = await importCode(specifier, cwd) + const found = findClassOrPlugin(imported) + if (!found) { + throw new Error(`${specifier} does not export a function/class`) + } + return found + } +} diff --git a/src/formatter/resolve_implementation_spec.ts b/src/formatter/resolve_implementation_spec.ts new file mode 100644 index 000000000..66ace5253 --- /dev/null +++ b/src/formatter/resolve_implementation_spec.ts @@ -0,0 +1,60 @@ +import { pathToFileURL } from 'node:url' +import path from 'node:path' +import { expect } from 'chai' +import { resolveImplementation } from './resolve_implementation' + +describe('resolveImplementation', () => { + const varieties = [ + 'esm.mjs', + 'exports_dot_default.cjs', + 'module_dot_exports.cjs', + ] + + describe('legacy classes', () => { + varieties.forEach((filename) => { + describe(filename, () => { + it('should handle a relative path', async () => { + const CustomClass = await resolveImplementation( + `./fixtures/legacy_${filename}`, + __dirname + ) + + expect(typeof CustomClass).to.eq('function') + }) + + it('should handle a file:// url', async () => { + const fileUrl = pathToFileURL( + path.resolve(__dirname, `./fixtures/legacy_${filename}`) + ).toString() + const CustomClass = await resolveImplementation(fileUrl, __dirname) + + expect(typeof CustomClass).to.eq('function') + }) + }) + }) + }) + + describe('plugins', () => { + varieties.forEach((filename) => { + describe(filename, () => { + it('should handle a relative path', async () => { + const plugin = await resolveImplementation( + `./fixtures/plugin_${filename}`, + __dirname + ) + + expect(typeof plugin).to.eq('object') + }) + + it('should handle a file:// url', async () => { + const fileUrl = pathToFileURL( + path.resolve(__dirname, `./fixtures/plugin_${filename}`) + ).toString() + const plugin = await resolveImplementation(fileUrl, __dirname) + + expect(typeof plugin).to.eq('object') + }) + }) + }) + }) +}) diff --git a/src/plugin/plugin_manager.ts b/src/plugin/plugin_manager.ts index c58268ed0..d9bb9ddff 100644 --- a/src/plugin/plugin_manager.ts +++ b/src/plugin/plugin_manager.ts @@ -8,6 +8,7 @@ import { CoordinatorPluginEventKey, CoordinatorPluginTransformEventKey, Operation, + FormatterPlugin, } from './types' type HandlerRegistry = { @@ -27,10 +28,25 @@ export class PluginManager { event: K, handler: CoordinatorPluginEventHandler ) { - this.handlers[event].push(handler) + this.handlers[event]?.push(handler) } - async init( + async initFormatter( + plugin: FormatterPlugin, + options: OptionsType, + write: (buffer: string | Uint8Array) => void + ) { + const cleanupFn = await plugin.formatter({ + on: (key, handler) => this.register(key, handler), + options, + write, + }) + if (typeof cleanupFn === 'function') { + this.cleanupFns.push(cleanupFn) + } + } + + async initCoordinator( operation: Operation, plugin: InternalPlugin, options: OptionsType, diff --git a/src/plugin/plugin_manager_spec.ts b/src/plugin/plugin_manager_spec.ts index cdc603b01..da8fe6791 100644 --- a/src/plugin/plugin_manager_spec.ts +++ b/src/plugin/plugin_manager_spec.ts @@ -19,7 +19,7 @@ describe('PluginManager', () => { it('passes the correct context to the coordinator function', async () => { const pluginManager = new PluginManager() const coordinator = sinon.fake() - await pluginManager.init( + await pluginManager.initCoordinator( 'runCucumber', { type: 'plugin', @@ -42,7 +42,7 @@ describe('PluginManager', () => { const pluginManager = new PluginManager() const cleanup1 = sinon.fake() const cleanup2 = sinon.fake() - await pluginManager.init( + await pluginManager.initCoordinator( 'runCucumber', { type: 'plugin', @@ -52,7 +52,7 @@ describe('PluginManager', () => { logger, environment ) - await pluginManager.init( + await pluginManager.initCoordinator( 'runCucumber', { type: 'plugin', @@ -74,7 +74,7 @@ describe('PluginManager', () => { const pluginManager = new PluginManager() const handler1 = sinon.fake() const handler2 = sinon.fake() - await pluginManager.init( + await pluginManager.initCoordinator( 'runCucumber', { type: 'plugin', @@ -84,7 +84,7 @@ describe('PluginManager', () => { logger, environment ) - await pluginManager.init( + await pluginManager.initCoordinator( 'runCucumber', { type: 'plugin', @@ -131,7 +131,7 @@ describe('PluginManager', () => { it('should apply transforms in the order registered', async () => { const pluginManager = new PluginManager() - await pluginManager.init( + await pluginManager.initCoordinator( 'runCucumber', { type: 'plugin', @@ -146,7 +146,7 @@ describe('PluginManager', () => { logger, environment ) - await pluginManager.init( + await pluginManager.initCoordinator( 'runCucumber', { type: 'plugin', @@ -171,7 +171,7 @@ describe('PluginManager', () => { it('should treat undefined as a noop', async () => { const pluginManager = new PluginManager() - await pluginManager.init( + await pluginManager.initCoordinator( 'runCucumber', { type: 'plugin', diff --git a/src/plugin/types.ts b/src/plugin/types.ts index db255084d..2137aea20 100644 --- a/src/plugin/types.ts +++ b/src/plugin/types.ts @@ -62,3 +62,19 @@ export interface InternalPlugin { type: 'plugin' coordinator: CoordinatorPluginFunction } + +export interface FormatterPluginContext { + on: (key: 'message', handler: (value: Envelope) => void) => void + options: OptionsType + write: (buffer: string | Uint8Array) => void +} + +export type FormatterPluginFunction = ( + context: FormatterPluginContext +) => Promisable + +export interface FormatterPlugin { + type: 'formatter' + formatter: FormatterPluginFunction + documentation: string +}