@specbook/core 0.1.0 → 0.3.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.
@@ -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
- /[<>&"']/g,
44
- (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[c] as string,
46
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: characters XML 1.0 forbids
47
+ /[<>&"']|[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g,
48
+ (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[c] ?? '',
45
49
  )
46
50
 
47
- export function toJUnit(results: RunResult[]): string {
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 → origin, 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: StepResult[] = []
50
- const walk = (s: StepResult) => (s.children?.length ? s.children.forEach(walk) : flat.push(s))
51
- r.steps.forEach(walk)
52
- const failures = flat.filter((s) => s.status === 'fail' || s.status === 'error').length
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') inner = `<error message="${esc(s.error ?? '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
- `<failure message="${esc(a.label)}">${esc(`expected: ${JSON.stringify(a.expected)}\nactual: ${JSON.stringify(a.actual)}`)}</failure>`,
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
- return ` <testcase classname="${esc(r.id)}" name="${esc(s.name)}" time="${time}">${inner}</testcase>`
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
- return ` <testsuite name="${esc(r.title)}" tests="${flat.length}" failures="${failures}" time="${(r.durationMs / 1000).toFixed(3)}">\n${cases}\n </testsuite>`
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
- /** GitHub Actions workflow commands: one annotation per failing assertion/step. */
86
- export function formatGithub(results: RunResult[]): string {
87
- const esc = (v: string) => v.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
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.status === 'error' || s.status === 'skipped') {
92
- if (s.status === 'error')
93
- lines.push(`::error title=${esc(`${r.title}: ${s.name}`)}::${esc(s.error ?? 'error')}`)
94
- return
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=${esc(a.location.file)},line=${a.location.line},` : ''
98
- lines.push(`::error ${loc}title=${esc(`${r.title}: ${s.name}`)}::${esc(a.label)}`)
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) for (const s of r.steps) visit(r, s)
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'
@@ -0,0 +1,254 @@
1
+ // The default report: steps as they finish, then what failed (with the assertion, a source frame and the trace) and a summary.
2
+
3
+ import { type DiffLine, diffAssertion } from '../diff'
4
+ import type { RunEvent } from '../runner/run'
5
+ import type { AssertionResult, RunResult, StepResult } from '../types'
6
+ import { type Colors, createColors } from './color'
7
+ import { reportTotals } from './envelope'
8
+ import { type TraceOptions, traceLines } from './trace'
9
+
10
+ export interface ListOptions {
11
+ colors?: Colors
12
+ /** only failures and the summary */
13
+ quiet?: boolean
14
+ /** show the request and response of a failed step (default), of every step, or not at all */
15
+ trace?: 'on-failure' | 'always' | 'off'
16
+ /** service → origin, to show full addresses in a trace */
17
+ origins?: Record<string, string>
18
+ /** paths are shown relative to this folder */
19
+ cwd?: string
20
+ /** the text of a project file, for the source frame of a failed assertion */
21
+ readSource?: (file: string) => string | undefined
22
+ /** the command that runs just this flow again */
23
+ rerun?: (r: RunResult) => string | undefined
24
+ /** how long the whole run took, for the summary */
25
+ durationMs?: number
26
+ }
27
+
28
+ const MARKS = { pass: '✓', fail: '✗', error: '!', skipped: '–' } as const
29
+
30
+ export const formatDuration = (ms: number): string =>
31
+ ms < 1000
32
+ ? `${Math.round(ms)}ms`
33
+ : ms < 60_000
34
+ ? `${(ms / 1000).toFixed(2)}s`
35
+ : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`
36
+
37
+ const leaves = (steps: StepResult[]): StepResult[] =>
38
+ steps.flatMap((s) => (s.children?.length ? leaves(s.children) : [s]))
39
+
40
+ function paint(status: StepResult['status'], c: Colors, text: string): string {
41
+ return status === 'pass'
42
+ ? c.green(text)
43
+ : status === 'skipped'
44
+ ? c.dim(text)
45
+ : status === 'error'
46
+ ? c.yellow(text)
47
+ : c.red(text)
48
+ }
49
+
50
+ interface StepFacts {
51
+ status: StepResult['status']
52
+ flow: string
53
+ step: string
54
+ method?: string
55
+ url?: string
56
+ code?: number
57
+ ms?: number
58
+ }
59
+
60
+ /** `✓ Checkout › Create order POST /orders 201 85ms` */
61
+ export function stepLine(f: StepFacts, c: Colors, indent = ' '): string {
62
+ const detail = [
63
+ f.method && f.url ? `${f.method} ${f.url}` : '',
64
+ f.code ? String(f.code) : '',
65
+ f.ms !== undefined ? formatDuration(f.ms) : '',
66
+ ]
67
+ .filter(Boolean)
68
+ .join(' ')
69
+ // a request that is its own flow is named once
70
+ const name = f.flow === f.step ? f.step : `${f.flow} › ${f.step}`
71
+ return `${indent}${paint(f.status, c, MARKS[f.status])} ${name}${detail ? ` ${c.dim(detail)}` : ''}`
72
+ }
73
+
74
+ function diffText(lines: DiffLine[], c: Colors, indent: string): string[] {
75
+ return lines.map((l) =>
76
+ l.kind === 'expected'
77
+ ? `${indent}${c.green(`- ${l.text}`)}`
78
+ : l.kind === 'received'
79
+ ? `${indent}${c.red(`+ ${l.text}`)}`
80
+ : l.kind === 'skip'
81
+ ? `${indent}${c.dim(` ${l.text}`)}`
82
+ : `${indent}${c.dim(` ${l.text}`)}`,
83
+ )
84
+ }
85
+
86
+ /** The lines around `line` with a caret under `column`, from a file's text. */
87
+ export function codeFrame(
88
+ source: string,
89
+ line: number,
90
+ column = 1,
91
+ c: Colors = createColors(false),
92
+ around = 2,
93
+ ): string[] {
94
+ const lines = source.split('\n')
95
+ const from = Math.max(1, line - around)
96
+ const to = Math.min(lines.length, line + around)
97
+ const width = String(to).length
98
+ const out: string[] = []
99
+ for (let n = from; n <= to; n++) {
100
+ const text = lines[n - 1] ?? ''
101
+ const mark = n === line ? c.red('>') : ' '
102
+ out.push(`${mark} ${c.dim(String(n).padStart(width))} ${c.dim('|')} ${text}`)
103
+ if (n === line)
104
+ out.push(
105
+ ` ${' '.repeat(width)} ${c.dim('|')} ${' '.repeat(Math.max(0, column - 1))}${c.red('^')}`,
106
+ )
107
+ }
108
+ return out
109
+ }
110
+
111
+ const rel = (file: string, cwd?: string) =>
112
+ cwd && file.startsWith(`${cwd}/`) ? file.slice(cwd.length + 1) : file
113
+
114
+ function assertionBlock(a: AssertionResult, o: ListOptions, c: Colors): string[] {
115
+ const out = [` ${c.red('✗')} ${a.label}`]
116
+ const diff = diffAssertion(a)
117
+ if (diff.length) {
118
+ out.push(` ${c.green('- Expected')} ${c.red('+ Received')}`)
119
+ out.push(...diffText(diff, c, ' '))
120
+ }
121
+ if (a.location) {
122
+ out.push(
123
+ ` ${c.dim(`at ${rel(a.location.file, o.cwd)}:${a.location.line}:${a.location.column ?? 1}`)}`,
124
+ )
125
+ const text = o.readSource?.(a.location.file)
126
+ if (text !== undefined)
127
+ for (const l of codeFrame(text, a.location.line, a.location.column, c))
128
+ out.push(` ${l}`)
129
+ }
130
+ return out
131
+ }
132
+
133
+ /** One block per failed or errored step: the assertions that failed, why a step errored, and its trace. */
134
+ export function formatFailures(results: RunResult[], o: ListOptions = {}): string {
135
+ const c = o.colors ?? createColors(false)
136
+ const out: string[] = []
137
+ for (const r of results) {
138
+ for (const s of leaves(r.steps)) {
139
+ if (s.status !== 'fail' && s.status !== 'error') continue
140
+ const where = r.file ? `${r.file} › ` : ''
141
+ out.push(
142
+ '',
143
+ `${c.bold(c.red(' FAIL '))} ${where}${r.title === s.name ? r.title : `${r.title} › ${s.name}`} ${c.dim(`[${r.env}] ${formatDuration(s.durationMs)}`)}`,
144
+ )
145
+ if (s.status === 'error') out.push(` ${c.yellow('!')} ${s.error ?? 'error'}`)
146
+ for (const a of s.assertions) if (!a.pass) out.push(...assertionBlock(a, o, c))
147
+ if ((o.trace ?? 'on-failure') !== 'off')
148
+ for (const l of traceLines(s, { origins: o.origins })) out.push(` ${c.dim(l)}`)
149
+ const rerun = o.rerun?.(r)
150
+ if (rerun) out.push(` ${c.dim(`Re-run: ${rerun}`)}`)
151
+ }
152
+ }
153
+ return out.join('\n')
154
+ }
155
+
156
+ /** `Flows 8 passed | 1 failed | 1 flaky`, steps, assertions, time and the slowest steps. */
157
+ export function formatSummary(results: RunResult[], o: ListOptions = {}): string {
158
+ const c = o.colors ?? createColors(false)
159
+ const t = reportTotals(results)
160
+ const part = (n: number, word: string, paintFn: (s: string) => string) =>
161
+ n ? paintFn(`${n} ${word}`) : ''
162
+ const join = (...parts: string[]) => parts.filter(Boolean).join(c.dim(' | ')) || c.dim('none')
163
+ const lines = [
164
+ '',
165
+ `${c.bold(' Flows ')} ${join(
166
+ part(t.flows.failed, 'failed', c.red),
167
+ part(t.flows.errored, 'errored', c.yellow),
168
+ part(t.flows.flaky, 'flaky', c.yellow),
169
+ part(t.flows.passed, 'passed', c.green),
170
+ )} ${c.dim(`(${results.length})`)}`,
171
+ `${c.bold(' Steps ')} ${join(
172
+ part(t.steps.failed, 'failed', c.red),
173
+ part(t.steps.skipped, 'skipped', c.dim),
174
+ part(t.steps.passed, 'passed', c.green),
175
+ )}`,
176
+ `${c.bold(' Assertions ')} ${join(
177
+ part(t.assertions.failed, 'failed', c.red),
178
+ part(t.assertions.passed, 'passed', c.green),
179
+ )}`,
180
+ ]
181
+ const total = o.durationMs ?? results.reduce((n, r) => n + r.durationMs, 0)
182
+ lines.push(`${c.bold(' Duration ')} ${formatDuration(total)}`)
183
+ const slow = results
184
+ .flatMap((r) =>
185
+ leaves(r.steps)
186
+ .filter((s) => s.status !== 'skipped')
187
+ // a request is a flow of one step: its name is said once
188
+ .map((s) => ({
189
+ name: r.title === s.name ? r.title : `${r.title} › ${s.name}`,
190
+ ms: s.durationMs,
191
+ })),
192
+ )
193
+ .sort((a, b) => b.ms - a.ms)
194
+ .slice(0, 5)
195
+ if (slow.length > 1) {
196
+ const times = slow.map((s) => formatDuration(s.ms))
197
+ const width = Math.max(...times.map((t) => t.length))
198
+ lines.push(c.bold(' Slowest'))
199
+ for (const [i, s] of slow.entries())
200
+ lines.push(` ${c.dim(`${(times[i] as string).padStart(width)} ${s.name}`)}`)
201
+ }
202
+ return lines.join('\n')
203
+ }
204
+
205
+ function stepFacts(r: RunResult, s: StepResult): StepFacts {
206
+ return {
207
+ status: s.status,
208
+ flow: r.title,
209
+ step: s.name,
210
+ method: s.request?.method,
211
+ url: s.request?.url,
212
+ code: s.response?.status,
213
+ ms: s.status === 'skipped' ? undefined : s.durationMs,
214
+ }
215
+ }
216
+
217
+ /** The whole report at once, from finished results (what the streaming reporter prints, in one string). */
218
+ export function formatList(results: RunResult[], o: ListOptions = {}): string {
219
+ const c = o.colors ?? createColors(false)
220
+ const lines: string[] = []
221
+ if (!o.quiet)
222
+ for (const r of results)
223
+ for (const s of leaves(r.steps)) lines.push(stepLine(stepFacts(r, s), c))
224
+ const failures = formatFailures(results, o)
225
+ return [lines.join('\n'), failures, formatSummary(results, o)].filter((x) => x !== '').join('\n')
226
+ }
227
+
228
+ /** Prints each step as it finishes, from the run's events; at the end prints the failures and the summary. */
229
+ export function createListReporter(o: ListOptions & { write(text: string): void }) {
230
+ const c = o.colors ?? createColors(false)
231
+ let flow = ''
232
+ const facts = new Map<string, Partial<StepFacts>>()
233
+ return {
234
+ onEvent(e: RunEvent) {
235
+ if (e.type === 'run:start') flow = e.title ?? e.id
236
+ else if (e.type === 'request')
237
+ facts.set(e.name, { ...facts.get(e.name), method: e.request.method, url: e.request.url })
238
+ else if (e.type === 'response')
239
+ facts.set(e.name, { ...facts.get(e.name), code: e.status, ms: e.durationMs })
240
+ else if (e.type === 'step:end') {
241
+ const f = facts.get(e.name)
242
+ facts.delete(e.name)
243
+ if (e.status === 'pass' && o.quiet) return
244
+ o.write(`${stepLine({ ...f, status: e.status, flow, step: e.name }, c)}\n`)
245
+ }
246
+ },
247
+ onEnd(results: RunResult[]) {
248
+ const rest = [formatFailures(results, o), formatSummary(results, o)].filter((x) => x !== '')
249
+ o.write(`${rest.join('\n')}\n`)
250
+ },
251
+ }
252
+ }
253
+
254
+ export type { TraceOptions }
@@ -0,0 +1,113 @@
1
+ // What a report may show of a run. Resolved secret values are already masked by the runner; this removes what is secret
2
+ // without being one of ours: credentials in headers, and tokens and passwords that an API returns or a request captures.
3
+
4
+ import type { RunEvent } from '../runner/run'
5
+ import type { AssertionResult, RunResult, StepResult } from '../types'
6
+
7
+ export const REDACTED = '***'
8
+
9
+ /** Headers whose values are never shown, whatever they hold. */
10
+ const DENIED_HEADERS = new Set([
11
+ 'authorization',
12
+ 'proxy-authorization',
13
+ 'cookie',
14
+ 'set-cookie',
15
+ 'x-api-key',
16
+ 'x-auth-token',
17
+ 'x-csrf-token',
18
+ 'x-xsrf-token',
19
+ ])
20
+
21
+ export const isDeniedHeader = (name: string) => DENIED_HEADERS.has(name.toLowerCase())
22
+
23
+ /** `accessToken`, `access_token`, `client-secret` → words joined by `_`, lowercase. */
24
+ const normalize = (key: string) =>
25
+ key
26
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
27
+ .replace(/[-.\s]+/g, '_')
28
+ .toLowerCase()
29
+
30
+ const SENSITIVE =
31
+ /(^|_)(tokens?|secrets?|passwords?|passwd|authorization|credentials?|cookies?|api_?keys?|private_?keys?|access_?keys?|secret_?keys?)$/
32
+
33
+ /** Whether a property named like this holds something that should not be printed (`access_token`, `password`, `apiKey`). */
34
+ export const isSensitiveKey = (key: string) => SENSITIVE.test(normalize(key))
35
+
36
+ function redactValue(v: unknown): unknown {
37
+ if (Array.isArray(v)) return v.map(redactValue)
38
+ if (v !== null && typeof v === 'object') {
39
+ return Object.fromEntries(
40
+ Object.entries(v).map(([k, x]) => [
41
+ k,
42
+ isSensitiveKey(k) && (typeof x === 'string' || typeof x === 'number')
43
+ ? REDACTED
44
+ : redactValue(x),
45
+ ]),
46
+ )
47
+ }
48
+ return v
49
+ }
50
+
51
+ export function redactHeaders(headers: Record<string, string>): Record<string, string> {
52
+ return Object.fromEntries(
53
+ Object.entries(headers).map(([k, v]) => [k, isDeniedHeader(k) ? REDACTED : v]),
54
+ )
55
+ }
56
+
57
+ function redactAssertion(a: AssertionResult): AssertionResult {
58
+ const hasValues = 'actual' in a || 'expected' in a
59
+ return {
60
+ ...a,
61
+ actual: redactValue(a.actual),
62
+ expected: redactValue(a.expected),
63
+ // a recorded diff is text made from the values; with the values present it is rebuilt from the clean ones
64
+ diff: hasValues ? undefined : a.diff,
65
+ }
66
+ }
67
+
68
+ function redactStep(s: StepResult): StepResult {
69
+ return {
70
+ ...s,
71
+ assertions: s.assertions.map(redactAssertion),
72
+ request: s.request
73
+ ? {
74
+ ...s.request,
75
+ headers: redactHeaders(s.request.headers),
76
+ body: redactValue(s.request.body),
77
+ }
78
+ : undefined,
79
+ response: s.response
80
+ ? {
81
+ ...s.response,
82
+ headers: redactHeaders(s.response.headers),
83
+ body: redactValue(s.response.body),
84
+ }
85
+ : undefined,
86
+ captured: redactValue(s.captured) as Record<string, unknown>,
87
+ children: s.children?.map(redactStep),
88
+ }
89
+ }
90
+
91
+ /** A copy of the results that is safe to print, attach to a build or send to a monitoring service. */
92
+ export function redactResults(results: RunResult[]): RunResult[] {
93
+ return results.map((r) => ({ ...r, steps: r.steps.map(redactStep) }))
94
+ }
95
+
96
+ /** The same for one event of a run, before it is written to a log or a stream. */
97
+ export function redactEvent(e: RunEvent): RunEvent {
98
+ if (e.type === 'request')
99
+ return {
100
+ ...e,
101
+ request: {
102
+ ...e.request,
103
+ headers: redactHeaders(e.request.headers),
104
+ body: redactValue(e.request.body),
105
+ },
106
+ }
107
+ if (e.type === 'capture')
108
+ return { ...e, captured: redactValue(e.captured) as Record<string, unknown> }
109
+ if (e.type === 'assert:pass' || e.type === 'assert:fail')
110
+ return { ...e, assertion: redactAssertion(e.assertion) }
111
+ if (e.type === 'done') return { ...e, result: redactResults([e.result])[0] as RunResult }
112
+ return e
113
+ }
@@ -0,0 +1,70 @@
1
+ // The request and response of a step as text, for a failure: what was sent, what came back, redacted and shortened.
2
+
3
+ import { statusText } from '../diff'
4
+ import type { StepResult } from '../types'
5
+ import { isDeniedHeader, REDACTED } from './redact'
6
+
7
+ /** Response headers worth showing: the rest is noise in a failure. */
8
+ const SHOWN_RESPONSE_HEADERS = [
9
+ 'content-type',
10
+ 'x-request-id',
11
+ 'x-correlation-id',
12
+ 'x-amzn-trace-id',
13
+ 'traceparent',
14
+ 'retry-after',
15
+ 'location',
16
+ ]
17
+
18
+ export interface TraceOptions {
19
+ /** service → origin (`https://api.example.com`), to show the full address */
20
+ origins?: Record<string, string>
21
+ /** characters of a body shown before it is cut (default 2048) */
22
+ maxBody?: number
23
+ }
24
+
25
+ function body(v: unknown, max: number): string[] {
26
+ if (v === undefined || v === null || v === '') return []
27
+ let text: string
28
+ try {
29
+ text = typeof v === 'string' ? v : (JSON.stringify(v, null, 2) ?? String(v))
30
+ } catch {
31
+ text = String(v)
32
+ }
33
+ const cut = text.length > max
34
+ const lines = (cut ? text.slice(0, max) : text).split('\n')
35
+ if (cut) lines.push(`… ${text.length - max} more characters`)
36
+ return lines
37
+ }
38
+
39
+ const query = (q: Record<string, string | string[]>) => {
40
+ const parts = Object.entries(q).flatMap(([k, v]) =>
41
+ (Array.isArray(v) ? v : [v]).map((x) => `${k}=${x}`),
42
+ )
43
+ return parts.length ? `?${parts.join('&')}` : ''
44
+ }
45
+
46
+ /** The trace of one step as lines (without indentation): `Request`, its headers and body, `Response`, and the same. */
47
+ export function traceLines(step: StepResult, o: TraceOptions = {}): string[] {
48
+ const max = o.maxBody ?? 2048
49
+ const out: string[] = []
50
+ const r = step.request
51
+ if (r) {
52
+ const origin = o.origins?.[r.service] ?? ''
53
+ out.push(`Request ${r.method} ${origin}${r.url}${query(r.query)}`)
54
+ for (const [k, v] of Object.entries(r.headers))
55
+ out.push(` ${k}: ${isDeniedHeader(k) ? REDACTED : v}`)
56
+ for (const l of body(r.body, max)) out.push(` ${l}`)
57
+ }
58
+ const res = step.response
59
+ if (res) {
60
+ out.push(
61
+ `Response ${[res.status, statusText(res.status)].filter(Boolean).join(' ')} ${res.durationMs}ms`,
62
+ )
63
+ for (const name of SHOWN_RESPONSE_HEADERS) {
64
+ const v = res.headers[name]
65
+ if (v !== undefined) out.push(` ${name}: ${isDeniedHeader(name) ? REDACTED : v}`)
66
+ }
67
+ for (const l of body(res.body, max)) out.push(` ${l}`)
68
+ }
69
+ return out
70
+ }