Skip to content

Commit abe487a

Browse files
committed
wip implement summary
1 parent 9292a5d commit abe487a

File tree

6 files changed

+293
-6
lines changed

6 files changed

+293
-6
lines changed

javascript/package-lock.json

Lines changed: 19 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

javascript/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"prepublishOnly": "tsc --build tsconfig.build.json"
2323
},
2424
"dependencies": {
25-
"@cucumber/query": "14.3.0"
25+
"@cucumber/query": "14.3.0",
26+
"luxon": "^3.7.2"
2627
},
2728
"peerDependencies": {
2829
"@cucumber/messages": "*"
@@ -34,6 +35,7 @@
3435
"@eslint/eslintrc": "^3.3.1",
3536
"@eslint/js": "^9.30.1",
3637
"@types/chai": "^5.0.0",
38+
"@types/luxon": "^3.7.1",
3739
"@types/mocha": "^10.0.6",
3840
"@types/node": "22.18.6",
3941
"@typescript-eslint/eslint-plugin": "8.44.0",

javascript/src/ProgressPrinter.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,4 @@ describe('ProgressPrinter', async () => {
100100
}
101101
})
102102
}
103-
})
103+
})

javascript/src/SummaryPrinter.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,4 @@ describe('SummaryPrinter', async () => {
100100
}
101101
})
102102
}
103-
})
103+
})

javascript/src/SummaryPrinter.ts

Lines changed: 205 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,27 @@
1-
import { Envelope } from '@cucumber/messages'
1+
import {
2+
Envelope,
3+
Location,
4+
Pickle,
5+
TestCaseFinished,
6+
TestCaseStarted,
7+
TestRunFinished,
8+
TestRunStarted,
9+
TestStepResult,
10+
TestStepResultStatus,
11+
} from '@cucumber/messages'
212
import { Query } from '@cucumber/query'
313

14+
import {
15+
ensure,
16+
ERROR_INDENT_LENGTH,
17+
formatCounts,
18+
formatDuration,
19+
formatNonPassingTitle,
20+
formatPickleLocation,
21+
formatTestStepResultError,
22+
GHERKIN_INDENT_LENGTH,
23+
indent,
24+
} from './helpers.js'
425
import type { Options } from './types.js'
526

