@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specbook/core",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "The runner, mock engine, template engine and shared proxy behind Specbook: Storybook for APIs.",
5
5
  "keywords": [
6
6
  "specbook",
package/src/assert.ts CHANGED
@@ -9,13 +9,24 @@ export const stripAnsi = (s: string) => s.replace(ANSI, '')
9
9
  /** Plain uvu/assert, for use outside a run. */
10
10
  export const assert = uvu as unknown as Assert
11
11
 
12
- function locate(stack: string | undefined): AssertionResult['location'] {
12
+ const FRAME = /(?:\(|at )?([^\s()]+):(\d+):(\d+)\)?\s*$/
13
+
14
+ /**
15
+ * Where in the user's code an assertion was made: the first frame of the stack that is not this helper. Run from source
16
+ * the helper is `core/src/assert`; inside a bundled CLI it is the bundle itself, so any frame in the file the stack starts
17
+ * in is the helper too.
18
+ */
19
+ export function locate(stack: string | undefined): AssertionResult['location'] {
13
20
  if (!stack) return undefined
14
- const lines = stack.split('\n').slice(1)
15
- for (const line of lines) {
16
- if (line.includes('/core/src/assert') || line.includes('node_modules')) continue
17
- const m = /(?:\(|at )?([^\s()]+):(\d+):(\d+)\)?\s*$/.exec(line)
18
- if (m) return { file: m[1] as string, line: Number(m[2]), column: Number(m[3]) }
21
+ const frames = stack
22
+ .split('\n')
23
+ .slice(1)
24
+ .map((line) => ({ line, m: FRAME.exec(line) }))
25
+ const own = frames[0]?.m?.[1]
26
+ for (const { line, m } of frames) {
27
+ if (!m || line.includes('/core/src/assert') || line.includes('node_modules')) continue
28
+ if (m[1] === own) continue
29
+ return { file: m[1] as string, line: Number(m[2]), column: Number(m[3]) }
19
30
  }
20
31
  return undefined
21
32
  }
package/src/config.ts CHANGED
@@ -18,7 +18,12 @@ export interface EnvironmentConfig {
18
18
  export interface ServiceConfig<E extends string = string> {
19
19
  /** Path to an OpenAPI/Swagger file (relative to the config's project root). */
20
20
  spec: string
21
- baseUrl: Partial<Record<E | 'live', string>>
21
+ /**
22
+ * Where calls go. A template over the environment's variables (`'{{petstoreUrl}}'`: config `vars`, then
23
+ * `environments.<env>.vars`, then `.specbook/environments/<env>.env`), which also makes `live` an
24
+ * environment; or one address per environment.
25
+ */
26
+ baseUrl: string | Partial<Record<E | 'live', string>>
22
27
  /** Default auth profile for this service. */
23
28
  auth?: string
24
29
  }
@@ -51,7 +56,10 @@ export interface BrandingConfig {
51
56
 
52
57
  export interface SpecbookConfig<E extends string = string> {
53
58
  project: string
54
- /** Non-sensitive variables, safe to commit. */
59
+ /**
60
+ * Non-sensitive variables, safe to commit: the defaults every environment starts from. An environment
61
+ * overrides them in `environments.<env>.vars` or `.specbook/environments/<env>.env`.
62
+ */
55
63
  vars?: Record<string, unknown>
56
64
  environments?: Partial<Record<E, EnvironmentConfig>>
57
65
  /** Secret references; OSS resolves them from env vars / dotenv files. */
@@ -60,6 +68,11 @@ export interface SpecbookConfig<E extends string = string> {
60
68
  auth?: Record<string, AuthProfile>
61
69
  secretProviders?: SecretProvider[]
62
70
  faker?: FakerConfig
71
+ /**
72
+ * How long a request waits for an answer, in milliseconds, in a run from the command line or a browser. A request's own
73
+ * `timeoutMs` wins, and `specbook test --timeout` wins over this. 0 waits for as long as it takes. Default: 30 seconds.
74
+ */
75
+ timeoutMs?: number
63
76
  codegen?: { outDir?: string; importPrefix?: string }
64
77
  branding?: BrandingConfig
65
78
  }
package/src/controls.ts CHANGED
@@ -26,24 +26,44 @@ interface SelectOpts<O, V> {
26
26
  default?: number | V | ((options: readonly O[]) => V)
27
27
  }
28
28
 
29
+ /** A starting value, or a function that makes one each time it is needed (a faker value, today's date). */
30
+ export type Default<T> = T | (() => T)
31
+
32
+ /** Whether a control's starting value is made fresh each time, so a UI can offer to make another. */
33
+ export const isGenerated = (ctl: Control): boolean => typeof ctl.opts.default === 'function'
34
+
35
+ function startingValue<T>(d: Default<T> | undefined): T | undefined {
36
+ return typeof d === 'function' ? (d as () => T)() : d
37
+ }
38
+
29
39
  export const c = {
30
- text: (o: { default?: string; label?: string; description?: string } = {}): Control<string> => ({
40
+ text: (
41
+ o: { default?: Default<string>; label?: string; description?: string } = {},
42
+ ): Control<string> => ({
31
43
  kind: 'text',
32
44
  opts: o,
33
45
  }),
34
46
  number: (
35
- o: { default?: number; min?: number; max?: number; label?: string; description?: string } = {},
47
+ o: {
48
+ default?: Default<number>
49
+ min?: number
50
+ max?: number
51
+ label?: string
52
+ description?: string
53
+ } = {},
36
54
  ): Control<number> => ({
37
55
  kind: 'number',
38
56
  opts: o,
39
57
  }),
40
58
  boolean: (
41
- o: { default?: boolean; label?: string; description?: string } = {},
59
+ o: { default?: Default<boolean>; label?: string; description?: string } = {},
42
60
  ): Control<boolean> => ({
43
61
  kind: 'boolean',
44
62
  opts: o,
45
63
  }),
46
- date: (o: { default?: string; label?: string; description?: string } = {}): Control<string> => ({
64
+ date: (
65
+ o: { default?: Default<string>; label?: string; description?: string } = {},
66
+ ): Control<string> => ({
47
67
  kind: 'date',
48
68
  opts: o,
49
69
  }),
@@ -105,7 +125,9 @@ export function resolveControl(name: string, ctl: Control, given: unknown): unkn
105
125
  )
106
126
  return values[d]
107
127
  if (typeof d === 'function') return d(options)
108
- if (d !== undefined) return d
128
+ // a starting value that is not one of the options means nothing (`default: 0` on pet ids is the first pet, not a
129
+ // pet numbered 0): the first option is the default
130
+ if (d !== undefined && values.some((v) => same(v, d))) return d
109
131
  return values[0]
110
132
  }
111
133
  case 'multiselect': {
@@ -132,11 +154,11 @@ export function resolveControl(name: string, ctl: Control, given: unknown): unkn
132
154
  return v.map((item) => resolveControl(name, ctl.opts.item, item))
133
155
  }
134
156
  case 'boolean':
135
- return given ?? ctl.opts.default ?? false
157
+ return given ?? startingValue(ctl.opts.default) ?? false
136
158
  case 'number':
137
- return given ?? ctl.opts.default ?? 0
159
+ return given ?? startingValue(ctl.opts.default) ?? 0
138
160
  default:
139
- return given ?? ctl.opts.default ?? ''
161
+ return given ?? startingValue(ctl.opts.default) ?? ''
140
162
  }
141
163
  }
142
164
 
@@ -161,7 +183,10 @@ export function describeControls(controls: Controls) {
161
183
  }))
162
184
  return [k, { kind: ctl.kind, options }]
163
185
  }
164
- return [k, { kind: ctl.kind, default: ctl.opts.default }]
186
+ return [
187
+ k,
188
+ { kind: ctl.kind, default: startingValue(ctl.opts.default), generated: isGenerated(ctl) },
189
+ ]
165
190
  }),
166
191
  )
167
192
  }
