Skip to content

Commit bfb66fa

Browse files
authored
formatters: include html-formatter as a built-in option (#1432)
* add html formatter as dependency * update run-script to use local install * add doc * add as first party formatter in switch * create our new formatter * use in formatters test * changelog, improve doc a bit * add pre script to make sure we're built * add concept of an async `finished` method on formatters * rework so it works * test html formatter roughly via formatters feature test * dont need this on the test path now * whoops missed one * what even is a computer * end stream from within formatter finished method except if stdout * update changelog again * fix botched merge
1 parent 937814b commit bfb66fa

14 files changed

+407
-53
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Please see [CONTRIBUTING.md](https://github.com/cucumber/cucumber/blob/master/CO
1111

1212
### Added
1313

14+
* Add a built in `html` formatter for rich HTML reports output as a standalone page
15+
1416
### Changed
1517

1618
### Deprecated

cucumber.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const FORMATTERS_INCLUDE = [
2828
'--publish-quiet',
2929
]
3030

31-
const formatters = [
31+
const htmlFormatter = [
3232
`node_modules/@cucumber/compatibility-kit/features/{${FORMATTERS_INCLUDE.join(
3333
','
3434
)}}/*.feature`,
@@ -37,12 +37,12 @@ const formatters = [
3737
'--require',
3838
`compatibility/features/{${FORMATTERS_INCLUDE.join(',')}}/*.ts`,
3939
'--format',
40-
'message',
40+
'html:html-formatter.html',
4141
'--publish-quiet',
4242
].join(' ')
4343

4444
module.exports = {
4545
default: feature,
4646
cck,
47-
formatters,
47+
htmlFormatter,
4848
}

docs/cli.md

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ If multiple formats are specified with the same output, only the last is used.
4545

4646
Built-in formatters
4747
* message - prints each [message](https://github.com/cucumber/cucumber/tree/master/cucumber-messages) in NDJSON form, which can then be consumed by other tools.
48+
* html - prints a rich HTML report to a standalone page
4849
* json - prints the feature as JSON. *Note: this formatter is deprecated and will be removed in the next major release. Where you need a structured data representation of your test run, it's best to use the `message` formatter. For legacy tools that depend on the deprecated JSON format, a standalone formatter is available (see https://github.com/cucumber/cucumber/tree/master/json-formatter).
4950
* progress - prints one character per scenario (default).
5051
* progress-bar - prints a progress bar and outputs errors/warnings along the way.

features/formatters.feature

+5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Feature: Formatters
1010
When I run cucumber-js with all formatters and `--tags @a`
1111
Then the "message" formatter output matches the fixture "formatters/rejected-pickle.message.json"
1212
Then the "json" formatter output matches the fixture "formatters/rejected-pickle.json"
13+
Then the html formatter output is complete
1314

1415
Scenario: passed from Scenario
1516
Given a file named "features/a.feature" with:
@@ -27,6 +28,7 @@ Feature: Formatters
2728
When I run cucumber-js with all formatters
2829
Then the "message" formatter output matches the fixture "formatters/passed-scenario.message.json"
2930
Then the "json" formatter output matches the fixture "formatters/passed-scenario.json"
31+
Then the html formatter output is complete
3032

3133
Scenario: passed from Rule
3234
Given a file named "features/a.feature" with:
@@ -45,6 +47,7 @@ Feature: Formatters
4547
When I run cucumber-js with all formatters
4648
Then the "message" formatter output matches the fixture "formatters/passed-rule.message.json"
4749
Then the "json" formatter output matches the fixture "formatters/passed-rule.json"
50+
Then the html formatter output is complete
4851

4952
Scenario: failed
5053
Given a file named "features/a.feature" with:
@@ -62,6 +65,7 @@ Feature: Formatters
6265
When I run cucumber-js with all formatters
6366
Then the "message" formatter output matches the fixture "formatters/failed.message.json"
6467
Then the "json" formatter output matches the fixture "formatters/failed.json"
68+
Then the html formatter output is complete
6569
And it fails
6670

6771
Scenario: retried and passed
@@ -89,3 +93,4 @@ Feature: Formatters
8993
When I run cucumber-js with all formatters and `--retry 1`
9094
Then the "message" formatter output matches the fixture "formatters/retried.message.json"
9195
Then the "json" formatter output matches the fixture "formatters/retried.json"
96+
Then the html formatter output is complete

features/step_definitions/cli_steps.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ When(
5151
args = ''
5252
}
5353
// message is always outputted as part of run
54-
const formats = ['json:json.out']
54+
const formats = ['html:html.out', 'json:json.out']
5555
args += ' ' + formats.map((f) => `--format ${f}`).join(' ')
5656
const renderedArgs = Mustache.render(args, this)
5757
const stringArgs = stringArgv(renderedArgs)

features/step_definitions/fixture_steps.ts renamed to features/step_definitions/formatter_steps.ts

+7
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,10 @@ Then(
3131
expect(actual).to.eql(expected)
3232
}
3333
)
34+
35+
Then('the html formatter output is complete', async function (this: World) {
36+
const actualPath = path.join(this.tmpDir, `html.out`)
37+
const actual = await fs.readFile(actualPath, 'utf8')
38+
expect(actual).to.contain('<html lang="en">')
39+
expect(actual).to.contain('</html>')
40+
})

package.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@
167167
"@cucumber/create-meta": "^2.0.2",
168168
"@cucumber/cucumber-expressions": "^10.3.0",
169169
"@cucumber/gherkin": "^15.0.2",
170+
"@cucumber/html-formatter": "^9.0.0",
170171
"@cucumber/messages": "^13.0.1",
171172
"@cucumber/query": "^7.0.0",
172173
"@cucumber/tag-expressions": "^2.0.4",
@@ -188,6 +189,7 @@
188189
"mz": "^2.7.0",
189190
"progress": "^2.0.3",
190191
"resolve": "^1.17.0",
192+
"resolve-pkg": "^2.0.0",
191193
"stack-chain": "^2.0.0",
192194
"stacktrace-js": "^2.0.2",
193195
"string-argv": "^0.3.1",
@@ -261,7 +263,7 @@
261263
"build-release": "browserify scripts/cucumber.ts -o dist/cucumber.js -p [ tsify -p tsconfig.browser.json ] --standalone Cucumber --debug",
262264
"cck-test": "mocha 'compatibility/**/*_spec.ts'",
263265
"feature-test": "node ./bin/cucumber-js",
264-
"formatters-test": "node ./bin/cucumber-js --profile formatters | npx @cucumber/[email protected] --format ndjson > html-formatter.html",
266+
"html-formatter": "node ./bin/cucumber-js --profile htmlFormatter",
265267
"lint-autofix": "eslint --fix \"{compatibility,example,features,scripts,src,test}/**/*.ts\"",
266268
"lint-code": "eslint \"{compatibility,example,features,scripts,src,test}/**/*.ts\"",
267269
"lint-dependencies": "dependency-lint",
@@ -272,8 +274,8 @@
272274
"prefeature-test": "yarn run build-local",
273275
"prepublishOnly": "rm -rf lib && npm run build-local",
274276
"test-browser-example": "yarn build-release && yarn build-browser-example && git checkout -- dist/cucumber.js",
275-
"test-coverage": "yarn run lint && ./scripts/test-coverage.sh && yarn run formatters-test",
276-
"test": "yarn run lint && yarn run unit-test && yarn run cck-test && yarn run feature-test && yarn run formatters-test",
277+
"test-coverage": "yarn run lint && ./scripts/test-coverage.sh",
278+
"test": "yarn run lint && yarn run unit-test && yarn run cck-test && yarn run feature-test",
277279
"unit-test": "mocha 'src/**/*_spec.ts'",
278280
"update-dependencies": "npx npm-check-updates --upgrade"
279281
},

src/cli/index.ts

+44-39
Original file line numberDiff line numberDiff line change
@@ -88,50 +88,55 @@ export default class Cli {
8888
formats,
8989
supportCodeLibrary,
9090
}: IInitializeFormattersRequest): Promise<() => Promise<void>> {
91-
const streamsToClose: IFormatterStream[] = []
92-
await bluebird.map(formats, async ({ type, outputTo }) => {
93-
let stream: IFormatterStream = this.stdout
94-
if (outputTo !== '') {
95-
if (outputTo.match(new RegExp('^https?://')) !== null) {
96-
const headers: { [key: string]: string } = {}
97-
if (process.env.CUCUMBER_PUBLISH_TOKEN !== undefined) {
98-
headers.Authorization = `Bearer ${process.env.CUCUMBER_PUBLISH_TOKEN}`
99-
}
91+
const formatters = await bluebird.map(
92+
formats,
93+
async ({ type, outputTo }) => {
94+
let stream: IFormatterStream = this.stdout
95+
if (outputTo !== '') {
96+
if (outputTo.match(new RegExp('^https?://')) !== null) {
97+
const headers: { [key: string]: string } = {}
98+
if (process.env.CUCUMBER_PUBLISH_TOKEN !== undefined) {
99+
headers.Authorization = `Bearer ${process.env.CUCUMBER_PUBLISH_TOKEN}`
100+
}
100101

101-
stream = new HttpStream(outputTo, 'GET', headers, (content) =>
102-
console.error(content)
102+
stream = new HttpStream(outputTo, 'GET', headers, (content) =>
103+
console.error(content)
104+
)
105+
} else {
106+
const fd = await fs.open(path.resolve(this.cwd, outputTo), 'w')
107+
stream = fs.createWriteStream(null, { fd })
108+
}
109+
}
110+
const typeOptions = {
111+
cwd: this.cwd,
112+
eventBroadcaster,
113+
eventDataCollector,
114+
log: stream.write.bind(stream),
115+
parsedArgvOptions: formatOptions,
116+
stream,
117+
cleanup:
118+
stream === this.stdout
119+
? async () => await Promise.resolve()
120+
: bluebird.promisify(stream.end.bind(stream)),
121+
supportCodeLibrary,
122+
}
123+
if (doesNotHaveValue(formatOptions.colorsEnabled)) {
124+
typeOptions.parsedArgvOptions.colorsEnabled = (stream as TtyWriteStream).isTTY
125+
}
126+
if (type === 'progress-bar' && !(stream as TtyWriteStream).isTTY) {
127+
const outputToName = outputTo === '' ? 'stdout' : outputTo
128+
console.warn(
129+
`Cannot use 'progress-bar' formatter for output to '${outputToName}' as not a TTY. Switching to 'progress' formatter.`
103130
)
104-
} else {
105-
const fd = await fs.open(path.resolve(this.cwd, outputTo), 'w')
106-
stream = fs.createWriteStream(null, { fd })
131+
type = 'progress'
107132
}
108-
streamsToClose.push(stream)
133+
return FormatterBuilder.build(type, typeOptions)
109134
}
110-
const typeOptions = {
111-
cwd: this.cwd,
112-
eventBroadcaster,
113-
eventDataCollector,
114-
log: stream.write.bind(stream),
115-
parsedArgvOptions: formatOptions,
116-
stream,
117-
supportCodeLibrary,
118-
}
119-
if (doesNotHaveValue(formatOptions.colorsEnabled)) {
120-
typeOptions.parsedArgvOptions.colorsEnabled = (stream as TtyWriteStream).isTTY
121-
}
122-
if (type === 'progress-bar' && !(stream as TtyWriteStream).isTTY) {
123-
const outputToName = outputTo === '' ? 'stdout' : outputTo
124-
console.warn(
125-
`Cannot use 'progress-bar' formatter for output to '${outputToName}' as not a TTY. Switching to 'progress' formatter.`
126-
)
127-
type = 'progress'
128-
}
129-
return FormatterBuilder.build(type, typeOptions)
130-
})
135+
)
131136
return async function () {
132-
await bluebird.each(streamsToClose, (stream) =>
133-
bluebird.promisify(stream.end.bind(stream))()
134-
)
137+
await bluebird.each(formatters, async (formatter) => {
138+
await formatter.finished()
139+
})
135140
}
136141
}
137142

src/formatter/builder.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ import SummaryFormatter from './summary_formatter'
1212
import UsageFormatter from './usage_formatter'
1313
import UsageJsonFormatter from './usage_json_formatter'
1414
import { ISupportCodeLibrary } from '../support_code_library_builder/types'
15-
import Formatter, { IFormatterLogFn } from '.'
15+
import Formatter, { IFormatterCleanupFn, IFormatterLogFn } from '.'
1616
import { doesHaveValue, doesNotHaveValue } from '../value_checker'
1717
import { EventEmitter } from 'events'
1818
import EventDataCollector from './helpers/event_data_collector'
1919
import { Writable as WritableStream } from 'stream'
2020
import { IParsedArgvFormatOptions } from '../cli/argv_parser'
2121
import { SnippetInterface } from './step_definition_snippet_builder/snippet_syntax'
22+
import HtmlFormatter from './html_formatter'
2223

2324
interface IGetStepDefinitionSnippetBuilderOptions {
2425
cwd: string
@@ -34,6 +35,7 @@ export interface IBuildOptions {
3435
log: IFormatterLogFn
3536
parsedArgvOptions: IParsedArgvFormatOptions
3637
stream: WritableStream
38+
cleanup: IFormatterCleanupFn
3739
supportCodeLibrary: ISupportCodeLibrary
3840
}
3941

@@ -63,6 +65,8 @@ const FormatterBuilder = {
6365
return JsonFormatter
6466
case 'message':
6567
return MessageFormatter
68+
case 'html':
69+
return HtmlFormatter
6670
case 'progress':
6771
return ProgressFormatter
6872
case 'progress-bar':

src/formatter/html_formatter.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import Formatter, { IFormatterOptions } from '.'
2+
import { messages } from '@cucumber/messages'
3+
import resolvePkg from 'resolve-pkg'
4+
import CucumberHtmlStream from '@cucumber/html-formatter'
5+
import { doesHaveValue } from '../value_checker'
6+
import { finished } from 'stream'
7+
import { promisify } from 'util'
8+
9+
export default class HtmlFormatter extends Formatter {
10+
private readonly _finished: Promise<void>
11+
12+
constructor(options: IFormatterOptions) {
13+
super(options)
14+
const cucumberHtmlStream = new CucumberHtmlStream(
15+
resolvePkg('@cucumber/react', { cwd: __dirname }) +
16+
'/dist/src/styles/cucumber-react.css',
17+
resolvePkg('@cucumber/html-formatter', { cwd: __dirname }) +
18+
'/dist/main.js'
19+
)
20+
options.eventBroadcaster.on('envelope', (envelope: messages.Envelope) => {
21+
cucumberHtmlStream.write(envelope)
22+
if (doesHaveValue(envelope.testRunFinished)) {
23+
cucumberHtmlStream.end()
24+
}
25+
})
26+
cucumberHtmlStream.on('data', (chunk) => this.log(chunk))
27+
this._finished = promisify(finished)(cucumberHtmlStream)
28+
}
29+
30+
async finished(): Promise<void> {
31+
await this._finished
32+
await super.finished()
33+
}
34+
}

src/formatter/index.ts

+8
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type IFormatterStream =
1515
| PassThrough
1616
| HttpStream
1717
export type IFormatterLogFn = (buffer: string | Uint8Array) => void
18+
export type IFormatterCleanupFn = () => Promise<any>
1819

1920
export interface IFormatterOptions {
2021
colorFns: IColorFns
@@ -25,6 +26,7 @@ export interface IFormatterOptions {
2526
parsedArgvOptions: IParsedArgvFormatOptions
2627
snippetBuilder: StepDefinitionSnippetBuilder
2728
stream: WritableStream
29+
cleanup: IFormatterCleanupFn
2830
supportCodeLibrary: ISupportCodeLibrary
2931
}
3032

@@ -36,6 +38,7 @@ export default class Formatter {
3638
protected snippetBuilder: StepDefinitionSnippetBuilder
3739
protected stream: WritableStream
3840
protected supportCodeLibrary: ISupportCodeLibrary
41+
private readonly cleanup: IFormatterCleanupFn
3942

4043
constructor(options: IFormatterOptions) {
4144
this.colorFns = options.colorFns
@@ -45,5 +48,10 @@ export default class Formatter {
4548
this.snippetBuilder = options.snippetBuilder
4649
this.stream = options.stream
4750
this.supportCodeLibrary = options.supportCodeLibrary
51+
this.cleanup = options.cleanup
52+
}
53+
54+
async finished(): Promise<void> {
55+
await this.cleanup()
4856
}
4957
}

src/formatter/progress_bar_formatter_spec.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import ProgressBarFormatter from './progress_bar_formatter'
2020
import { doesHaveValue, doesNotHaveValue } from '../value_checker'
2121
import { PassThrough } from 'stream'
2222
import ProgressBar from 'progress'
23+
import bluebird from 'bluebird'
2324

2425
interface ITestProgressBarFormatterOptions {
2526
runtimeOptions?: Partial<IRuntimeOptions>
@@ -53,13 +54,15 @@ async function testProgressBarFormatter({
5354
const logFn = (data: string): void => {
5455
output += data
5556
}
57+
const passThrough = new PassThrough()
5658
const progressBarFormatter = FormatterBuilder.build('progress-bar', {
5759
cwd: '',
5860
eventBroadcaster,
5961
eventDataCollector: new EventDataCollector(eventBroadcaster),
6062
log: logFn,
6163
parsedArgvOptions: {},
62-
stream: new PassThrough(),
64+
stream: passThrough,
65+
cleanup: bluebird.promisify(passThrough.end.bind(passThrough)),
6366
supportCodeLibrary,
6467
}) as ProgressBarFormatter
6568
let mocked = false

test/formatter_helpers.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { doesNotHaveValue } from '../src/value_checker'
1111
import { IParsedArgvFormatOptions } from '../src/cli/argv_parser'
1212
import { PassThrough } from 'stream'
1313
import { emitSupportCodeMessages } from '../src/cli/helpers'
14+
import bluebird from 'bluebird'
1415
import IEnvelope = messages.IEnvelope
1516

1617
const { uuid } = IdGenerator
@@ -57,13 +58,15 @@ export async function testFormatter({
5758
const logFn = (data: string): void => {
5859
output += data
5960
}
61+
const passThrough = new PassThrough()
6062
FormatterBuilder.build(type, {
6163
cwd: '',
6264
eventBroadcaster,
6365
eventDataCollector,
6466
log: logFn,
6567
parsedArgvOptions,
66-
stream: new PassThrough(),
68+
stream: passThrough,
69+
cleanup: bluebird.promisify(passThrough.end.bind(passThrough)),
6770
supportCodeLibrary,
6871
})
6972
let pickleIds: string[] = []

0 commit comments

Comments
 (0)