627
export class SummaryPrinter {
@@ -19,7 +40,189 @@ export class SummaryPrinter {
1940
this.query.update(message)
2041

2142
if (message.testRunFinished) {
22-
this.println('Summary!')
43+
this.printSummary()
44+
}
45+
}
46+
47+
private printSummary() {
48+
this.printNonPassingScenarios()
49+
this.printStats()
50+
this.printSnippets()
51+
}
52+
53+
private printStats() {
54+
this.println()
55+
this.printGlobalHookCounts()
56+
this.printScenarioCounts()
57+
this.printStepCounts()
58+
this.printDuration()
59+
}
60+
61+
private printNonPassingScenarios() {
62+
const theOrder: TestStepResultStatus[] = [
63+
TestStepResultStatus.UNKNOWN,
64+
TestStepResultStatus.PENDING,
65+
TestStepResultStatus.UNDEFINED,
66+
TestStepResultStatus.AMBIGUOUS,
67+
TestStepResultStatus.FAILED,
68+
]
69+
70+
const picklesByStatus = new Map<
71+
TestStepResultStatus,
72+
Array<{
73+
pickle: Pickle
74+
location: Location | undefined
75+
testCaseStarted: TestCaseStarted
76+
testCaseFinished: TestCaseFinished
77+
testStepResult: TestStepResult
78+
}>
79+
>()
80+
81+
for (const testCaseFinished of this.query.findAllTestCaseFinished()) {
82+
const testCaseStarted = ensure(
83+
this.query.findTestCaseStartedBy(testCaseFinished),
84+
'TestCaseStarted must exist for TestCaseFinished'
85+
)
86+
const pickle = ensure(
87+
this.query.findPickleBy(testCaseFinished),
88+
'Pickle must exist for TestCaseFinished'
89+
)
90+
const location = this.query.findLocationOf(pickle)
91+
const testStepResult = this.query.findMostSevereTestStepResultBy(testCaseFinished)
92+
if (testStepResult) {
93+
if (!picklesByStatus.has(testStepResult.status)) {
94+
picklesByStatus.set(testStepResult.status, [])
95+
}
96+
picklesByStatus.get(testStepResult.status)!.push({
97+
pickle,
98+
location,
99+
testCaseStarted,
100+
testCaseFinished,
101+
testStepResult,
102+
})
103+
}
104+
}
105+
106+
for (const status of theOrder) {
107+
const picklesForThisStatus = picklesByStatus.get(status) ?? []
108+
if (picklesForThisStatus.length > 0) {
109+
this.println()
110+
this.println(formatNonPassingTitle(status, this.options.theme, this.stream))
111+
picklesForThisStatus.forEach(
112+
({ pickle, location, testCaseStarted, testStepResult }, index) => {
113+
const formattedLocation = formatPickleLocation(
114+
pickle,
115+
location,
116+
this.options.theme,
117+
this.stream
118+
)
119+
const formattedAttempt =
120+
testCaseStarted.attempt > 0 ? `, after ${testCaseStarted.attempt + 1} attempts` : ''
121+
this.println(
122+
indent(
123+
`${index + 1}) ${pickle.name}${formattedAttempt} ${formattedLocation}`,
124+
GHERKIN_INDENT_LENGTH
125+
)
126+
)
127+
if (status === TestStepResultStatus.FAILED) {
128+
const content = formatTestStepResultError(
129+
testStepResult,
130+
this.options.theme,
131+
this.stream
132+
)
133+
if (content) {
134+
this.println(indent(content, GHERKIN_INDENT_LENGTH + ERROR_INDENT_LENGTH + 1))
135+
this.println()
136+
}
137+
}
138+
}
139+
)
140+
}
141+
}
142+
}
143+
144+
private printGlobalHookCounts() {
145+
const testRunHookFinished = this.query.findAllTestRunHookFinished()
146+
if (testRunHookFinished.length === 0) {
147+
return
148+
}
149+
150+
const globalHookCountsByStatus = testRunHookFinished
151+
.map((testRunHookFinished) => testRunHookFinished.result.status)
152+
.reduce(
153+
(prev, status) => {
154+
return {
155+
...prev,
156+
[status]: (prev[status] ?? 0) + 1,
157+
}
158+
},
159+
{} as Partial<Record<TestStepResultStatus, number>>
160+
)
161+
this.println(formatCounts('hooks', globalHookCountsByStatus, this.options.theme, this.stream))
162+
}
163+
164+
private printScenarioCounts() {
165+
const scenarioCountsByStatus = this.query
166+
.findAllTestCaseFinished()
167+
.map((testCaseFinished) => this.query.findMostSevereTestStepResultBy(testCaseFinished))
168+
.map((testStepResult) => testStepResult?.status ?? TestStepResultStatus.PASSED)
169+
.reduce(
170+
(prev, status) => {
171+
return {
172+
...prev,
173+
[status]: (prev[status] ?? 0) + 1,
174+
}
175+
},
176+
{} as Partial<Record<TestStepResultStatus, number>>
177+
)
178+
this.println(formatCounts('scenarios', scenarioCountsByStatus, this.options.theme, this.stream))
179+
}
180+
181+
private printStepCounts() {
182+
const stepCountsByStatus = this.query
183+
.findAllTestCaseFinished()
184+
.flatMap((testCaseFinished) => this.query.findTestStepsFinishedBy(testCaseFinished))
185+
.map((testStepFinished) => testStepFinished.testStepResult.status)
186+
.reduce(
187+
(prev, status) => {
188+
return {
189+
...prev,
190+
[status]: (prev[status] ?? 0) + 1,
191+
}
192+
},
193+
{} as Partial<Record<TestStepResultStatus, number>>
194+
)
195+
this.println(formatCounts('steps', stepCountsByStatus, this.options.theme, this.stream))
196+
}
197+
198+
private printDuration() {
199+
const testRunStarted = this.query.findTestRunStarted() as TestRunStarted
200+
const testRunFinished = this.query.findTestRunFinished() as TestRunFinished
201+
202+
this.println(formatDuration(testRunStarted.timestamp, testRunFinished.timestamp))
203+
}
204+
205+
private printSnippets() {
206+
const snippets = this.query
207+
.findAllTestCaseFinished()
208+
.map((testCaseFinished) => this.query.findPickleBy(testCaseFinished))
209+
.filter((pickle) => !!pickle)
210+
.sort((a, b) => {
211+
// TODO compare by location too
212+
return a?.uri.localeCompare(b?.uri || '') || 0
213+
})
214+
.flatMap((pickle) => this.query.findSuggestionsBy(pickle))
215+
.flatMap((suggestion) => suggestion.snippets)
216+
.map((snippet) => snippet.code)
217+
const uniqueSnippets = new Set(snippets)
218+
if (uniqueSnippets.size > 0) {
219+
this.println()
220+
this.println('You can implement missing steps with the snippets below:')
221+
this.println()
222+
for (const snippet of uniqueSnippets) {
223+
this.println(snippet)
224+
this.println()
225+
}
23226
}
24227
}
25228
}

