@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
|
@@ -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 → the address its calls go to (origin and base path), so a trace shows the URL that was called */
|
|
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 → the address its calls go to (`https://api.example.com/v1`: origin and base path), to show the full URL */
|
|
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
|
+
}
|
package/src/request.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { deepMerge } from './template'
|
|
|
2
2
|
import type {
|
|
3
3
|
AnyOp,
|
|
4
4
|
Init,
|
|
5
|
+
MockHook,
|
|
5
6
|
Operation,
|
|
6
7
|
OpMeta,
|
|
7
8
|
OpTypes,
|
|
@@ -15,6 +16,7 @@ function make<T extends OpTypes, I extends Init, C>(
|
|
|
15
16
|
input: RequestInput<T, I, C>,
|
|
16
17
|
dataPatches: readonly unknown[],
|
|
17
18
|
varOverrides: Readonly<Record<string, unknown>> = {},
|
|
19
|
+
mockHook?: MockHook<T, I>,
|
|
18
20
|
): RequestDef<T, I, C> {
|
|
19
21
|
return {
|
|
20
22
|
kind: 'request',
|
|
@@ -22,6 +24,7 @@ function make<T extends OpTypes, I extends Init, C>(
|
|
|
22
24
|
input,
|
|
23
25
|
dataPatches,
|
|
24
26
|
varOverrides,
|
|
27
|
+
mockHook,
|
|
25
28
|
with<C2>(o: RequestOverrides<T, I, C2>) {
|
|
26
29
|
const next: RequestInput<T, any, any> = { ...input }
|
|
27
30
|
if (o.path) next.path = deepMerge(input.path as any, o.path) as any
|
|
@@ -29,16 +32,31 @@ function make<T extends OpTypes, I extends Init, C>(
|
|
|
29
32
|
if (o.headers) next.headers = { ...(input.headers ?? {}), ...o.headers }
|
|
30
33
|
if (o.capture) next.capture = { ...(input.capture ?? {}), ...o.capture } as any
|
|
31
34
|
if (o.assert) next.assert = o.assert
|
|
32
|
-
if (o.mock) next.mock = { ...(input.mock ?? {}), ...o.mock } as any
|
|
33
35
|
if (o.auth !== undefined) next.auth = o.auth
|
|
34
36
|
if (o.assertMode) next.assertMode = o.assertMode
|
|
37
|
+
if (o.tags) next.tags = [...new Set([...(input.tags ?? []), ...o.tags])]
|
|
35
38
|
const patches = o.data !== undefined ? [...dataPatches, o.data] : dataPatches
|
|
36
39
|
const overrides = o.vars ? { ...varOverrides, ...o.vars } : varOverrides
|
|
37
|
-
return make(op, next, patches, overrides) as RequestDef<T, I, C & C2>
|
|
40
|
+
return make(op, next, patches, overrides, mockHook) as RequestDef<T, I, C & C2>
|
|
38
41
|
},
|
|
39
42
|
}
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
/**
|
|
46
|
+
* How the `mock` environment answers this request, when the generated example (filled from the request) is not enough.
|
|
47
|
+
* Rarely needed; `req` and `res` are typed from the operation. Returns the request, so it is the file's one export:
|
|
48
|
+
*
|
|
49
|
+
* export default withMock(GetOrder.request({ ... }), (req, res) => {
|
|
50
|
+
* if (res.status === 200) res.data.status = 'placed'
|
|
51
|
+
* })
|
|
52
|
+
*/
|
|
53
|
+
export function withMock<T extends OpTypes, I extends Init, C>(
|
|
54
|
+
def: RequestDef<T, I, C>,
|
|
55
|
+
hook: MockHook<T, I>,
|
|
56
|
+
): RequestDef<T, I, C> {
|
|
57
|
+
return make(def.op, def.input, def.dataPatches, def.varOverrides, hook)
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
/** Generated code calls this once per operation. */
|
|
43
61
|
export function defineOperation<T extends OpTypes = AnyOp>(meta: OpMeta): Operation<T> {
|
|
44
62
|
return {
|
package/src/runner/index.ts
CHANGED