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

reimplement publish functionality as a plugin #2092

Merged
merged 9 commits into from
Aug 11, 2022
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
61 changes: 61 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
🚨 This functionality is a work in progress and not yet available in a released version of Cucumber. If you have any
feedback about how plugins are going to work, please jump into the comments
on <https://github.com/cucumber/cucumber-js/discussions/2091> 🚨

- - -

# Plugins

You can extend Cucumber's functionality by writing plugins. They allow you to:

- Listen to Cucumber's message stream
- Hook into core behaviour and change things
- Output extra information for the user

The API is described below. Our own Publish functionality is [implemented as a plugin](../src/publish/publish_plugin.ts), which you might find useful as an example.

## Writing a plugin

A plugin in its simplest form is a function. It should be the default export of your plugin package. Here's the signature:

```js
export default async({
on,
logger,
configuration,
environment
}) => {
// do stuff here
}
```

### Lifecycle

Your plugin is initialised early in Cucumber's lifecycle, just after we have resolved the configuration. You can do async setup work in your plugin function - Cucumber will await the promise.

Your plugin is stopped after the test run finishes, just before Cucumber exits. If you need to do cleanup work, your plugin function can return a cleanup function - it will be executed (and awaited if it returns a promise) before Cucumber exits.

A plugin function accepts a single argument which provides the context for your plugin. It has:

- `on(event: string, handler: Function)` - function for registering handlers for events (see below for supported events) - you can call this as many times as you'd like
- `logger` - a console instance that directs output to stderr (or other appropriate stream)
- `configuration` - the final resolved configuration object being used for this execution of Cucumber
- `environment` - details of the environment for this execution of Cucumber

### Events

These are the events for which you can register handlers:

| Name | Signature | Notes |
|-----------|-------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `message` | `(message: Envelope) => void` | Cucumber emits a message for all significant events over the course of a test run. These are most commonly consumed by formatters, but have other uses too. Note that you can do async work in this handler, but Cucumber won't await the promise. |










2 changes: 1 addition & 1 deletion features/publish.feature
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ Feature: Publish reports
@spawn
Scenario: when results are not published due to an error raised by the server, the banner is displayed
When I run cucumber-js with env `CUCUMBER_PUBLISH_TOKEN=keyboardcat`
Then it fails
Then it passes
Copy link
Contributor Author

@davidjgoss davidjgoss Jul 23, 2022

Choose a reason for hiding this comment

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

The only functional change here. For formatters, we catch errors on the stream(s), log them and then fail the test run at the end. This is intended to cope with things like file system snafus etc. Publish is now not a formatter, and at any rate this felt a bit off - failing to publish the report seems like it shouldn't fail the test run? That's debatable, though.

It does raise a couple of questions about error handling though:

  • If there's an uncaught error in a plugin, should we try to fail the test run gracefully-ish?
  • Should there be a way for a plugin to say "The test run should be marked as a failure because XYZ"? Or should it just throw if it wants to do that?