javascript/src/helpers.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import {
1717
TestStep,
1818
TestStepResult,
1919
TestStepResultStatus,
20+
TimeConversion,
21+
Timestamp,
2022
} from '@cucumber/messages'
23+
import { Interval } from 'luxon'
2124

2225
import { TextBuilder } from './TextBuilder.js'
2326
import { Theme } from './types.js'
@@ -26,6 +29,16 @@ export const GHERKIN_INDENT_LENGTH = 2
2629
export const STEP_ARGUMENT_INDENT_LENGTH = 2
2730
export const ATTACHMENT_INDENT_LENGTH = 4
2831
export const ERROR_INDENT_LENGTH = 4
32+
33+
const STATUS_ORDER: TestStepResultStatus[] = [
34+
TestStepResultStatus.UNKNOWN,
35+
TestStepResultStatus.PASSED,
36+
TestStepResultStatus.SKIPPED,
37+
TestStepResultStatus.PENDING,
38+
TestStepResultStatus.UNDEFINED,
39+
TestStepResultStatus.AMBIGUOUS,
40+
TestStepResultStatus.FAILED,
41+
]
2942
const STATUS_CHARACTERS: Record<TestStepResultStatus, string> = {
3043
[TestStepResultStatus.AMBIGUOUS]: 'A',
3144
[TestStepResultStatus.FAILED]: 'F',
@@ -35,6 +48,7 @@ const STATUS_CHARACTERS: Record<TestStepResultStatus, string> = {
3548
[TestStepResultStatus.UNDEFINED]: 'U',
3649
[TestStepResultStatus.UNKNOWN]: '?',
3750
} as const
51+
const DURATION_FORMAT = "m'm' s.S's'"
3852

3953
export function ensure<T>(value: T | undefined, message: string): T {
4054
if (!value) {
@@ -81,6 +95,7 @@ export function formatPickleTags(pickle: Pickle, theme: Theme, stream: NodeJS.Wr
8195
.build(theme.tag)
8296
}
8397
}
98+
8499
export function formatPickleTitle(
85100
pickle: Pickle,
86101
scenario: Scenario,
@@ -325,3 +340,52 @@ export function formatStatusCharacter(
325340
const character = STATUS_CHARACTERS[status]
326341
return new TextBuilder(stream).append(character).build(theme.status?.all?.[status])
327342
}
343+
344+
export function formatNonPassingTitle(
345+
status: TestStepResultStatus,
346+
theme: Theme,
347+
stream: NodeJS.WritableStream
348+
) {
349+
return new TextBuilder(stream)
350+
.append(status.charAt(0).toUpperCase() + status.slice(1).toLowerCase())
351+
.append(' scenarios:')
352+
.build(theme.status?.all?.[status])
353+
}
354+
355+
export function formatCounts(
356+
suffix: string,
357+
counts: Partial<Record<TestStepResultStatus, number>>,
358+
theme: Theme,
359+
stream: NodeJS.WritableStream
360+
) {
361+
const builder = new TextBuilder(stream)
362+
const total = Object.values(counts).reduce((prev, curr) => prev + curr, 0)
363+
builder.append(`${total} ${suffix}`)
364+
if (total > 0) {
365+
let first = true
366+
builder.append(' (')
367+
for (const status of STATUS_ORDER) {
368+
const count = counts[status]
369+
if (count) {
370+
if (!first) {
371+
builder.append(', ')
372+
}
373+
builder.append(`${count} ${status.toLowerCase()}`, theme.status?.all?.[status])
374+
first = false
375+
}
376+
}
377+
builder.append(')')
378+
}
379+
return builder.build()
380+
}
381+
382+
export function formatDuration(start: Timestamp, finish: Timestamp) {
383+
const startMillis = new Date(TimeConversion.timestampToMillisecondsSinceEpoch(start))
384+
const finishMillis = new Date(TimeConversion.timestampToMillisecondsSinceEpoch(finish))
385+
const duration = Interval.fromDateTimes(startMillis, finishMillis).toDuration([
386+
'minutes',
387+
'seconds',
388+
'milliseconds',
389+
])
390+
return duration.toFormat(DURATION_FORMAT)
391+
}

0 commit comments

Comments
 (0)