@specbook/core 0.2.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.
@@ -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
+ }
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 {
@@ -1,3 +1,4 @@
1
1
  export * from './encode'
2
2
  export * from './run'
3
+ export * from './suite'
3
4
  export * from './transport'
package/src/runner/run.ts CHANGED
@@ -23,7 +23,12 @@ import type {
23
23
  VarUse,
24
24
  } from '../types'
25
25
  import { describeFiles, isBlob } from './encode'
26
- import type { PreparedRequest, RawResponse, Transport } from './transport'
26
+ import {
27
+ DEFAULT_TIMEOUT_MS,
28
+ type PreparedRequest,
29
+ type RawResponse,
30
+ type Transport,
31
+ } from './transport'
27
32
 
28
33
  // ---------------- public options ----------------
29
34
 
@@ -41,12 +46,22 @@ export interface AuthProfile {
41
46
  token?: (res: ApiResponse<number, any>) => Session
42
47
  apply: { headers?: Record<string, string>; query?: Record<string, string> }
43
48
  retryOn401?: boolean
44
- /** an interactive sign-in the UI can offer (authorization code with PKCE); the runner ignores it */
49
+ /**
50
+ * an interactive sign-in the UI can offer (authorization code with PKCE); the runner ignores it.
51
+ * `authorizationUrl`, `tokenUrl`, `openIdConnectUrl` and `clientId` may use environment variables
52
+ * (`'{{issuer}}/token'`).
53
+ */
45
54
  oauth?: OAuthSettings
46
55
  }
47
56
 
48
57
  export type RunEvent =
49
- | { type: 'run:start'; id: string }
58
+ | {
59
+ type: 'run:start'
60
+ id: string
61
+ title?: string
62
+ kind?: 'request' | 'sequence'
63
+ tags?: string[]
64
+ }
50
65
  | { type: 'step:start'; name: string }
51
66
  | { type: 'auth:login'; profile: string }
52
67
  | { type: 'request'; name: string; request: RenderedRequest }
@@ -60,21 +75,31 @@ export type RunEvent =
60
75
 
61
76
  export interface RunOptions {
62
77
  env: string
63
- /** config.vars merged with environments[env].vars. */
78
+ /** config.vars, then environments[env].vars, then the environment's file. */
64
79
  vars?: Record<string, unknown>
65
80
  /** Highest-precedence overrides (UI vars panel, CLI --var). */
66
81
  overrides?: Record<string, unknown>
67
- /** Sequence param overrides. */
68
- params?: Record<string, unknown>
82
+ /** Flow input values: the ones a person would set (UI controls, CLI --input). */
83
+ inputs?: Record<string, unknown>
69
84
  transport: Transport
70
85
  /** Resolved secret values, keyed by secret name. Not needed in the mock env. */
71
86
  secrets?: Record<string, string>
87
+ /**
88
+ * Refuse to send a request that may change data (anything but GET, HEAD and OPTIONS, a GraphQL mutation, or a request
89
+ * not marked `safe`). Auth logins are exempt. For runs against real environments that must not write.
90
+ */
91
+ readOnly?: boolean
92
+ /**
93
+ * A request with no `assert` of its own passes only on a 2xx response (recorded as the assertion `status is 2xx`);
94
+ * one with an `assert` is judged by that alone. Off, a request with no `assert` passes whatever the status.
95
+ */
96
+ implicitStatusCheck?: boolean
72
97
  /** Leave `{{secrets.*}}` placeholders in the wire request (a trusted server substitutes them). */
73
98
  deferSecrets?: boolean
74
99
  auth?: Record<string, AuthProfile>
75
100
  /** service -> default auth profile name. */
76
101
  serviceAuth?: Record<string, string>
77
- /** Called at `input()` steps; omit for headless (params override > default > first option). */
102
+ /** Called at `input()` steps; omit for headless (inputs given > default > first option). */
78
103
  onInput?: (req: {
79
104
  name: string
80
105
  controls: ReturnType<typeof describeControls>
@@ -91,6 +116,13 @@ export interface RunOptions {
91
116
  example?: ExampleLoader
92
117
  /** Reads files named by upload fields (path relative to the example's `dir`). */
93
118
  readFile?: FileReader
119
+ /**
120
+ * How long each request waits for an answer, in milliseconds, when the request does not say (`timeoutMs` on the request
121
+ * wins). 0 waits for as long as it takes. Default: 30 seconds.
122
+ */
123
+ timeoutMs?: number
124
+ /** The `mock` environment answers with this status (the UI's "Respond with"). */
125
+ mockStatus?: number
94
126
  }
95
127
 
96
128
  // ---------------- internals ----------------
@@ -222,8 +254,22 @@ function substitute<T>(value: T, rt: Runtime, profile?: string): T {
222
254
  return walk(value) as T
223
255
  }
224
256
 
257
+ /** Events are masked as they are emitted: a listener (a stream, a log) must never see a secret value. */
225
258
  function emit(rt: Runtime, e: RunEvent) {
226
- rt.o.onEvent?.(e)
259
+ rt.o.onEvent?.(rt.mask.size ? maskDeep(e, rt.mask) : e)
260
+ }
261
+
262
+ const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
263
+
264
+ /** True when the request cannot change data: a read method, a GraphQL query, or one marked `safe`. */
265
+ function isReadOnly(def: RequestDef<any, any, any>, prepared: PreparedRequest): boolean {
266
+ if (def.input.safe) return true
267
+ if (READ_METHODS.has(prepared.method)) return true
268
+ if (prepared.encoding === 'graphql') {
269
+ const body = prepared.body as { query?: unknown } | undefined
270
+ return typeof body?.query === 'string' && !/(^|[\s}])mutation\b/.test(body.query)
271
+ }
272
+ return false
227
273
  }
228
274
 
229
275
  function profileFor(rt: Runtime, def: RequestDef<any, any, any>): string | undefined {
@@ -289,13 +335,13 @@ async function ensureSession(
289
335
 
290
336
  /** What earlier steps and the sequence hand to a request. */
291
337
  interface Flow {
292
- params: Record<string, unknown>
338
+ inputs: Record<string, unknown>
293
339
  captured: Record<string, unknown>
294
340
  /** capture name -> the step that produced it */
295
341
  from: Record<string, string>
296
342
  }
297
343
 
298
- const NO_FLOW: Flow = { params: {}, captured: {}, from: {} }
344
+ const NO_FLOW: Flow = { inputs: {}, captured: {}, from: {} }
299
345
 
300
346
  interface ExecOut {
301
347
  result: StepResult
@@ -328,7 +374,7 @@ async function execute(
328
374
  const layers: [VarUse['source'], Record<string, unknown>][] = [
329
375
  ['config', rt.o.vars ?? {}],
330
376
  ['init', evalInit(def.input.init)],
331
- ['param', flow.params],
377
+ ['input', flow.inputs],
332
378
  ['capture', flow.captured],
333
379
  ['override', evalInit(def.varOverrides)],
334
380
  ['override', rt.o.overrides ?? {}],
@@ -388,12 +434,12 @@ async function execute(
388
434
  encoding,
389
435
  mediaType: encoding === 'binary' ? def.op.mediaType : undefined,
390
436
  formArrays: def.op.formArrays,
391
- timeoutMs: def.input.timeoutMs,
392
- mockStatus: rt.o.env === 'mock' ? def.input.mock?.status : undefined,
437
+ timeoutMs: def.input.timeoutMs ?? rt.o.timeoutMs ?? DEFAULT_TIMEOUT_MS,
438
+ mockStatus: rt.o.env === 'mock' ? rt.o.mockStatus : undefined,
393
439
  mock:
394
440
  rt.o.env === 'mock'
395
441
  ? {
396
- config: def.input.mock,
442
+ hook: def.mockHook,
397
443
  vars: scope,
398
444
  pathParams,
399
445
  rawQuery: query,
@@ -412,6 +458,10 @@ async function execute(
412
458
  profile,
413
459
  }
414
460
  emit(rt, { type: 'request', name, request: result.request })
461
+ if (rt.o.readOnly && kind !== 'auth' && !isReadOnly(def, prepared))
462
+ throw new StepError(
463
+ `Refusing ${prepared.method} ${def.op.path} in "${rt.o.env}": this is a --safe run, which only reads.`,
464
+ )
415
465
 
416
466
  const send = async () => {
417
467
  if (profile) await ensureSession(rt, profile, authResults)
@@ -458,6 +508,17 @@ async function execute(
458
508
  for (const r of a.results) {
459
509
  emit(rt, { type: r.pass ? 'assert:pass' : 'assert:fail', name, assertion: r })
460
510
  }
511
+ } else if (rt.o.implicitStatusCheck && kind === 'request') {
512
+ const pass = response.status >= 200 && response.status < 300
513
+ const implicit: AssertionResult = {
514
+ label: 'status is 2xx',
515
+ operator: 'status',
516
+ pass,
517
+ actual: response.status,
518
+ expected: '2xx',
519
+ }
520
+ result.assertions = [implicit]
521
+ emit(rt, { type: pass ? 'assert:pass' : 'assert:fail', name, assertion: implicit })
461
522
  }
462
523
  if (result.status === 'pass' && result.assertions.some((r) => !r.pass)) result.status = 'fail'
463
524
  } catch (e: any) {
@@ -583,7 +644,13 @@ export async function collectSecretNames(
583
644
  }
584
645
 
585
646
  function newRuntime(o: RunOptions): Runtime {
586
- return { o, sessions: new Map(), inflight: new Map(), mask: new Set() }
647
+ // every resolved secret is masked from the start, not only once it has been substituted into a request
648
+ return {
649
+ o,
650
+ sessions: new Map(),
651
+ inflight: new Map(),
652
+ mask: new Set(Object.values(o.secrets ?? {})),
653
+ }
587
654
  }
588
655
 
589
656
  export async function runRequest(
@@ -592,10 +659,22 @@ export async function runRequest(
592
659
  ): Promise<RunResult> {
593
660
  const rt = newRuntime(o)
594
661
  const t0 = o.now?.() ?? now0()
595
- emit(rt, { type: 'run:start', id: def.op.id })
662
+ emit(rt, {
663
+ type: 'run:start',
664
+ id: def.op.id,
665
+ title: def.input.name ?? def.op.id,
666
+ kind: 'request',
667
+ tags: def.input.tags,
668
+ })
596
669
  const name = def.input.name ?? def.op.id
597
670
  const { result, authResults } = await execute(rt, def, name, NO_FLOW)
598
- return finish(rt, 'request', { id: def.op.id, title: name }, [...authResults, result], t0)
671
+ return finish(
672
+ rt,
673
+ 'request',
674
+ { id: def.op.id, title: name, tags: def.input.tags },
675
+ [...authResults, result],
676
+ t0,
677
+ )
599
678
  }
600
679
 
601
680
  export async function runSequence(seq: Sequence<any, any>, o: RunOptions): Promise<RunResult> {
@@ -603,7 +682,13 @@ export async function runSequence(seq: Sequence<any, any>, o: RunOptions): Promi
603
682
  rt.seqAuth = seq.meta.auth
604
683
  rt.seqAssertMode = seq.meta.assertMode
605
684
  const t0 = o.now?.() ?? now0()
606
- emit(rt, { type: 'run:start', id: seq.meta.id })
685
+ emit(rt, {
686
+ type: 'run:start',
687
+ id: seq.meta.id,
688
+ title: seq.meta.title,
689
+ kind: 'sequence',
690
+ tags: seq.meta.tags,
691
+ })
607
692
  const steps: StepResult[] = []
608
693
  const captured: Record<string, any> = {}
609
694
  const from: Record<string, string> = {}
@@ -611,12 +696,12 @@ export async function runSequence(seq: Sequence<any, any>, o: RunOptions): Promi
611
696
  Object.assign(captured, values)
612
697
  for (const k of Object.keys(values)) from[k] = step
613
698
  }
614
- let params: Record<string, unknown>
699
+ let inputs: Record<string, unknown>
615
700
  try {
616
- params = resolveControls(seq.meta.params ?? {}, o.params)
701
+ inputs = resolveControls(seq.meta.inputs ?? {}, o.inputs)
617
702
  } catch (e: any) {
618
703
  steps.push({
619
- name: 'params',
704
+ name: 'inputs',
620
705
  kind: 'input',
621
706
  status: 'error',
622
707
  assertions: [],
@@ -628,7 +713,7 @@ export async function runSequence(seq: Sequence<any, any>, o: RunOptions): Promi
628
713
  }
629
714
  const vars = o.vars ?? {}
630
715
  const ctx = (): StepCtx<any, any> => ({
631
- params,
716
+ inputs,
632
717
  captured,
633
718
  vars: { ...vars, ...(o.overrides ?? {}) },
634
719
  env: o.env,
@@ -652,7 +737,7 @@ export async function runSequence(seq: Sequence<any, any>, o: RunOptions): Promi
652
737
  if (node.kind === 'step') {
653
738
  const def = typeof node.def === 'function' ? node.def(ctx()) : node.def
654
739
  const { result, authResults } = await execute(rt, def, node.name, {
655
- params,
740
+ inputs,
656
741
  captured,
657
742
  from,
658
743
  })
@@ -675,7 +760,7 @@ export async function runSequence(seq: Sequence<any, any>, o: RunOptions): Promi
675
760
  for (let i = 0; i < items.length; i++) {
676
761
  const def = node.def(items[i], ctx(), i)
677
762
  const { result, authResults } = await execute(rt, def, `${node.name} #${i + 1}`, {
678
- params,
763
+ inputs,
679
764
  captured,
680
765
  from,
681
766
  })
@@ -700,9 +785,9 @@ export async function runSequence(seq: Sequence<any, any>, o: RunOptions): Promi
700
785
  emit(rt, { type: 'input:required', name: node.name, controls: described })
701
786
  const given = o.onInput
702
787
  ? await o.onInput({ name: node.name, controls: described })
703
- : (o.params ?? {})
788
+ : (o.inputs ?? {})
704
789
  const values = resolveControls(controls, given)
705
- params = { ...params, ...values }
790
+ inputs = { ...inputs, ...values }
706
791
  steps.push({
707
792
  name: node.name,
708
793
  kind: 'input',
@@ -0,0 +1,122 @@
1
+ // Running a list of requests and flows the way `specbook test` does: in order, retrying a failure, stopping at a number of
2
+ // failures or at a global deadline. The CLI and the docs site's terminal share it, so what the page shows is what a
3
+ // terminal does. Nothing here touches files or the process.
4
+
5
+ import type { Colors } from '../reporters/color'
6
+ import type { Sequence } from '../sequence'
7
+ import type { RequestDef, RunResult } from '../types'
8
+ import { type RunOptions, runRequest, runSequence } from './run'
9
+
10
+ export interface SuiteTarget {
11
+ title: string
12
+ /** project file, shown in reports */
13
+ file?: string
14
+ tags: string[]
15
+ run: RequestDef<any, any, any> | Sequence<any, any>
16
+ }
17
+
18
+ export interface SuiteOptions {
19
+ /** what every run is given; `signal` is the suite's own */
20
+ run: Omit<RunOptions, 'signal'>
21
+ /** extra tries for a failing target (0 = one try) */
22
+ retries?: number
23
+ /** stop after this many failed targets */
24
+ bail?: number
25
+ /** milliseconds after which no further target starts, and a running one is aborted */
26
+ globalTimeoutMs?: number
27
+ /** called before each try, e.g. to seed faker again */
28
+ beforeAttempt?: () => void
29
+ /** called when a failing target is about to be tried again */
30
+ onRetry?: (target: SuiteTarget, attempt: number, allowed: number) => void
31
+ }
32
+
33
+ export interface SuiteResult {
34
+ results: RunResult[]
35
+ failures: number
36
+ /** targets that did not start */
37
+ notRun: number
38
+ /** why the suite stopped early; empty when it did not */
39
+ stopped: string
40
+ }
41
+
42
+ export async function runSuite(targets: SuiteTarget[], o: SuiteOptions): Promise<SuiteResult> {
43
+ const controller = new AbortController()
44
+ const deadline = o.globalTimeoutMs
45
+ ? setTimeout(() => controller.abort(), o.globalTimeoutMs)
46
+ : undefined
47
+ const results: RunResult[] = []
48
+ const allowed = 1 + Math.max(0, o.retries ?? 0)
49
+ let failures = 0
50
+ let notRun = 0
51
+ let stopped = ''
52
+ for (const [i, t] of targets.entries()) {
53
+ if (controller.signal.aborted) {
54
+ notRun = targets.length - i
55
+ stopped = `stopped: the global timeout of ${o.globalTimeoutMs}ms passed`
56
+ break
57
+ }
58
+ let result: RunResult | undefined
59
+ let attempt = 0
60
+ while (attempt < allowed) {
61
+ attempt++
62
+ o.beforeAttempt?.()
63
+ const opts = { ...o.run, signal: controller.signal }
64
+ result =
65
+ t.run.kind === 'sequence' ? await runSequence(t.run, opts) : await runRequest(t.run, opts)
66
+ if (result.status === 'pass' || controller.signal.aborted) break
67
+ if (attempt < allowed) o.onRetry?.(t, attempt + 1, allowed)
68
+ }
69
+ const final = result as RunResult
70
+ results.push({
71
+ ...final,
72
+ file: t.file,
73
+ attempts: attempt,
74
+ flaky: final.status === 'pass' && attempt > 1,
75
+ tags: final.tags.length ? final.tags : t.tags,
76
+ })
77
+ if (final.status !== 'pass') failures++
78
+ if (o.bail && failures >= o.bail && i < targets.length - 1) {
79
+ notRun = targets.length - i - 1
80
+ stopped = `stopped after ${failures} failed ${failures === 1 ? 'flow' : 'flows'} (--bail)`
81
+ break
82
+ }
83
+ }
84
+ if (deadline) clearTimeout(deadline)
85
+ return { results, failures, notRun, stopped }
86
+ }
87
+
88
+ /** The line a run opens with: the tool and version, then what is being run and where. */
89
+ export function headerLine(
90
+ h: {
91
+ version: string
92
+ env: string
93
+ picked: number
94
+ total: number
95
+ /** `main@abc1234`, `bundle <sha> built <date>` */
96
+ where?: string[]
97
+ },
98
+ colors: Colors,
99
+ ): string {
100
+ return `${colors.bold(`specbook test ${h.version}`)} ${colors.dim(
101
+ [
102
+ `env ${h.env}`,
103
+ `${h.picked}${h.picked < h.total ? `/${h.total}` : ''} tests`,
104
+ ...(h.where ?? []),
105
+ ].join(' · '),
106
+ )}\n\n`
107
+ }
108
+
109
+ /** What a retry looks like in a terminal. */
110
+ export const retryLine = (colors: Colors, title: string, attempt: number, allowed: number) =>
111
+ ` ${colors.yellow('↻')} retrying ${title} (attempt ${attempt} of ${allowed})\n`
112
+
113
+ /** A bundle's assertions point into the bundled file, which tells a reader nothing. */
114
+ export function withoutLocations(results: RunResult[]): RunResult[] {
115
+ const strip = (steps: RunResult['steps']): RunResult['steps'] =>
116
+ steps.map((s) => ({
117
+ ...s,
118
+ assertions: s.assertions.map((a) => ({ ...a, location: undefined })),
119
+ ...(s.children ? { children: strip(s.children) } : {}),
120
+ }))
121
+ return results.map((r) => ({ ...r, steps: strip(r.steps) }))
122
+ }