@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 +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 +4 -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 +111 -26
- package/src/runner/suite.ts +122 -0
- package/src/runner/transport.ts +29 -4
- package/src/secrets.ts +31 -2
- package/src/sequence.ts +4 -4
- package/src/spec-config.ts +77 -11
- package/src/types.ts +49 -32
package/src/runner/transport.ts
CHANGED
|
@@ -16,11 +16,11 @@ export interface PreparedRequest {
|
|
|
16
16
|
/** Array style per form field, from the spec's `encoding` object. */
|
|
17
17
|
formArrays?: Record<string, FormArrays>
|
|
18
18
|
timeoutMs?: number
|
|
19
|
-
/** Mock status the caller wants served (mock env only). */
|
|
19
|
+
/** Mock status the caller wants served, e.g. the UI's "Respond with" (mock env only). */
|
|
20
20
|
mockStatus?: number
|
|
21
21
|
/** In-process hints for the mock transport; never serialized or substituted. */
|
|
22
22
|
mock?: {
|
|
23
|
-
|
|
23
|
+
hook?: import('../types').MockHook<any, any>
|
|
24
24
|
vars: Record<string, unknown>
|
|
25
25
|
pathParams: Record<string, unknown>
|
|
26
26
|
rawQuery: Record<string, unknown>
|
|
@@ -46,6 +46,18 @@ export function buildUrl(base: string, req: PreparedRequest): string {
|
|
|
46
46
|
return `${base.replace(/\/+$/, '')}${req.url}${q ? `?${q}` : ''}`
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** A config `baseUrl` may be relative (`/api/v1`): it is resolved against the page. */
|
|
50
|
+
export function absoluteBase(baseUrl: string, pageHref?: string): string {
|
|
51
|
+
try {
|
|
52
|
+
return new URL(baseUrl, pageHref).toString()
|
|
53
|
+
} catch {
|
|
54
|
+
return baseUrl
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** How long a request waits for an answer when nothing says otherwise. */
|
|
59
|
+
export const DEFAULT_TIMEOUT_MS = 30_000
|
|
60
|
+
|
|
49
61
|
export interface FetchTransportOptions {
|
|
50
62
|
baseUrl: (service: string) => string
|
|
51
63
|
fetch?: typeof fetch
|
|
@@ -53,6 +65,8 @@ export interface FetchTransportOptions {
|
|
|
53
65
|
|
|
54
66
|
export function createFetchTransport(opts: FetchTransportOptions): Transport {
|
|
55
67
|
return async (req, signal) => {
|
|
68
|
+
// a run stopped before this request left: nothing is sent, and no 'abort' will ever be heard from a signal that has fired
|
|
69
|
+
if (signal?.aborted) throw new Error('Stopped before the API answered.')
|
|
56
70
|
const f = opts.fetch ?? fetch
|
|
57
71
|
const headers: Record<string, string> = { ...req.headers }
|
|
58
72
|
let body: BodyInit | undefined
|
|
@@ -61,10 +75,15 @@ export function createFetchTransport(opts: FetchTransportOptions): Transport {
|
|
|
61
75
|
body = enc.body
|
|
62
76
|
if (enc.contentType) headers['content-type'] ??= enc.contentType
|
|
63
77
|
}
|
|
64
|
-
if (req.mockStatus !== undefined) headers['x-specbook-mock-status'] = String(req.mockStatus)
|
|
65
78
|
|
|
66
79
|
const ctl = new AbortController()
|
|
67
|
-
|
|
80
|
+
let timedOut = false
|
|
81
|
+
const timer = req.timeoutMs
|
|
82
|
+
? setTimeout(() => {
|
|
83
|
+
timedOut = true
|
|
84
|
+
ctl.abort()
|
|
85
|
+
}, req.timeoutMs)
|
|
86
|
+
: undefined
|
|
68
87
|
signal?.addEventListener('abort', () => ctl.abort())
|
|
69
88
|
try {
|
|
70
89
|
const res = await f(buildUrl(opts.baseUrl(req.service), req), {
|
|
@@ -88,6 +107,12 @@ export function createFetchTransport(opts: FetchTransportOptions): Transport {
|
|
|
88
107
|
}
|
|
89
108
|
}
|
|
90
109
|
return { status: res.status, headers: outHeaders, text, body: parsed }
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// an abort that the request's own timeout caused says so, not "This operation was aborted"
|
|
112
|
+
if (timedOut) throw new Error(`Timed out after ${req.timeoutMs}ms: no answer from the API.`)
|
|
113
|
+
// a person pressing Stop, or the run's own deadline: say so, not "This operation was aborted"
|
|
114
|
+
if (signal?.aborted) throw new Error('Stopped before the API answered.')
|
|
115
|
+
throw e
|
|
91
116
|
} finally {
|
|
92
117
|
if (timer) clearTimeout(timer)
|
|
93
118
|
}
|
package/src/secrets.ts
CHANGED
|
@@ -67,9 +67,38 @@ export async function resolveSecrets(
|
|
|
67
67
|
return out
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/**
|
|
70
|
+
/** Secrets shorter than this are not masked: they would blank out ordinary text. */
|
|
71
|
+
const MIN_MASKED = 3
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A secret as it can appear in a URL, a JSON string or a Basic-auth style base64 value, besides itself: what a server
|
|
75
|
+
* echoes back or an error message quotes is not always the raw value.
|
|
76
|
+
*/
|
|
77
|
+
export function maskVariants(secret: string): string[] {
|
|
78
|
+
const out = new Set<string>([secret])
|
|
79
|
+
const add = (f: () => string) => {
|
|
80
|
+
try {
|
|
81
|
+
out.add(f())
|
|
82
|
+
} catch {
|
|
83
|
+
/* not encodable: the raw value is still masked */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
add(() => encodeURIComponent(secret))
|
|
87
|
+
add(() => encodeURI(secret))
|
|
88
|
+
add(() => JSON.stringify(secret).slice(1, -1))
|
|
89
|
+
add(() => btoa(String.fromCharCode(...new TextEncoder().encode(secret))))
|
|
90
|
+
return [...out]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Masks with `***`, longest first so a secret containing another is hidden whole. Every secret is masked in its
|
|
95
|
+
* raw, URL-encoded, JSON-escaped and base64 forms; values under 3 characters are skipped to avoid masking
|
|
96
|
+
* ordinary text.
|
|
97
|
+
*/
|
|
71
98
|
export function maskDeep<T>(value: T, secrets: Iterable<string>): T {
|
|
72
|
-
const list = [
|
|
99
|
+
const list = [
|
|
100
|
+
...new Set([...secrets].filter((s) => s.length >= MIN_MASKED).flatMap(maskVariants)),
|
|
101
|
+
].sort((a, b) => b.length - a.length)
|
|
73
102
|
if (list.length === 0) return value
|
|
74
103
|
const maskStr = (s: string) => list.reduce((acc, v) => acc.split(v).join('***'), s)
|
|
75
104
|
const walk = (v: unknown): unknown => {
|
package/src/sequence.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Controls, InferControls } from './controls'
|
|
|
2
2
|
import type { Assert, AssertMode, AuthProfileName, RequestDef } from './types'
|
|
3
3
|
|
|
4
4
|
export interface StepCtx<P, C> {
|
|
5
|
-
|
|
5
|
+
inputs: P
|
|
6
6
|
captured: C
|
|
7
7
|
vars: Record<string, unknown>
|
|
8
8
|
env: string
|
|
@@ -34,7 +34,7 @@ export interface SequenceMeta {
|
|
|
34
34
|
tags?: string[]
|
|
35
35
|
/** Heading the flow is listed under in the catalog; flows without one are under "Default". */
|
|
36
36
|
group?: string
|
|
37
|
-
|
|
37
|
+
inputs?: Controls
|
|
38
38
|
auth?: AuthProfileName | false
|
|
39
39
|
assertMode?: AssertMode
|
|
40
40
|
continueOnFailure?: boolean
|
|
@@ -55,7 +55,7 @@ export interface Sequence<P = {}, C = {}> {
|
|
|
55
55
|
name: string,
|
|
56
56
|
fn: (ctx: StepCtx<P, C>, h: { assert: Assert }) => void | Promise<void>,
|
|
57
57
|
): Sequence<P, C>
|
|
58
|
-
/** Pause for user input; the chosen values merge into ctx.
|
|
58
|
+
/** Pause for user input; the chosen values merge into ctx.inputs. */
|
|
59
59
|
input<R extends Controls>(
|
|
60
60
|
name: string,
|
|
61
61
|
controls: (ctx: StepCtx<P, C>) => R,
|
|
@@ -77,7 +77,7 @@ function build(meta: SequenceMeta, nodes: readonly StepNode[]): Sequence<any, an
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
export function sequence<P extends Controls = {}>(
|
|
80
|
-
meta: Omit<SequenceMeta, '
|
|
80
|
+
meta: Omit<SequenceMeta, 'inputs'> & { inputs?: P },
|
|
81
81
|
): Sequence<InferControls<P>, {}> {
|
|
82
82
|
return build(meta, [])
|
|
83
83
|
}
|
package/src/spec-config.ts
CHANGED
|
@@ -14,30 +14,33 @@ export interface JsonAuthProfile {
|
|
|
14
14
|
oauth?: OAuthSettings
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/** Gives the address a profile field should carry for a raw URL from the document. */
|
|
18
|
+
type Address = (field: 'authorizationUrl' | 'tokenUrl' | 'openIdConnectUrl', raw: string) => string
|
|
19
|
+
|
|
17
20
|
/** An OpenID Connect scheme: the discovery document says where to sign in. */
|
|
18
|
-
function discovery(s: any,
|
|
21
|
+
function discovery(s: any, address: Address): OAuthSettings | undefined {
|
|
19
22
|
if (typeof s.openIdConnectUrl !== 'string' || s.openIdConnectUrl === '') return undefined
|
|
20
23
|
try {
|
|
21
|
-
return { openIdConnectUrl:
|
|
24
|
+
return { openIdConnectUrl: address('openIdConnectUrl', s.openIdConnectUrl) }
|
|
22
25
|
} catch {
|
|
23
26
|
return undefined
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/** The authorization code flow of an OAuth scheme (OpenAPI 3 `flows`, or Swagger 2 `accessCode`). */
|
|
28
|
-
function codeFlow(s: any,
|
|
31
|
+
function codeFlow(s: any, address: Address): OAuthSettings | undefined {
|
|
29
32
|
const flow = s.flows?.authorizationCode ?? (s.flow === 'accessCode' ? s : undefined)
|
|
30
33
|
if (!flow?.authorizationUrl || !flow?.tokenUrl) return undefined
|
|
31
|
-
const abs = (u: string) => {
|
|
34
|
+
const abs = (field: 'authorizationUrl' | 'tokenUrl', u: string) => {
|
|
32
35
|
try {
|
|
33
|
-
return
|
|
36
|
+
return address(field, u)
|
|
34
37
|
} catch {
|
|
35
38
|
return u
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
41
|
return {
|
|
39
|
-
authorizationUrl: abs(flow.authorizationUrl),
|
|
40
|
-
tokenUrl: abs(flow.tokenUrl),
|
|
42
|
+
authorizationUrl: abs('authorizationUrl', flow.authorizationUrl),
|
|
43
|
+
tokenUrl: abs('tokenUrl', flow.tokenUrl),
|
|
41
44
|
...(flow.scopes && Object.keys(flow.scopes).length ? { scopes: flow.scopes } : {}),
|
|
42
45
|
}
|
|
43
46
|
}
|
|
@@ -48,6 +51,12 @@ export interface SpecAuth {
|
|
|
48
51
|
secrets: Record<string, Record<string, never>>
|
|
49
52
|
/** the profile requests use unless they say otherwise */
|
|
50
53
|
defaultProfile?: string
|
|
54
|
+
/**
|
|
55
|
+
* Sign-in addresses the document gives relative to its servers become variables, so each environment
|
|
56
|
+
* signs in at its own server: the `live` values here, and one entry per other environment below.
|
|
57
|
+
*/
|
|
58
|
+
vars: Record<string, string>
|
|
59
|
+
environmentVars: Record<string, Record<string, string>>
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
const secretName = (scheme: string) =>
|
|
@@ -60,10 +69,41 @@ const secretName = (scheme: string) =>
|
|
|
60
69
|
* Turns a document's security schemes into auth profiles whose secrets are filled in at run time, so
|
|
61
70
|
* previewing a protected API only needs the user to paste a key. OAuth-style schemes take an access token.
|
|
62
71
|
*/
|
|
63
|
-
export function authFromSpec(api: any, source?: string): SpecAuth {
|
|
72
|
+
export function authFromSpec(api: any, source?: string, servers?: ServerEntry[]): SpecAuth {
|
|
64
73
|
const schemes: Record<string, any> =
|
|
65
74
|
api.components?.securitySchemes ?? api.securityDefinitions ?? {}
|
|
66
|
-
const out: SpecAuth = { auth: {}, secrets: {} }
|
|
75
|
+
const out: SpecAuth = { auth: {}, secrets: {}, vars: {}, environmentVars: {} }
|
|
76
|
+
const pageBase = source && isUrl(source) ? source : undefined
|
|
77
|
+
const resolve = (raw: string, base: string | undefined) => new URL(raw, base).toString()
|
|
78
|
+
// a relative address follows the server (OpenAPI 3.1): one variable per scheme and field, set per environment
|
|
79
|
+
const addressFor =
|
|
80
|
+
(scheme: string): Address =>
|
|
81
|
+
(field, raw) => {
|
|
82
|
+
if (!servers?.length || isUrl(raw)) return resolve(raw, pageBase)
|
|
83
|
+
const name = `${secretName(scheme)
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.replace(/_(.)/g, (_, c) => c.toUpperCase())}${field[0]?.toUpperCase()}${field.slice(1)}`
|
|
86
|
+
let any = false
|
|
87
|
+
for (const sv of servers) {
|
|
88
|
+
try {
|
|
89
|
+
const abs = resolve(raw, sv.url.endsWith('/') ? sv.url : `${sv.url}/`)
|
|
90
|
+
if (sv.name === 'live') out.vars[name] = abs
|
|
91
|
+
else out.environmentVars[sv.name] = { ...out.environmentVars[sv.name], [name]: abs }
|
|
92
|
+
any = true
|
|
93
|
+
} catch {
|
|
94
|
+
/* this server cannot resolve it */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!any) return resolve(raw, pageBase)
|
|
98
|
+
if (out.vars[name] === undefined) {
|
|
99
|
+
try {
|
|
100
|
+
out.vars[name] = resolve(raw, pageBase)
|
|
101
|
+
} catch {
|
|
102
|
+
out.vars[name] = raw
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return `{{${name}}}`
|
|
106
|
+
}
|
|
67
107
|
for (const [name, s] of Object.entries(schemes)) {
|
|
68
108
|
const secret = secretName(name)
|
|
69
109
|
const ref = `{{secrets.${secret}}}`
|
|
@@ -88,8 +128,8 @@ export function authFromSpec(api: any, source?: string): SpecAuth {
|
|
|
88
128
|
title = `${name} (basic auth, paste base64 of user:password)`
|
|
89
129
|
} else if (s.type === 'oauth2' || s.type === 'openIdConnect') {
|
|
90
130
|
apply = { headers: { authorization: `Bearer ${ref}` } }
|
|
91
|
-
const
|
|
92
|
-
oauth = s.type === 'oauth2' ? codeFlow(s,
|
|
131
|
+
const address = addressFor(name)
|
|
132
|
+
oauth = s.type === 'oauth2' ? codeFlow(s, address) : discovery(s, address)
|
|
93
133
|
title = `${name} (OAuth, ${oauth ? 'sign in or ' : ''}paste an access token)`
|
|
94
134
|
}
|
|
95
135
|
if (!apply) continue
|
|
@@ -209,6 +249,32 @@ export function openApiServers(
|
|
|
209
249
|
return { servers, needsBaseUrl, omitted }
|
|
210
250
|
}
|
|
211
251
|
|
|
252
|
+
/**
|
|
253
|
+
* What a document's servers become in a config: the first server's address as the variable `varName`, the
|
|
254
|
+
* others as the same variable in an environment of their own, and a `baseUrl` that reads it.
|
|
255
|
+
*/
|
|
256
|
+
export function environmentsFromServers(
|
|
257
|
+
servers: ServerEntry[],
|
|
258
|
+
varName: string,
|
|
259
|
+
): {
|
|
260
|
+
vars: Record<string, string>
|
|
261
|
+
environments: Record<string, { description?: string; vars?: Record<string, string> }>
|
|
262
|
+
baseUrl: string
|
|
263
|
+
} {
|
|
264
|
+
const vars: Record<string, string> = {}
|
|
265
|
+
const environments: Record<string, { description?: string; vars?: Record<string, string> }> = {}
|
|
266
|
+
for (const s of servers) {
|
|
267
|
+
const description = s.description ? { description: s.description } : {}
|
|
268
|
+
if (s.name === 'live') {
|
|
269
|
+
vars[varName] = s.url
|
|
270
|
+
if (s.description) environments.live = description
|
|
271
|
+
} else {
|
|
272
|
+
environments[s.name] = { ...description, vars: { [varName]: s.url } }
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return { vars, environments, baseUrl: `{{${varName}}}` }
|
|
276
|
+
}
|
|
277
|
+
|
|
212
278
|
/** Where the API lives, from `servers[0]` (OpenAPI 3) or `host`/`basePath` (Swagger 2). */
|
|
213
279
|
export function liveBaseUrl(api: any, source: string): string | undefined {
|
|
214
280
|
return openApiServers(api, source).servers.find((s) => s.name === 'live')?.url
|
package/src/types.ts
CHANGED
|
@@ -63,11 +63,6 @@ export type DeepPartial<T> = T extends readonly (infer U)[]
|
|
|
63
63
|
|
|
64
64
|
export type Source<T> = Templated<T> | (() => Promise<{ default: Templated<T> }>)
|
|
65
65
|
|
|
66
|
-
/** Mock overrides are deep-merged over the generated example, so they only need the fields that matter. */
|
|
67
|
-
export type PartialSource<T> =
|
|
68
|
-
| DeepPartial<Templated<T>>
|
|
69
|
-
| (() => Promise<{ default: DeepPartial<Templated<T>> }>)
|
|
70
|
-
|
|
71
66
|
export type Init = Record<string, unknown>
|
|
72
67
|
export type ResolvedInit<I> = { [K in keyof I]: I[K] extends () => infer R ? R : I[K] }
|
|
73
68
|
|
|
@@ -109,6 +104,10 @@ export interface RequestInput<T extends OpTypes, I extends Init = Init, C = {}>
|
|
|
109
104
|
data?: Source<T['body']>
|
|
110
105
|
encoding?: Encoding
|
|
111
106
|
timeoutMs?: number
|
|
107
|
+
/** A request that only reads (a POST search, a GraphQL query): allowed in a read-only run. */
|
|
108
|
+
safe?: boolean
|
|
109
|
+
/** Labels to select this request by (`specbook test --tag monitor`) and to group it by in the UI. */
|
|
110
|
+
tags?: string[]
|
|
112
111
|
capture?: { [K in keyof C]: (res: ResponseOf<T>, vars: ResolvedInit<I>) => C[K] }
|
|
113
112
|
assert?: (
|
|
114
113
|
res: ResponseOf<T>,
|
|
@@ -120,39 +119,50 @@ export interface RequestInput<T extends OpTypes, I extends Init = Init, C = {}>
|
|
|
120
119
|
req: RenderedRequest
|
|
121
120
|
},
|
|
122
121
|
) => void | Promise<void>
|
|
123
|
-
mock?: MockInput<T, I>
|
|
124
122
|
auth?: AuthProfileName | false
|
|
125
123
|
assertMode?: AssertMode
|
|
126
124
|
}
|
|
127
125
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
126
|
+
/** What the request being answered looks like to a mock: its values, as they were sent. */
|
|
127
|
+
export interface MockReq<I> {
|
|
128
|
+
vars: ResolvedInit<I> & Record<string, unknown>
|
|
129
|
+
body: unknown
|
|
130
|
+
query: unknown
|
|
131
|
+
path: unknown
|
|
132
|
+
headers: Record<string, string>
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The answer a mock is about to give, one shape per status the operation declares so that checking `res.status` narrows
|
|
137
|
+
* `res.data`. Change `data`, `headers` and `delayMs` in place, or `respond` with another status.
|
|
138
|
+
*/
|
|
139
|
+
export type MockRes<T extends OpTypes> = {
|
|
140
|
+
[S in keyof T['responses'] & number]: {
|
|
141
|
+
status: S
|
|
142
|
+
data: T['responses'][S]
|
|
143
|
+
headers: Record<string, string>
|
|
144
|
+
/** Wait this long before answering. */
|
|
145
|
+
delayMs: number
|
|
146
|
+
/**
|
|
147
|
+
* Answer with another status the operation declares: its generated example, with `data` merged over it. Request values
|
|
148
|
+
* are echoed into a 2xx example as usual.
|
|
149
|
+
*/
|
|
150
|
+
respond<R extends keyof T['responses'] & number>(
|
|
151
|
+
status: R,
|
|
152
|
+
data?: DeepPartial<T['responses'][R]>,
|
|
153
|
+
): void
|
|
146
154
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
}[keyof T['responses'] & number]
|
|
156
|
+
|
|
157
|
+
/** Runs when the `mock` environment answers this request, after the example is chosen, filled from the request and rendered. */
|
|
158
|
+
export type MockHook<T extends OpTypes, I> = (
|
|
159
|
+
req: MockReq<I>,
|
|
160
|
+
res: MockRes<T>,
|
|
161
|
+
) => void | Promise<void>
|
|
154
162
|
|
|
155
163
|
export interface RequestOverrides<T extends OpTypes, I, C2> {
|
|
164
|
+
/** Added to the request's own tags. */
|
|
165
|
+
tags?: string[]
|
|
156
166
|
vars?: Partial<ResolvedInit<I>> & Record<string, unknown>
|
|
157
167
|
path?: Partial<Templated<T['params']['path']>>
|
|
158
168
|
query?: Partial<Templated<T['params']['query']>>
|
|
@@ -160,7 +170,6 @@ export interface RequestOverrides<T extends OpTypes, I, C2> {
|
|
|
160
170
|
data?: DeepPartial<Templated<T['body']>>
|
|
161
171
|
capture?: { [K in keyof C2]: (res: ResponseOf<T>) => C2[K] }
|
|
162
172
|
assert?: RequestInput<T, any, any>['assert']
|
|
163
|
-
mock?: { status?: keyof T['responses'] & number }
|
|
164
173
|
auth?: AuthProfileName | false
|
|
165
174
|
assertMode?: AssertMode
|
|
166
175
|
}
|
|
@@ -171,8 +180,10 @@ export interface RequestDef<T extends OpTypes = AnyOp, I extends Init = Init, C
|
|
|
171
180
|
readonly input: RequestInput<T, I, C>
|
|
172
181
|
/** Deep-merge patches applied to the rendered data template, in order. */
|
|
173
182
|
readonly dataPatches: readonly unknown[]
|
|
174
|
-
/** Explicit `.with({ vars })`; beats captures and
|
|
183
|
+
/** Explicit `.with({ vars })`; beats captures and inputs, only UI overrides are higher. */
|
|
175
184
|
readonly varOverrides: Readonly<Record<string, unknown>>
|
|
185
|
+
/** Set by `withMock`: how the mock environment answers this request. */
|
|
186
|
+
readonly mockHook?: MockHook<T, I>
|
|
176
187
|
/** Phantom: lets sequence steps infer the captured type. */
|
|
177
188
|
readonly _captured?: C
|
|
178
189
|
with<C2 = {}>(o: RequestOverrides<T, I, C2>): RequestDef<T, I, C & C2>
|
|
@@ -208,7 +219,7 @@ export interface RenderedRequest {
|
|
|
208
219
|
/** Where a placeholder's value came from, highest precedence first. */
|
|
209
220
|
export interface VarUse {
|
|
210
221
|
key: string
|
|
211
|
-
source: 'override' | 'capture' | '
|
|
222
|
+
source: 'override' | 'capture' | 'input' | 'init' | 'config'
|
|
212
223
|
/** Step that produced it, for captures. */
|
|
213
224
|
step?: string
|
|
214
225
|
/** A capture replaced a value of another type (e.g. a string id in an integer slot). */
|
|
@@ -241,6 +252,12 @@ export interface RunResult {
|
|
|
241
252
|
seed?: number
|
|
242
253
|
usedDrafts: boolean
|
|
243
254
|
tags: string[]
|
|
255
|
+
/** The project file the request or flow comes from, relative to the project; set by the CLI. */
|
|
256
|
+
file?: string
|
|
257
|
+
/** How many times it was run when retries were allowed (1 = no retry); set by the CLI. */
|
|
258
|
+
attempts?: number
|
|
259
|
+
/** It failed, then passed on a retry; set by the CLI. */
|
|
260
|
+
flaky?: boolean
|
|
244
261
|
status: 'pass' | 'fail' | 'error'
|
|
245
262
|
startedAt: string
|
|
246
263
|
durationMs: number
|