package/src/diff.ts ADDED
@@ -0,0 +1,131 @@
1
+ // A line diff for a failed assertion, shared by the command line and the UI so both show the same thing.
2
+
3
+ import type { AssertionResult } from './types'
4
+
5
+ export type DiffLine =
6
+ | { kind: 'same' | 'expected' | 'received'; text: string }
7
+ /** a run of unchanged lines left out */
8
+ | { kind: 'skip'; text: string }
9
+
10
+ const STATUS_TEXT: Record<number, string> = {
11
+ 200: 'OK',
12
+ 201: 'Created',
13
+ 202: 'Accepted',
14
+ 204: 'No Content',
15
+ 301: 'Moved Permanently',
16
+ 302: 'Found',
17
+ 304: 'Not Modified',
18
+ 400: 'Bad Request',
19
+ 401: 'Unauthorized',
20
+ 403: 'Forbidden',
21
+ 404: 'Not Found',
22
+ 405: 'Method Not Allowed',
23
+ 408: 'Request Timeout',
24
+ 409: 'Conflict',
25
+ 410: 'Gone',
26
+ 422: 'Unprocessable Entity',
27
+ 429: 'Too Many Requests',
28
+ 500: 'Internal Server Error',
29
+ 501: 'Not Implemented',
30
+ 502: 'Bad Gateway',
31
+ 503: 'Service Unavailable',
32
+ 504: 'Gateway Timeout',
33
+ }
34
+
35
+ export const statusText = (status: number): string => STATUS_TEXT[status] ?? ''
36
+
37
+ /** A value as lines: strings as JSON (multi-line text as it is), everything else pretty-printed JSON. */
38
+ function show(v: unknown): string[] {
39
+ if (typeof v === 'string') return v.includes('\n') ? v.split('\n') : [JSON.stringify(v)]
40
+ if (v === undefined) return ['undefined']
41
+ try {
42
+ return (JSON.stringify(v, null, 2) ?? String(v)).split('\n')
43
+ } catch {
44
+ return [String(v)]
45
+ }
46
+ }
47
+
48
+ /** Lines in common stay; the rest are `expected` (only in a) or `received` (only in b). */
49
+ function lineDiff(a: string[], b: string[]): DiffLine[] {
50
+ const out: DiffLine[] = []
51
+ // very large values are not worth a quadratic table: show one against the other
52
+ if (a.length * b.length > 250_000) {
53
+ for (const text of a) out.push({ kind: 'expected', text })
54
+ for (const text of b) out.push({ kind: 'received', text })
55
+ return out
56
+ }
57
+ const n = a.length
58
+ const m = b.length
59
+ const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
60
+ for (let i = n - 1; i >= 0; i--)
61
+ for (let j = m - 1; j >= 0; j--)
62
+ (table[i] as Uint32Array)[j] =
63
+ a[i] === b[j]
64
+ ? ((table[i + 1] as Uint32Array)[j + 1] as number) + 1
65
+ : Math.max(
66
+ (table[i + 1] as Uint32Array)[j] as number,
67
+ (table[i] as Uint32Array)[j + 1] as number,
68
+ )
69
+ let i = 0
70
+ let j = 0
71
+ while (i < n && j < m) {
72
+ if (a[i] === b[j]) {
73
+ out.push({ kind: 'same', text: a[i] as string })
74
+ i++
75
+ j++
76
+ } else if (
77
+ ((table[i + 1] as Uint32Array)[j] as number) >= ((table[i] as Uint32Array)[j + 1] as number)
78
+ ) {
79
+ out.push({ kind: 'expected', text: a[i++] as string })
80
+ } else {
81
+ out.push({ kind: 'received', text: b[j++] as string })
82
+ }
83
+ }
84
+ while (i < n) out.push({ kind: 'expected', text: a[i++] as string })
85
+ while (j < m) out.push({ kind: 'received', text: b[j++] as string })
86
+ return out
87
+ }
88
+
89
+ /** Keeps the changed lines and `context` lines around them; longer runs of unchanged lines become one `skip` line. */
90
+ function collapse(lines: DiffLine[], context: number): DiffLine[] {
91
+ const keep = new Array<boolean>(lines.length).fill(false)
92
+ lines.forEach((l, i) => {
93
+ if (l.kind === 'same') return
94
+ for (let k = Math.max(0, i - context); k <= Math.min(lines.length - 1, i + context); k++)
95
+ keep[k] = true
96
+ })
97
+ const out: DiffLine[] = []
98
+ let skipped = 0
99
+ const flush = () => {
100
+ if (skipped > 0)
101
+ out.push({ kind: 'skip', text: `… ${skipped} unchanged line${skipped > 1 ? 's' : ''}` })
102
+ skipped = 0
103
+ }
104
+ lines.forEach((l, i) => {
105
+ if (keep[i]) {
106
+ flush()
107
+ out.push(l)
108
+ } else skipped++
109
+ })
110
+ flush()
111
+ return out
112
+ }
113
+
114
+ /**
115
+ * What a failed assertion expected and what it received, as diff lines: the status check reads as one pair of lines, values
116
+ * are compared line by line, and an assertion that recorded only a diff string shows that.
117
+ */
118
+ export function diffAssertion(a: AssertionResult, context = 3): DiffLine[] {
119
+ if (a.operator === 'status') {
120
+ const got =
121
+ typeof a.actual === 'number' ? `${a.actual} ${statusText(a.actual)}`.trim() : String(a.actual)
122
+ return [
123
+ { kind: 'expected', text: `status ${a.expected}` },
124
+ { kind: 'received', text: `status ${got}` },
125
+ ]
126
+ }
127
+ if ('expected' in a || 'actual' in a)
128
+ return collapse(lineDiff(show(a.expected), show(a.actual)), context)
129
+ if (a.diff) return a.diff.split('\n').map((text) => ({ kind: 'same', text }))
130
+ return []
131
+ }
@@ -0,0 +1,175 @@
1
+ // Where an environment's calls go: base URLs and sign-in endpoints are templates over the environment's
2
+ // variables (config `vars`, then `environments.<env>.vars`, then the environment's own file).
3
+
4
+ import type { SpecbookConfig } from './config'
5
+ import type { OAuthSettings } from './oauth'
6
+ import {
7
+ collectPlaceholders,
8
+ isDeferred,
9
+ isFakerName,
10
+ renderString,
11
+ TemplateError,
12
+ } from './template'
13
+
14
+ /** An address (base URL or sign-in endpoint) that cannot be worked out for an environment. */
15
+ export class AddressError extends Error {
16
+ constructor(
17
+ message: string,
18
+ /** the variable to set, when that is what is missing */
19
+ readonly variable?: string,
20
+ ) {
21
+ super(message)
22
+ this.name = 'AddressError'
23
+ }
24
+ }
25
+
26
+ type AddressConfig = Pick<SpecbookConfig, 'vars' | 'environments' | 'services'> & {
27
+ auth?: Record<string, { oauth?: OAuthSettings }>
28
+ }
29
+
30
+ /** Fields of an OAuth profile that are templates; `scopes` and the rest stay as written. */
31
+ export const OAUTH_ADDRESS_FIELDS = [
32
+ 'authorizationUrl',
33
+ 'tokenUrl',
34
+ 'openIdConnectUrl',
35
+ 'clientId',
36
+ ] as const
37
+
38
+ /**
39
+ * The variables an environment's addresses are rendered with, later wins:
40
+ * config `vars` → `environments.<env>.vars` → the environment's file (→ browser store, via `extra`).
41
+ * Per-run values (overrides, captures) deliberately take no part: the dev server has to reach the same
42
+ * answer as the page.
43
+ */
44
+ export function environmentScope(
45
+ cfg: Pick<SpecbookConfig, 'vars' | 'environments'>,
46
+ env: string,
47
+ fileVars: Record<string, unknown> = {},
48
+ extra: Record<string, unknown> = {},
49
+ ): Record<string, unknown> {
50
+ return {
51
+ ...cfg.vars,
52
+ ...(cfg.environments as Record<string, { vars?: Record<string, unknown> }> | undefined)?.[env]
53
+ ?.vars,
54
+ ...fileVars,
55
+ ...extra,
56
+ }
57
+ }
58
+
59
+ /** Renders an address template with plain variables only; secrets and faker values have no place in one. */
60
+ function renderAddress(template: string, scope: Record<string, unknown>, what: string): string {
61
+ for (const name of collectPlaceholders(template)) {
62
+ if (isDeferred(name) || isFakerName(name))
63
+ throw new AddressError(
64
+ `${what} cannot use {{${name}}}: only environment variables are available`,
65
+ )
66
+ }
67
+ let value: unknown
68
+ try {
69
+ value = renderString(template, scope)
70
+ } catch (e) {
71
+ if (e instanceof TemplateError) throw new AddressError(`${what}: set ${e.variable}`, e.variable)
72
+ throw e
73
+ }
74
+ if (value !== null && typeof value === 'object')
75
+ throw new AddressError(`${what} is not a text value`)
76
+ const out = value === undefined || value === null ? '' : String(value).trim()
77
+ if (/\{\{/.test(out)) throw new AddressError(`${what} still contains a placeholder: ${out}`)
78
+ return out
79
+ }
80
+
81
+ /**
82
+ * The address calls for `service` go to in `env`, without a trailing slash; `undefined` when there is none.
83
+ * A string `baseUrl` is a template over `scope`; a record is read by environment name (and may use variables
84
+ * too).
85
+ */
86
+ export function baseUrlFor(
87
+ cfg: Pick<SpecbookConfig, 'services'>,
88
+ service: string,
89
+ env: string,
90
+ scope: Record<string, unknown>,
91
+ ): string | undefined {
92
+ const base = cfg.services[service]?.baseUrl as
93
+ | string
94
+ | Record<string, string | undefined>
95
+ | undefined
96
+ const template = typeof base === 'string' ? base : base?.[env]
97
+ if (!template) return undefined
98
+ const what = `No base URL for ${service} in ${env}`
99
+ const url = /\{\{/.test(template) ? renderAddress(template, scope, what) : template.trim()
100
+ return url ? url.replace(/\/+$/, '') : undefined
101
+ }
102
+
103
+ /** `oauth` with its address fields rendered; `scopes` and everything else as written. */
104
+ export function oauthFor<T extends OAuthSettings>(
105
+ oauth: T,
106
+ scope: Record<string, unknown>,
107
+ profile = 'sign-in',
108
+ ): T {
109
+ const out: Record<string, unknown> = { ...oauth }
110
+ for (const field of OAUTH_ADDRESS_FIELDS) {
111
+ const v = out[field]
112
+ if (typeof v !== 'string' || !/\{\{/.test(v)) continue
113
+ const rendered = renderAddress(v, scope, `${profile} ${field}`)
114
+ if (rendered) out[field] = rendered
115
+ else delete out[field]
116
+ }
117
+ return out as T
118
+ }
119
+
120
+ /**
121
+ * Every environment name, in the order the pickers show them: `mock`, `live` (when a service has a string
122
+ * `baseUrl` or a `live` address), the record keys, `environments`, then environment files and `extra` (the
123
+ * browser's own environments).
124
+ */
125
+ export function environmentNames(
126
+ cfg: Pick<SpecbookConfig, 'environments' | 'services'>,
127
+ fileEnvs: Iterable<string> = [],
128
+ extra: Iterable<string> = [],
129
+ ): string[] {
130
+ const names = new Set<string>(['mock'])
131
+ const services = Object.values(cfg.services ?? {})
132
+ if (services.some((s) => typeof s.baseUrl === 'string')) names.add('live')
133
+ for (const s of services) {
134
+ if (s.baseUrl && typeof s.baseUrl === 'object')
135
+ for (const k of Object.keys(s.baseUrl)) names.add(k)
136
+ }
137
+ for (const k of Object.keys(cfg.environments ?? {})) names.add(k)
138
+ for (const k of fileEnvs) names.add(k)
139
+ for (const k of extra) names.add(k)
140
+ return [...names]
141
+ }
142
+
143
+ /** True when `url` is an absolute http(s) address. */
144
+ export const isAbsoluteHttp = (url: string): boolean => /^https?:\/\/[^\s/]/i.test(url)
145
+
146
+ export interface AddressUse {
147
+ services: string[]
148
+ profiles: { profile: string; field: (typeof OAUTH_ADDRESS_FIELDS)[number] }[]
149
+ }
150
+
151
+ /** Which base URLs and sign-in fields read each variable: what a variable is "for" in an environment. */
152
+ export function addressUses(cfg: AddressConfig): Record<string, AddressUse> {
153
+ const out: Record<string, AddressUse> = {}
154
+ const use = (name: string) => {
155
+ if (isDeferred(name) || isFakerName(name)) return undefined
156
+ out[name] ??= { services: [], profiles: [] }
157
+ return out[name]
158
+ }
159
+ for (const [service, s] of Object.entries(cfg.services ?? {})) {
160
+ const templates = typeof s.baseUrl === 'string' ? [s.baseUrl] : Object.values(s.baseUrl ?? {})
161
+ for (const t of templates)
162
+ for (const name of collectPlaceholders(t)) {
163
+ const u = use(name)
164
+ if (u && !u.services.includes(service)) u.services.push(service)
165
+ }
166
+ }
167
+ for (const [profile, p] of Object.entries(cfg.auth ?? {})) {
168
+ for (const field of OAUTH_ADDRESS_FIELDS) {
169
+ const v = (p.oauth as Record<string, unknown> | undefined)?.[field]
170
+ if (typeof v !== 'string') continue
171
+ for (const name of collectPlaceholders(v)) use(name)?.profiles.push({ profile, field })
172
+ }
173
+ }
174
+ return out
175
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export { assert, createAsserter, stripAnsi } from './assert'
2
2
  export * from './config'
3
3
  export * from './controls'
4
+ export * from './diff'
5
+ export * from './environment'
4
6
  export type { Example, ExampleLoader, FileReader, Row } from './example'
5
7
  export { fromFormRows, fromRows, toFormRows, toRows } from './example'
6
8
  export type { FakerConfig } from './faker'
@@ -9,7 +11,7 @@ export * from './mock'
9
11
  export * from './oauth'
10
12
  export * from './proxy/index'
11
13
  export * from './reporters/index'
12
- export { defineOperation, request } from './request'
14
+ export { defineOperation, request, withMock } from './request'
13
15
  export * from './runner/index'
14
16
  export * from './secrets'
15
17
  export type { Sequence, SequenceMeta, StepCtx, StepNode } from './sequence'
@@ -18,6 +20,7 @@ export type { SnippetInput, SnippetLang } from './snippets'
18
20
  export { snippetLangs, toSnippet } from './snippets'
19
21
  export {
20
22
  authFromSpec,
23
+ environmentsFromServers,
21
24
  type JsonAuthProfile,
22
25
  liveBaseUrl,
23
26
  MAX_SERVERS,
package/src/mock.ts CHANGED
@@ -1,8 +1,8 @@
1
- // In-process mock transport: serves request-level mock overrides, falling back to generated examples.
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
- sleep?: (ms: number) => Promise<void>
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 isMockResponse = (v: unknown): v is MockResponse<unknown> =>
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
- function evalInit(init: Record<string, unknown> | undefined) {
27
- return Object.fromEntries(
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 = opts.sleep ?? ((ms) => new Promise<void>((r) => setTimeout(r, ms)))
37
- return async (req: PreparedRequest): Promise<RawResponse> => {
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 cfg = hints?.config
40
- const known = await opts.loader.statuses(req.service, req.opId)
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 override = (cfg?.responses as Record<number, unknown> | undefined)?.[status]
71
- let delay = 0
72
- let headers: Record<string, string> = {}
73
- let scope = { ...(hints?.vars ?? {}), ...(hints?.pathParams ?? {}), ...(hints?.rawQuery ?? {}) }
74
- let template: unknown = await opts.loader.example(req.service, req.opId, status)
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
- if (override !== undefined) {
84
- // Overrides are layered over the generated example (objects merge, arrays/scalars replace).
85
- let data: unknown
86
- if (isMockResponse(override)) {
87
- data = await resolveSource(override.data)
88
- scope = { ...scope, ...evalInit(override.init) }
89
- headers = override.headers ? render(override.headers, scope) : {}
90
- delay = override.delayMs ?? 0
91
- } else {
92
- data = await resolveSource(override)
93
- }
94
- if (data !== undefined) template = deepMerge(template, data)
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
- async function resolveSource(src: unknown): Promise<unknown> {
103
- if (typeof src === 'function')
104
- return (await (src as () => Promise<{ default: unknown }>)()).default
105
- return src
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 {