@specbook/core 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/assert.ts +17 -6
- package/src/config.ts +15 -2
- package/src/controls.ts +34 -9
- package/src/diff.ts +131 -0
- package/src/environment.ts +175 -0
- package/src/index.ts +5 -1
- package/src/mock.ts +80 -70
- package/src/reporters/ci.ts +53 -0
- package/src/reporters/color.ts +34 -0
- package/src/reporters/envelope.ts +63 -0
- package/src/reporters/index.ts +139 -25
- package/src/reporters/list.ts +254 -0
- package/src/reporters/redact.ts +113 -0
- package/src/reporters/trace.ts +70 -0
- package/src/request.ts +20 -2
- package/src/runner/index.ts +1 -0
- package/src/runner/run.ts +113 -27
- package/src/runner/suite.ts +122 -0
- package/src/runner/transport.ts +65 -5
- package/src/secrets.ts +31 -2
- package/src/sequence.ts +4 -4
- package/src/spec-config.ts +77 -11
- package/src/template.ts +13 -4
- package/src/types.ts +49 -32
package/src/mock.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// In-process mock transport:
|
|
1
|
+
// In-process mock transport: answers from the generated examples, filled in from the request that was sent, and lets a
|
|
2
|
+
// request's `withMock` hook change the answer.
|
|
2
3
|
import { reflectRequest } from './mock-reflect'
|
|
3
4
|
import type { PreparedRequest, RawResponse, Transport } from './runner/transport'
|
|
4
5
|
import { deepMerge, render } from './template'
|
|
5
|
-
import type { MockResponse } from './types'
|
|
6
6
|
|
|
7
7
|
export interface MockExampleLoader {
|
|
8
8
|
/** Statuses that have an example for this operation, in spec order. */
|
|
@@ -12,48 +12,35 @@ export interface MockExampleLoader {
|
|
|
12
12
|
|
|
13
13
|
export interface MockTransportOptions {
|
|
14
14
|
loader: MockExampleLoader
|
|
15
|
-
|
|
15
|
+
/** Waits a mock's `delayMs`. The default can be ended early by the run's signal: a person pressing Stop. */
|
|
16
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
const
|
|
19
|
-
v !== null &&
|
|
20
|
-
typeof v === 'object' &&
|
|
21
|
-
('data' in (v as object) ||
|
|
22
|
-
'init' in (v as object) ||
|
|
23
|
-
'headers' in (v as object) ||
|
|
24
|
-
'delayMs' in (v as object))
|
|
19
|
+
const is2xx = (s: number) => s >= 200 && s < 300
|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
Object.entries(init ?? {}).map(([k, v]) => [
|
|
29
|
-
k,
|
|
30
|
-
typeof v === 'function' ? (v as () => unknown)() : v,
|
|
31
|
-
]),
|
|
32
|
-
)
|
|
33
|
-
}
|
|
21
|
+
/** The loader may hand out one object to every caller, and a hook edits what it is given. */
|
|
22
|
+
const copy = <T>(v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T))
|
|
34
23
|
|
|
35
24
|
export function createMockTransport(opts: MockTransportOptions): Transport {
|
|
36
|
-
const sleep =
|
|
37
|
-
|
|
25
|
+
const sleep =
|
|
26
|
+
opts.sleep ??
|
|
27
|
+
((ms: number, signal?: AbortSignal) =>
|
|
28
|
+
new Promise<void>((done, stop) => {
|
|
29
|
+
if (signal?.aborted) return stop(new Error('Stopped before the API answered.'))
|
|
30
|
+
const timer = setTimeout(done, ms)
|
|
31
|
+
signal?.addEventListener(
|
|
32
|
+
'abort',
|
|
33
|
+
() => {
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
stop(new Error('Stopped before the API answered.'))
|
|
36
|
+
},
|
|
37
|
+
{ once: true },
|
|
38
|
+
)
|
|
39
|
+
}))
|
|
40
|
+
return async (req: PreparedRequest, signal?: AbortSignal): Promise<RawResponse> => {
|
|
38
41
|
const hints = req.mock
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const overridden = Object.keys(cfg?.responses ?? {}).map(Number)
|
|
42
|
-
const all = [...new Set([...known, ...overridden])]
|
|
43
|
-
|
|
44
|
-
const fromWhen = cfg?.when?.({
|
|
45
|
-
vars: (hints?.vars ?? {}) as never,
|
|
46
|
-
body: req.body,
|
|
47
|
-
query: hints?.rawQuery,
|
|
48
|
-
path: hints?.pathParams,
|
|
49
|
-
})
|
|
50
|
-
const status =
|
|
51
|
-
req.mockStatus ??
|
|
52
|
-
fromWhen ??
|
|
53
|
-
cfg?.status ??
|
|
54
|
-
all.find((s) => s >= 200 && s < 300) ??
|
|
55
|
-
all[0] ??
|
|
56
|
-
200
|
|
42
|
+
const all = await opts.loader.statuses(req.service, req.opId)
|
|
43
|
+
const status = req.mockStatus ?? all.find(is2xx) ?? all[0] ?? 200
|
|
57
44
|
|
|
58
45
|
// Secured operations answer 401 when no credential header was applied.
|
|
59
46
|
if (
|
|
@@ -67,42 +54,65 @@ export function createMockTransport(opts: MockTransportOptions): Transport {
|
|
|
67
54
|
return json(401, body)
|
|
68
55
|
}
|
|
69
56
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
57
|
+
const scope = {
|
|
58
|
+
...(hints?.vars ?? {}),
|
|
59
|
+
...(hints?.pathParams ?? {}),
|
|
60
|
+
...(hints?.rawQuery ?? {}),
|
|
61
|
+
}
|
|
75
62
|
// The generated example answers the request that was sent, so a created thing has the fields you posted.
|
|
76
|
-
if (status >= 200 && status < 300 && cfg?.reflect !== false)
|
|
77
|
-
template = reflectRequest(template, {
|
|
78
|
-
body: req.body,
|
|
79
|
-
path: hints?.pathParams,
|
|
80
|
-
query: hints?.rawQuery,
|
|
81
|
-
})
|
|
82
63
|
// A declared status without a response schema (e.g. Petstore's 404) has no example: empty body.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
64
|
+
const answer = async (s: number) => {
|
|
65
|
+
let template: unknown = copy(await opts.loader.example(req.service, req.opId, s))
|
|
66
|
+
if (is2xx(s))
|
|
67
|
+
template = reflectRequest(template, {
|
|
68
|
+
body: req.body,
|
|
69
|
+
path: hints?.pathParams,
|
|
70
|
+
query: hints?.rawQuery,
|
|
71
|
+
})
|
|
72
|
+
return template === undefined ? undefined : render(template, scope)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const res: {
|
|
76
|
+
status: number
|
|
77
|
+
data: unknown
|
|
78
|
+
headers: Record<string, string>
|
|
79
|
+
delayMs: number
|
|
80
|
+
respond(status: number, data?: unknown): void
|
|
81
|
+
} = {
|
|
82
|
+
status,
|
|
83
|
+
data: await answer(status),
|
|
84
|
+
headers: {},
|
|
85
|
+
delayMs: 0,
|
|
86
|
+
respond: (s, data) => {
|
|
87
|
+
pending = { status: s, data }
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
let pending: { status: number; data?: unknown } | undefined
|
|
91
|
+
|
|
92
|
+
if (hints?.hook) {
|
|
93
|
+
await hints.hook(
|
|
94
|
+
{
|
|
95
|
+
vars: (hints.vars ?? {}) as never,
|
|
96
|
+
body: req.body,
|
|
97
|
+
query: hints.rawQuery,
|
|
98
|
+
path: hints.pathParams,
|
|
99
|
+
headers: req.headers,
|
|
100
|
+
},
|
|
101
|
+
res as never,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
if (pending) {
|
|
105
|
+
const base = await answer(pending.status)
|
|
106
|
+
res.status = pending.status
|
|
107
|
+
res.data = pending.data === undefined ? base : deepMerge(base, pending.data)
|
|
95
108
|
}
|
|
96
|
-
if (delay) await sleep(delay)
|
|
97
|
-
const body = template === undefined ? undefined : render(template, scope)
|
|
98
|
-
return { ...json(status, body), headers: { 'content-type': 'application/json', ...headers } }
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
109
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
if (res.delayMs) await sleep(res.delayMs, signal)
|
|
111
|
+
return {
|
|
112
|
+
...json(res.status, res.data),
|
|
113
|
+
headers: { 'content-type': 'application/json', ...res.headers },
|
|
114
|
+
}
|
|
115
|
+
}
|
|
106
116
|
}
|
|
107
117
|
|
|
108
118
|
function json(status: number, body: unknown): RawResponse {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Where a run happens, from the environment a CI system sets, for the report's header and its links.
|
|
2
|
+
|
|
3
|
+
export interface CiInfo {
|
|
4
|
+
provider: string
|
|
5
|
+
runUrl?: string
|
|
6
|
+
sha?: string
|
|
7
|
+
branch?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function detectCi(env: Record<string, string | undefined>): CiInfo | undefined {
|
|
11
|
+
if (env.GITHUB_ACTIONS === 'true') {
|
|
12
|
+
const server = env.GITHUB_SERVER_URL ?? 'https://github.com'
|
|
13
|
+
return {
|
|
14
|
+
provider: 'github-actions',
|
|
15
|
+
runUrl:
|
|
16
|
+
env.GITHUB_REPOSITORY && env.GITHUB_RUN_ID
|
|
17
|
+
? `${server}/${env.GITHUB_REPOSITORY}/actions/runs/${env.GITHUB_RUN_ID}`
|
|
18
|
+
: undefined,
|
|
19
|
+
sha: env.GITHUB_SHA,
|
|
20
|
+
branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (env.GITLAB_CI === 'true')
|
|
24
|
+
return {
|
|
25
|
+
provider: 'gitlab-ci',
|
|
26
|
+
runUrl: env.CI_PIPELINE_URL,
|
|
27
|
+
sha: env.CI_COMMIT_SHA,
|
|
28
|
+
branch: env.CI_COMMIT_REF_NAME,
|
|
29
|
+
}
|
|
30
|
+
if (env.CIRCLECI === 'true')
|
|
31
|
+
return {
|
|
32
|
+
provider: 'circleci',
|
|
33
|
+
runUrl: env.CIRCLE_BUILD_URL,
|
|
34
|
+
sha: env.CIRCLE_SHA1,
|
|
35
|
+
branch: env.CIRCLE_BRANCH,
|
|
36
|
+
}
|
|
37
|
+
if (env.BUILDKITE === 'true')
|
|
38
|
+
return {
|
|
39
|
+
provider: 'buildkite',
|
|
40
|
+
runUrl: env.BUILDKITE_BUILD_URL,
|
|
41
|
+
sha: env.BUILDKITE_COMMIT,
|
|
42
|
+
branch: env.BUILDKITE_BRANCH,
|
|
43
|
+
}
|
|
44
|
+
if (env.JENKINS_URL)
|
|
45
|
+
return {
|
|
46
|
+
provider: 'jenkins',
|
|
47
|
+
runUrl: env.BUILD_URL,
|
|
48
|
+
sha: env.GIT_COMMIT,
|
|
49
|
+
branch: env.GIT_BRANCH,
|
|
50
|
+
}
|
|
51
|
+
if (env.CI) return { provider: 'ci' }
|
|
52
|
+
return undefined
|
|
53
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Terminal colors, only when they will be seen: a TTY or a CI log that renders them, not NO_COLOR, or FORCE_COLOR.
|
|
2
|
+
|
|
3
|
+
export interface Colors {
|
|
4
|
+
red(s: string): string
|
|
5
|
+
green(s: string): string
|
|
6
|
+
yellow(s: string): string
|
|
7
|
+
cyan(s: string): string
|
|
8
|
+
dim(s: string): string
|
|
9
|
+
bold(s: string): string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** CI systems whose logs render color though the stream is not a terminal. */
|
|
13
|
+
const COLOR_CI = ['GITHUB_ACTIONS', 'GITLAB_CI', 'CIRCLECI', 'BUILDKITE']
|
|
14
|
+
|
|
15
|
+
/** Whether to color output: FORCE_COLOR wins, then NO_COLOR, then an interactive terminal or a CI log that shows color. */
|
|
16
|
+
export function colorEnabled(env: Record<string, string | undefined>, isTTY: boolean): boolean {
|
|
17
|
+
if (env.FORCE_COLOR && env.FORCE_COLOR !== '0') return true
|
|
18
|
+
if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return false
|
|
19
|
+
if (COLOR_CI.some((k) => env[k] && env[k] !== 'false')) return true
|
|
20
|
+
return isTTY && env.TERM !== 'dumb'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createColors(enabled: boolean): Colors {
|
|
24
|
+
const wrap = (open: number, close: number) => (s: string) =>
|
|
25
|
+
enabled ? `\u001b[${open}m${s}\u001b[${close}m` : s
|
|
26
|
+
return {
|
|
27
|
+
red: wrap(31, 39),
|
|
28
|
+
green: wrap(32, 39),
|
|
29
|
+
yellow: wrap(33, 39),
|
|
30
|
+
cyan: wrap(36, 39),
|
|
31
|
+
dim: wrap(2, 22),
|
|
32
|
+
bold: wrap(1, 22),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The JSON report: the results with what a monitoring system needs to know about the run around them.
|
|
2
|
+
|
|
3
|
+
import type { RunResult } from '../types'
|
|
4
|
+
import type { CiInfo } from './ci'
|
|
5
|
+
|
|
6
|
+
export interface ReportMeta {
|
|
7
|
+
env: string
|
|
8
|
+
/** version of the specbook that ran it */
|
|
9
|
+
specbook?: string
|
|
10
|
+
node?: string
|
|
11
|
+
os?: string
|
|
12
|
+
git?: { sha?: string; branch?: string }
|
|
13
|
+
ci?: CiInfo
|
|
14
|
+
/** `bundle.json` details when run from a bundle */
|
|
15
|
+
bundle?: { sha?: string; createdAt?: string }
|
|
16
|
+
/** service → origin of the address its calls went to */
|
|
17
|
+
services?: Record<string, string>
|
|
18
|
+
startedAt: string
|
|
19
|
+
finishedAt: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ReportTotals {
|
|
23
|
+
flows: { passed: number; failed: number; errored: number; flaky: number }
|
|
24
|
+
steps: { passed: number; failed: number; skipped: number }
|
|
25
|
+
assertions: { passed: number; failed: number }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RunReport extends ReportMeta {
|
|
29
|
+
schemaVersion: 2
|
|
30
|
+
durationMs: number
|
|
31
|
+
totals: ReportTotals
|
|
32
|
+
results: RunResult[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function reportTotals(results: RunResult[]): ReportTotals {
|
|
36
|
+
const t: ReportTotals = {
|
|
37
|
+
flows: { passed: 0, failed: 0, errored: 0, flaky: 0 },
|
|
38
|
+
steps: { passed: 0, failed: 0, skipped: 0 },
|
|
39
|
+
assertions: { passed: 0, failed: 0 },
|
|
40
|
+
}
|
|
41
|
+
for (const r of results) {
|
|
42
|
+
if (r.status === 'pass') t.flows.passed++
|
|
43
|
+
else if (r.status === 'error') t.flows.errored++
|
|
44
|
+
else t.flows.failed++
|
|
45
|
+
if (r.flaky) t.flows.flaky++
|
|
46
|
+
t.steps.passed += r.totals.passed
|
|
47
|
+
t.steps.failed += r.totals.failed
|
|
48
|
+
t.steps.skipped += r.totals.skipped
|
|
49
|
+
t.assertions.passed += r.totals.assertions.passed
|
|
50
|
+
t.assertions.failed += r.totals.assertions.failed
|
|
51
|
+
}
|
|
52
|
+
return t
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildReport(results: RunResult[], meta: ReportMeta): RunReport {
|
|
56
|
+
return {
|
|
57
|
+
schemaVersion: 2,
|
|
58
|
+
...meta,
|
|
59
|
+
durationMs: Math.max(0, Date.parse(meta.finishedAt) - Date.parse(meta.startedAt)),
|
|
60
|
+
totals: reportTotals(results),
|
|
61
|
+
results,
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/reporters/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { stripAnsi } from '../assert'
|
|
2
|
+
import { diffAssertion } from '../diff'
|
|
2
3
|
import type { RunResult, StepResult } from '../types'
|
|
4
|
+
import { reportTotals } from './envelope'
|
|
5
|
+
import { traceLines } from './trace'
|
|
3
6
|
|
|
4
7
|
export interface Reporter {
|
|
5
8
|
onEvent?(e: unknown): void
|
|
@@ -40,35 +43,74 @@ export function formatPretty(results: RunResult[]): string {
|
|
|
40
43
|
|
|
41
44
|
const esc = (s: unknown) =>
|
|
42
45
|
String(s).replace(
|
|
43
|
-
/
|
|
44
|
-
|
|
46
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: characters XML 1.0 forbids
|
|
47
|
+
/[<>&"']|[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g,
|
|
48
|
+
(c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[c] ?? '',
|
|
45
49
|
)
|
|
46
50
|
|
|
47
|
-
export
|
|
51
|
+
export interface JUnitOptions {
|
|
52
|
+
/** ISO time the run began; shown on each suite */
|
|
53
|
+
timestamp?: string
|
|
54
|
+
hostname?: string
|
|
55
|
+
/** shown as `<properties>` on each suite (env, git sha, seed…) */
|
|
56
|
+
properties?: Record<string, string | undefined>
|
|
57
|
+
/** service → the address its calls go to (origin and base path), for the trace in `<system-out>` */
|
|
58
|
+
origins?: Record<string, string>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const leafSteps = (steps: StepResult[]): StepResult[] =>
|
|
62
|
+
steps.flatMap((s) => (s.children?.length ? leafSteps(s.children) : [s]))
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* JUnit XML: one suite per flow or request, one case per step (named `flow › step`, class = the project file), failures
|
|
66
|
+
* with the expected and received values, errors with their message, skipped steps, a flow that passed on a retry as a
|
|
67
|
+
* `<flakyFailure>`, and the redacted request/response trace in `<system-out>`.
|
|
68
|
+
*/
|
|
69
|
+
export function toJUnit(results: RunResult[], o: JUnitOptions = {}): string {
|
|
48
70
|
const suites = results.map((r) => {
|
|
49
|
-
const flat
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
const
|
|
71
|
+
const flat = leafSteps(r.steps)
|
|
72
|
+
const failures = flat.filter((s) => s.status === 'fail').length
|
|
73
|
+
const errors = flat.filter((s) => s.status === 'error').length
|
|
74
|
+
const skipped = flat.filter((s) => s.status === 'skipped').length
|
|
75
|
+
const props = Object.entries({ env: r.env, seed: r.seed, ...o.properties })
|
|
76
|
+
.filter(([, v]) => v !== undefined && v !== '')
|
|
77
|
+
.map(([k, v]) => ` <property name="${esc(k)}" value="${esc(v)}"/>`)
|
|
78
|
+
.join('\n')
|
|
53
79
|
const cases = flat
|
|
54
80
|
.map((s) => {
|
|
55
81
|
const time = (s.durationMs / 1000).toFixed(3)
|
|
56
82
|
const failed = s.assertions.filter((a) => !a.pass)
|
|
57
83
|
let inner = ''
|
|
58
84
|
if (s.status === 'skipped') inner = '<skipped/>'
|
|
59
|
-
else if (s.status === 'error')
|
|
85
|
+
else if (s.status === 'error')
|
|
86
|
+
inner = `<error message="${esc(s.error ?? 'error')}" type="Error">${esc(s.error ?? 'error')}</error>`
|
|
60
87
|
else if (failed.length) {
|
|
61
88
|
inner = failed
|
|
62
|
-
.map(
|
|
63
|
-
(a)
|
|
64
|
-
|
|
65
|
-
|
|
89
|
+
.map((a) => {
|
|
90
|
+
const lines = diffAssertion(a)
|
|
91
|
+
.filter((l) => l.kind !== 'skip')
|
|
92
|
+
.map(
|
|
93
|
+
(l) =>
|
|
94
|
+
`${l.kind === 'expected' ? '- ' : l.kind === 'received' ? '+ ' : ' '}${l.text}`,
|
|
95
|
+
)
|
|
96
|
+
const at = a.location
|
|
97
|
+
? `\nat ${a.location.file}:${a.location.line}:${a.location.column ?? 1}`
|
|
98
|
+
: ''
|
|
99
|
+
return `<failure message="${esc(a.label)}" type="${esc(a.operator)}">${esc(`${lines.join('\n')}${at}`)}</failure>`
|
|
100
|
+
})
|
|
66
101
|
.join('')
|
|
67
102
|
}
|
|
68
|
-
|
|
103
|
+
if (r.flaky && s.status === 'pass' && (r.attempts ?? 1) > 1)
|
|
104
|
+
inner += `<flakyFailure message="passed on attempt ${r.attempts}"/>`
|
|
105
|
+
const trace =
|
|
106
|
+
s.status === 'fail' || s.status === 'error'
|
|
107
|
+
? `<system-out>${esc(traceLines(s, { origins: o.origins }).join('\n'))}</system-out>`
|
|
108
|
+
: ''
|
|
109
|
+
return ` <testcase classname="${esc(r.file ?? r.id)}" name="${esc(`${r.title} › ${s.name}`)}" time="${time}">${inner}${trace}</testcase>`
|
|
69
110
|
})
|
|
70
111
|
.join('\n')
|
|
71
|
-
|
|
112
|
+
const stamp = r.startedAt || o.timestamp
|
|
113
|
+
return ` <testsuite name="${esc(r.title)}" tests="${flat.length}" failures="${failures}" errors="${errors}" skipped="${skipped}" time="${(r.durationMs / 1000).toFixed(3)}"${stamp ? ` timestamp="${esc(stamp)}"` : ''}${o.hostname ? ` hostname="${esc(o.hostname)}"` : ''}>\n <properties>\n${props}\n </properties>\n${cases}\n </testsuite>`
|
|
72
114
|
})
|
|
73
115
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n${suites.join('\n')}\n</testsuites>\n`
|
|
74
116
|
}
|
|
@@ -82,24 +124,96 @@ export function exitCode(results: RunResult[]): 0 | 1 | 2 {
|
|
|
82
124
|
return 0
|
|
83
125
|
}
|
|
84
126
|
|
|
85
|
-
/**
|
|
86
|
-
export function
|
|
87
|
-
|
|
127
|
+
/** For `specbook test`: 0 when every flow passed (a flaky one counts), 1 when any failed or errored. */
|
|
128
|
+
export function testExitCode(results: RunResult[]): 0 | 1 {
|
|
129
|
+
return results.some((r) => r.status !== 'pass') ? 1 : 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface GithubOptions {
|
|
133
|
+
/** folder the paths are made relative to (the project or repository root) */
|
|
134
|
+
cwd?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A workflow command property: `%`, newlines, `:` and `,` are escaped. */
|
|
138
|
+
const prop = (v: string) =>
|
|
139
|
+
v
|
|
140
|
+
.replace(/%/g, '%25')
|
|
141
|
+
.replace(/\r/g, '%0D')
|
|
142
|
+
.replace(/\n/g, '%0A')
|
|
143
|
+
.replace(/:/g, '%3A')
|
|
144
|
+
.replace(/,/g, '%2C')
|
|
145
|
+
const data = (v: string) => v.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
|
|
146
|
+
|
|
147
|
+
/** GitHub Actions workflow commands: one annotation per failing assertion/step, a warning for a flaky flow. */
|
|
148
|
+
export function formatGithub(results: RunResult[], o: GithubOptions = {}): string {
|
|
149
|
+
const rel = (f: string) => (o.cwd && f.startsWith(`${o.cwd}/`) ? f.slice(o.cwd.length + 1) : f)
|
|
88
150
|
const lines: string[] = []
|
|
89
151
|
const visit = (r: RunResult, s: StepResult) => {
|
|
90
152
|
for (const c of s.children ?? []) visit(r, c)
|
|
91
|
-
if (s.
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
153
|
+
if (s.children?.length) return
|
|
154
|
+
const title = prop(`${r.title}: ${s.name}`)
|
|
155
|
+
const file = r.file ? `file=${prop(r.file)},` : ''
|
|
156
|
+
if (s.status === 'error')
|
|
157
|
+
lines.push(`::error ${file}title=${title}::${data(s.error ?? 'error')}`)
|
|
158
|
+
if (s.status === 'skipped' || s.status === 'error') return
|
|
96
159
|
for (const a of s.assertions.filter((x) => !x.pass)) {
|
|
97
|
-
const loc = a.location ? `file=${
|
|
98
|
-
lines.push(`::error ${loc}title=${
|
|
160
|
+
const loc = a.location ? `file=${prop(rel(a.location.file))},line=${a.location.line},` : file
|
|
161
|
+
lines.push(`::error ${loc}title=${title}::${data(a.label)}`)
|
|
99
162
|
}
|
|
100
163
|
}
|
|
101
|
-
for (const r of results)
|
|
164
|
+
for (const r of results) {
|
|
165
|
+
for (const s of r.steps) visit(r, s)
|
|
166
|
+
if (r.flaky)
|
|
167
|
+
lines.push(
|
|
168
|
+
`::warning ${r.file ? `file=${prop(r.file)},` : ''}title=${prop(`${r.title}: flaky`)}::${data(`passed on attempt ${r.attempts}`)}`,
|
|
169
|
+
)
|
|
170
|
+
}
|
|
102
171
|
const ok = results.filter((r) => r.status === 'pass').length
|
|
103
172
|
lines.push(`${ok}/${results.length} runs passed`)
|
|
104
173
|
return lines.join('\n')
|
|
105
174
|
}
|
|
175
|
+
|
|
176
|
+
/** Markdown for `$GITHUB_STEP_SUMMARY`: totals, a table of flows, and what failed. */
|
|
177
|
+
export function githubSummary(
|
|
178
|
+
results: RunResult[],
|
|
179
|
+
meta: { env: string; runUrl?: string } = { env: '' },
|
|
180
|
+
): string {
|
|
181
|
+
const t = reportTotals(results)
|
|
182
|
+
const mark = (r: RunResult) =>
|
|
183
|
+
r.status === 'pass' ? (r.flaky ? '⚠️ flaky' : '✅') : r.status === 'error' ? '❗ error' : '❌'
|
|
184
|
+
const cell = (s: string) => s.replace(/\|/g, '\\|')
|
|
185
|
+
const lines = [
|
|
186
|
+
`### specbook test${meta.env ? ` · ${meta.env}` : ''}`,
|
|
187
|
+
'',
|
|
188
|
+
`**${t.flows.passed}** passed · **${t.flows.failed + t.flows.errored}** failed · **${t.flows.flaky}** flaky · ${results.length} total`,
|
|
189
|
+
'',
|
|
190
|
+
'| | Flow | Steps | Assertions | Time |',
|
|
191
|
+
'| --- | --- | --- | --- | --- |',
|
|
192
|
+
...results.map(
|
|
193
|
+
(r) =>
|
|
194
|
+
`| ${mark(r)} | ${cell(r.title)} | ${r.totals.passed}/${r.totals.steps} | ${r.totals.assertions.passed}/${r.totals.assertions.passed + r.totals.assertions.failed} | ${(r.durationMs / 1000).toFixed(2)}s |`,
|
|
195
|
+
),
|
|
196
|
+
]
|
|
197
|
+
const bad = results.flatMap((r) =>
|
|
198
|
+
leafSteps(r.steps)
|
|
199
|
+
.filter((s) => s.status === 'fail' || s.status === 'error')
|
|
200
|
+
.map((s) => ({ r, s })),
|
|
201
|
+
)
|
|
202
|
+
if (bad.length) {
|
|
203
|
+
lines.push('', '#### Failures', '')
|
|
204
|
+
for (const { r, s } of bad) {
|
|
205
|
+
lines.push(`- **${cell(r.title)} › ${cell(s.name)}**`)
|
|
206
|
+
if (s.error) lines.push(` - ${s.error}`)
|
|
207
|
+
for (const a of s.assertions.filter((x) => !x.pass)) lines.push(` - ${a.label}`)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (meta.runUrl) lines.push('', `[Run](${meta.runUrl})`)
|
|
211
|
+
return `${lines.join('\n')}\n`
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export * from './ci'
|
|
215
|
+
export * from './color'
|
|
216
|
+
export * from './envelope'
|
|
217
|
+
export * from './list'
|
|
218
|
+
export * from './redact'
|
|
219
|
+
export * from './trace'
|