And the error output contains the text:
"""
┌─────────────────────┐
Expand Down
2 changes: 1 addition & 1 deletion src/api/convert_configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
isTruthyString,
OptionSplitter,
} from '../configuration'
import { IPublishConfig } from '../formatter/publish'
import { IPublishConfig } from '../formatter'
import { IRunConfiguration } from './types'

export async function convertConfiguration(
Expand Down
38 changes: 0 additions & 38 deletions src/api/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,12 @@ import { WriteStream as TtyWriteStream } from 'tty'
import FormatterBuilder from '../formatter/builder'
import fs from 'mz/fs'
import path from 'path'
import { DEFAULT_CUCUMBER_PUBLISH_URL } from '../formatter/publish'
import HttpStream from '../formatter/http_stream'
import { Writable } from 'stream'
import { supportsColor } from 'supports-color'
import { IRunOptionsFormats } from './types'
import hasAnsi from 'has-ansi'
import stripAnsi from 'strip-ansi'

export async function initializeFormatters({
env,
cwd,
stdout,
stderr,
logger,
onStreamError,
eventBroadcaster,
Expand Down Expand Up @@ -83,38 +76,7 @@ export async function initializeFormatters({
formatters.push(await initializeFormatter(stream, target, type))
}

if (configuration.publish) {
const { url = DEFAULT_CUCUMBER_PUBLISH_URL, token } = configuration.publish
const headers: { [key: string]: string } = {}
if (token !== undefined) {
headers.Authorization = `Bearer ${token}`
}
const stream = new HttpStream(url, 'GET', headers)
const readerStream = new Writable({
objectMode: true,
write: function (responseBody: string, encoding, writeCallback) {
logger.error(sanitisePublishOutput(responseBody, stderr))
writeCallback()
},
})
stream.pipe(readerStream)
formatters.push(await initializeFormatter(stream, url, 'message'))
}

return async function () {
await Promise.all(formatters.map(async (f) => await f.finished()))
}
}

/*
This is because the Cucumber Reports service returns a pre-formatted console message
including ANSI escapes, so if our stderr stream doesn't support those we need to
strip them back out. Ideally we should get structured data from the service and
compose the console message on this end.
*/
function sanitisePublishOutput(raw: string, stderr: IFormatterStream) {
if (!supportsColor(stderr) && hasAnsi(raw)) {
return stripAnsi(raw)
}
return raw
}
18 changes: 18 additions & 0 deletions src/api/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Plugin, PluginManager } from '../plugin'
import publishPlugin from '../publish'
import { IRunEnvironment, IRunOptions } from './types'

const INTERNAL_PLUGINS: Record<string, Plugin> = {
publish: publishPlugin,
}

export async function initializePlugins(
logger: Console,
configuration: IRunOptions,
environment: IRunEnvironment
): Promise<PluginManager> {
// eventually we'll load plugin packages here
const pluginManager = new PluginManager(Object.values(INTERNAL_PLUGINS))
await pluginManager.init(logger, configuration, environment)
return pluginManager
}
14 changes: 10 additions & 4 deletions src/api/run_cucumber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getSupportCodeLibrary } from './support'
import { Console } from 'console'
import { mergeEnvironment } from './environment'
import { getFilteredPicklesAndErrors } from './gherkin'
import { initializePlugins } from './plugins'

/**
* Execute a Cucumber test run.
Expand All @@ -25,7 +26,7 @@ export async function runCucumber(
onMessage?: (message: Envelope) => void
): Promise<IRunResult> {
const { cwd, stdout, stderr, env } = mergeEnvironment(environment)
const logger = new Console(stdout, stderr)
const logger = new Console(stderr)
Copy link
Contributor Author

@davidjgoss davidjgoss Jul 23, 2022

Choose a reason for hiding this comment

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

This was an oversight when initially added. This logger is generally used for warn and error, but even for log (if used) it should still go to stderr - only formatters get to write stuff to stdout. Important now if we are going to make logger available to plugins.

const newId = IdGenerator.uuid()

const supportCoordinates =
Expand All @@ -47,14 +48,17 @@ export async function runCucumber(
requireModules: supportCoordinates.requireModules,
})

const plugins = await initializePlugins(logger, configuration, environment)

const eventBroadcaster = new EventEmitter()
if (onMessage) {
eventBroadcaster.on('envelope', onMessage)
}
eventBroadcaster.on('envelope', (value) => plugins.emit('message', value))
const eventDataCollector = new EventDataCollector(eventBroadcaster)

let formatterStreamError = false
const cleanup = await initializeFormatters({
const cleanupFormatters = await initializeFormatters({
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Renamed for clarity.

env,
cwd,
stdout,
Expand Down Expand Up @@ -89,7 +93,8 @@ export async function runCucumber(
`Parse error in "${parseError.source.uri}" ${parseError.message}`
)
})
await cleanup()
await cleanupFormatters()
await plugins.cleanup()
return {
success: false,
support: supportCodeLibrary,
Expand All @@ -116,7 +121,8 @@ export async function runCucumber(
options: configuration.runtime,
})
const success = await runtime.start()
await cleanup()
await cleanupFormatters()
await plugins.cleanup()

return {
success: success && !formatterStreamError,
Expand Down
8 changes: 4 additions & 4 deletions src/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ISupportCodeLibrary } from '../support_code_library_builder/types'
import { FormatOptions, IFormatterStream } from '../formatter'
import { FormatOptions, IPublishConfig } from '../formatter'
import { PickleOrder } from '../models/pickle_order'
import { IRuntimeOptions } from '../runtime'
import { IConfiguration } from '../configuration'
import { IPublishConfig } from '../formatter/publish'
import { Writable } from 'stream'

/**
* @public
Expand Down Expand Up @@ -154,11 +154,11 @@ export interface IRunEnvironment {
/**
* Writable stream where the test run's main output is written (defaults to `process.stdout` if omitted).
*/
stdout?: IFormatterStream
stdout?: Writable
/**
* Writable stream where the test run's warning/error output is written (defaults to `process.stderr` if omitted).
*/
stderr?: IFormatterStream
stderr?: Writable
/**
* Environment variables (defaults to `process.env` if omitted).
*/
Expand Down
20 changes: 9 additions & 11 deletions src/formatter/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { IColorFns } from './get_color_fns'
import { EventDataCollector } from './helpers'
import StepDefinitionSnippetBuilder from './step_definition_snippet_builder'
import { PassThrough, Writable as WritableStream } from 'stream'
import { Writable } from 'stream'
import { ISupportCodeLibrary } from '../support_code_library_builder/types'
import { WriteStream as FsWriteStream } from 'fs'
import { WriteStream as TtyWriteStream } from 'tty'
import { EventEmitter } from 'events'
import HttpStream from './http_stream'
import { valueOrDefault } from '../value_checker'
import { SnippetInterface } from './step_definition_snippet_builder/snippet_syntax'

Expand All @@ -23,11 +20,12 @@ export interface FormatOptions {
[customKey: string]: any
}

export type IFormatterStream =
| FsWriteStream
| TtyWriteStream
| PassThrough
| HttpStream
export interface IPublishConfig {
url: string
token: string
}

export type IFormatterStream = Writable
Copy link
Contributor Author

Choose a reason for hiding this comment

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

HttpStream was no longer applicable here but also this didn't feel like a useful type. We need a writable stream, is all.

export type IFormatterLogFn = (buffer: string | Uint8Array) => void
export type IFormatterCleanupFn = () => Promise<any>

Expand All @@ -39,7 +37,7 @@ export interface IFormatterOptions {
log: IFormatterLogFn
parsedArgvOptions: FormatOptions
snippetBuilder: StepDefinitionSnippetBuilder
stream: WritableStream
stream: Writable
cleanup: IFormatterCleanupFn
supportCodeLibrary: ISupportCodeLibrary
}
Expand All @@ -50,7 +48,7 @@ export default class Formatter {
protected eventDataCollector: EventDataCollector
protected log: IFormatterLogFn
protected snippetBuilder: StepDefinitionSnippetBuilder
protected stream: WritableStream
protected stream: Writable
protected supportCodeLibrary: ISupportCodeLibrary
protected printAttachments: boolean
private readonly cleanup: IFormatterCleanupFn
Expand Down
7 changes: 0 additions & 7 deletions src/formatter/publish.ts

This file was deleted.

2 changes: 2 additions & 0 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './types'
export * from './plugin_manager'
48 changes: 48 additions & 0 deletions src/plugin/plugin_manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Plugin, PluginCleanup, PluginEvents } from './types'
import { IRunEnvironment, IRunOptions } from '../api'

type HandlerRegistry = {
[K in keyof PluginEvents]: Array<(value: PluginEvents[K]) => void>
}

export class PluginManager {
private handlers: HandlerRegistry = { message: [] }
private cleanupFns: PluginCleanup[] = []

constructor(private pluginFns: Plugin[]) {}

private async register<K extends keyof PluginEvents>(
event: K,
handler: (value: PluginEvents[K]) => void
) {
this.handlers[event].push(handler)
}

async init(
logger: Console,
configuration: IRunOptions,
environment: IRunEnvironment
) {
for (const pluginFn of this.pluginFns) {
const cleanupFn = await pluginFn({
on: this.register.bind(this),
logger,
configuration,
environment,
})
if (cleanupFn) {
this.cleanupFns.push(cleanupFn)
}
}
}

emit<K extends keyof PluginEvents>(event: K, value: PluginEvents[K]): void {
this.handlers[event].forEach((handler) => handler(value))
}

async cleanup(): Promise<void> {
for (const cleanupFn of this.cleanupFns) {
await cleanupFn()
}
}
}
20 changes: 20 additions & 0 deletions src/plugin/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { IRunEnvironment, IRunOptions } from '../api'
import { Envelope } from '@cucumber/messages'

export interface PluginEvents {
Copy link
Contributor Author

@davidjgoss davidjgoss Jul 23, 2022

Choose a reason for hiding this comment

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

This would expand to cover other events we start to support.

message: Envelope
}

export interface PluginContext {
on: <K extends keyof PluginEvents>(
event: K,
handler: (value: PluginEvents[K]) => void
) => void
logger: Console
configuration: IRunOptions
environment: IRunEnvironment
}

export type PluginCleanup = () => any | void | Promise<any | void>

export type Plugin = (context: PluginContext) => Promise<PluginCleanup | void>
File renamed without changes.
3 changes: 3 additions & 0 deletions src/publish/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { publishPlugin } from './publish_plugin'

export default publishPlugin
Loading