Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add formatter plugins and start reimplementing builtins #2400

Merged
merged 5 commits into from
May 4, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion exports/root/report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export class Formatter {

// @public (undocumented)
export const FormatterBuilder: {
build(type: string, options: IBuildOptions): Promise<Formatter>;
build(FormatterConstructor: string | typeof Formatter, options: IBuildOptions): Promise<Formatter>;
getConstructorByType(type: string, cwd: string): Promise<typeof Formatter>;
getStepDefinitionSnippetBuilder({ cwd, snippetInterface, snippetSyntax, supportCodeLibrary, }: IGetStepDefinitionSnippetBuilderOptions): Promise<StepDefinitionSnippetBuilder>;
loadCustomClass(type: 'formatter' | 'syntax', descriptor: string, cwd: string): Promise<any>;
Expand Down
106 changes: 51 additions & 55 deletions src/api/formatters.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -21,6 +21,7 @@ export async function initializeFormatters({
eventDataCollector,
configuration,
supportCodeLibrary,
pluginManager,
}: {
env: NodeJS.ProcessEnv
cwd: string
Expand All @@ -32,69 +33,64 @@ export async function initializeFormatters({
eventDataCollector: EventDataCollector
configuration: IRunOptionsFormats
supportCodeLibrary: SupportCodeLibrary
pluginManager: PluginManager
}): Promise<() => Promise<void>> {
const cleanupFns: Array<() => Promise<void>> = []

async function initializeFormatter(
stream: IFormatterStream,
target: string,
type: string
): Promise<Formatter> {
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<any>(stream.end.bind(stream)),
supportCodeLibrary,
}
if (type === 'progress-bar' && !(stream as TtyWriteStream).isTTY) {
specifier: string
): Promise<void> {
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<any>(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<any>(stream.end.bind(stream)))
}
}
return await FormatterBuilder.build(type, typeOptions)
}

const formatters: Formatter[] = []

formatters.push(
await initializeFormatter(stdout, 'stdout', configuration.stdout)
)

const streamPromises: Promise<void>[] = []

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()))
}
}
6 changes: 3 additions & 3 deletions src/api/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export async function initializeForLoadSources(
): Promise<PluginManager> {
// eventually we'll load plugin packages here
const pluginManager = new PluginManager()
await pluginManager.init(
await pluginManager.initCoordinator(
'loadSources',
filterPlugin,
coordinates,
Expand All @@ -37,14 +37,14 @@ export async function initializeForRunCucumber(
): Promise<PluginManager> {
// 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,
Expand Down
3 changes: 2 additions & 1 deletion src/api/run_cucumber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export async function runCucumber(
eventDataCollector,
configuration: options.formats,
supportCodeLibrary,
pluginManager,
})
await emitMetaMessage(eventBroadcaster, env)

Expand Down Expand Up @@ -150,8 +151,8 @@ export async function runCucumber(
options: options.runtime,
})
const success = await runtime.start()
await cleanupFormatters()
await pluginManager.cleanup()
await cleanupFormatters()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleaning up formatter streams should happen after formatter plugins have had a chance to do their own cleaning up.


return {
success: success && !formatterStreamError,
Expand Down
8 changes: 6 additions & 2 deletions src/configuration/argv_parser.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -81,7 +81,11 @@ const ArgvParser = {
.option(
'-f, --format <TYPE[:PATH]>',
'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(
Expand Down
15 changes: 10 additions & 5 deletions src/formatter/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,16 @@ export interface IBuildOptions {
}

const FormatterBuilder = {
async build(type: string, options: IBuildOptions): Promise<Formatter> {
const FormatterConstructor = await FormatterBuilder.getConstructorByType(
type,
options.cwd
)
async build(
FormatterConstructor: string | typeof Formatter,
options: IBuildOptions
): Promise<Formatter> {
if (typeof FormatterConstructor === 'string') {
FormatterConstructor = await FormatterBuilder.getConstructorByType(
FormatterConstructor,
options.cwd
)
}
const colorFns = getColorFns(
options.stream,
options.env,
Expand Down
25 changes: 25 additions & 0 deletions src/formatter/builtin/html.ts
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions src/formatter/builtin/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, FormatterImplementation> = {
// 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
9 changes: 9 additions & 0 deletions src/formatter/builtin/message.ts
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions src/formatter/create_stream.ts
Original file line number Diff line number Diff line change
@@ -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<Writable> {
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
}
13 changes: 0 additions & 13 deletions src/formatter/helpers/formatters.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
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'
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<string, typeof Formatter> {
return {
json: JsonFormatter,
message: MessageFormatter,
html: HtmlFormatter,
progress: ProgressFormatter,
'progress-bar': ProgressBarFormatter,
rerun: RerunFormatter,
Expand All @@ -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