@specbook/core 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/assert.ts +17 -6
- package/src/config.ts +17 -2
- package/src/controls.ts +34 -9
- package/src/diff.ts +131 -0
- package/src/environment.ts +175 -0
- package/src/index.ts +14 -2
- package/src/mock.ts +80 -70
- package/src/oauth.ts +164 -0
- 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 -25
- 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 +211 -14
- package/src/types.ts +49 -32
package/src/spec-config.ts
CHANGED
|
@@ -3,11 +3,46 @@
|
|
|
3
3
|
* the CLI's `preview` and the browser viewer, so both read a spec the same way.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { OAuthSettings } from './oauth'
|
|
7
|
+
|
|
6
8
|
const isUrl = (s: string) => /^https?:\/\//i.test(s)
|
|
7
9
|
|
|
8
10
|
export interface JsonAuthProfile {
|
|
9
11
|
title: string
|
|
10
12
|
apply: { headers?: Record<string, string>; query?: Record<string, string> }
|
|
13
|
+
/** present when the scheme has an authorization code flow the UI can sign in to */
|
|
14
|
+
oauth?: OAuthSettings
|
|
15
|
+
}
|
|
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
|
+
|
|
20
|
+
/** An OpenID Connect scheme: the discovery document says where to sign in. */
|
|
21
|
+
function discovery(s: any, address: Address): OAuthSettings | undefined {
|
|
22
|
+
if (typeof s.openIdConnectUrl !== 'string' || s.openIdConnectUrl === '') return undefined
|
|
23
|
+
try {
|
|
24
|
+
return { openIdConnectUrl: address('openIdConnectUrl', s.openIdConnectUrl) }
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The authorization code flow of an OAuth scheme (OpenAPI 3 `flows`, or Swagger 2 `accessCode`). */
|
|
31
|
+
function codeFlow(s: any, address: Address): OAuthSettings | undefined {
|
|
32
|
+
const flow = s.flows?.authorizationCode ?? (s.flow === 'accessCode' ? s : undefined)
|
|
33
|
+
if (!flow?.authorizationUrl || !flow?.tokenUrl) return undefined
|
|
34
|
+
const abs = (field: 'authorizationUrl' | 'tokenUrl', u: string) => {
|
|
35
|
+
try {
|
|
36
|
+
return address(field, u)
|
|
37
|
+
} catch {
|
|
38
|
+
return u
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
authorizationUrl: abs('authorizationUrl', flow.authorizationUrl),
|
|
43
|
+
tokenUrl: abs('tokenUrl', flow.tokenUrl),
|
|
44
|
+
...(flow.scopes && Object.keys(flow.scopes).length ? { scopes: flow.scopes } : {}),
|
|
45
|
+
}
|
|
11
46
|
}
|
|
12
47
|
|
|
13
48
|
export interface SpecAuth {
|
|
@@ -16,6 +51,12 @@ export interface SpecAuth {
|
|
|
16
51
|
secrets: Record<string, Record<string, never>>
|
|
17
52
|
/** the profile requests use unless they say otherwise */
|
|
18
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>>
|
|
19
60
|
}
|
|
20
61
|
|
|
21
62
|
const secretName = (scheme: string) =>
|
|
@@ -28,15 +69,47 @@ const secretName = (scheme: string) =>
|
|
|
28
69
|
* Turns a document's security schemes into auth profiles whose secrets are filled in at run time, so
|
|
29
70
|
* previewing a protected API only needs the user to paste a key. OAuth-style schemes take an access token.
|
|
30
71
|
*/
|
|
31
|
-
export function authFromSpec(api: any): SpecAuth {
|
|
72
|
+
export function authFromSpec(api: any, source?: string, servers?: ServerEntry[]): SpecAuth {
|
|
32
73
|
const schemes: Record<string, any> =
|
|
33
74
|
api.components?.securitySchemes ?? api.securityDefinitions ?? {}
|
|
34
|
-
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
|
+
}
|
|
35
107
|
for (const [name, s] of Object.entries(schemes)) {
|
|
36
108
|
const secret = secretName(name)
|
|
37
109
|
const ref = `{{secrets.${secret}}}`
|
|
38
110
|
let apply: JsonAuthProfile['apply'] | undefined
|
|
39
111
|
let title = name
|
|
112
|
+
let oauth: OAuthSettings | undefined
|
|
40
113
|
if (s.type === 'apiKey') {
|
|
41
114
|
const key = s.name as string
|
|
42
115
|
if (s.in === 'header') apply = { headers: { [key]: ref } }
|
|
@@ -55,10 +128,12 @@ export function authFromSpec(api: any): SpecAuth {
|
|
|
55
128
|
title = `${name} (basic auth, paste base64 of user:password)`
|
|
56
129
|
} else if (s.type === 'oauth2' || s.type === 'openIdConnect') {
|
|
57
130
|
apply = { headers: { authorization: `Bearer ${ref}` } }
|
|
58
|
-
|
|
131
|
+
const address = addressFor(name)
|
|
132
|
+
oauth = s.type === 'oauth2' ? codeFlow(s, address) : discovery(s, address)
|
|
133
|
+
title = `${name} (OAuth, ${oauth ? 'sign in or ' : ''}paste an access token)`
|
|
59
134
|
}
|
|
60
135
|
if (!apply) continue
|
|
61
|
-
out.auth[name] = { title, apply }
|
|
136
|
+
out.auth[name] = { title, apply, ...(oauth ? { oauth } : {}) }
|
|
62
137
|
out.secrets[secret] = {}
|
|
63
138
|
}
|
|
64
139
|
// the document's top-level `security` says which scheme most operations use
|
|
@@ -69,16 +144,138 @@ export function authFromSpec(api: any): SpecAuth {
|
|
|
69
144
|
return out
|
|
70
145
|
}
|
|
71
146
|
|
|
72
|
-
/**
|
|
73
|
-
export
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
147
|
+
/** One server a spec lists, as an environment the UI can switch to. */
|
|
148
|
+
export interface ServerEntry {
|
|
149
|
+
/** the environment name: `live` for the first, else a slug of its description or host */
|
|
150
|
+
name: string
|
|
151
|
+
url: string
|
|
152
|
+
description?: string
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** `live` plus this many minus one: a spec with a server per region should not bury the picker. */
|
|
156
|
+
export const MAX_SERVERS = 20
|
|
157
|
+
|
|
158
|
+
const slug = (s: string) =>
|
|
159
|
+
s
|
|
160
|
+
.toLowerCase()
|
|
161
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
162
|
+
.replace(/^-+|-+$/g, '')
|
|
163
|
+
.slice(0, 24)
|
|
164
|
+
.replace(/-+$/, '')
|
|
165
|
+
|
|
166
|
+
/** An absolute address for one `servers` entry: variables at their defaults, a relative URL against `source`. */
|
|
167
|
+
function resolveServer(entry: any, source: string | undefined): string | undefined {
|
|
168
|
+
if (typeof entry?.url !== 'string') return undefined
|
|
169
|
+
const url = entry.url
|
|
170
|
+
.replace(/\{([^}]+)\}/g, (whole: string, key: string) => {
|
|
171
|
+
const d = entry.variables?.[key]?.default
|
|
172
|
+
return d === undefined || d === null ? whole : String(d)
|
|
173
|
+
})
|
|
174
|
+
.trim()
|
|
175
|
+
if (/\{[^}]*\}/.test(url)) return undefined
|
|
176
|
+
if (isUrl(url)) return url.replace(/\/+$/, '')
|
|
177
|
+
if (source) {
|
|
178
|
+
try {
|
|
179
|
+
return new URL(url, source).toString().replace(/\/+$/, '')
|
|
180
|
+
} catch {
|
|
181
|
+
/* not an address */
|
|
182
|
+
}
|
|
79
183
|
}
|
|
80
|
-
if (api.host)
|
|
81
|
-
return `${api.schemes?.[0] ?? 'https'}://${api.host}${api.basePath ?? ''}`.replace(/\/$/, '')
|
|
82
|
-
if (isUrl(source)) return new URL(source).origin
|
|
83
184
|
return undefined
|
|
84
185
|
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The servers a document lists, as environments. The first is `live` (what a single-server spec has always
|
|
189
|
+
* had); the rest are named from their description, else their host, and are always a valid environment name.
|
|
190
|
+
*
|
|
191
|
+
* `needsBaseUrl` is true when the first server is relative and `source` cannot resolve it: the person is
|
|
192
|
+
* asked for the address, and what they type is `live`. `omitted` counts usable servers beyond `MAX_SERVERS`.
|
|
193
|
+
*/
|
|
194
|
+
export function openApiServers(
|
|
195
|
+
api: any,
|
|
196
|
+
source: string,
|
|
197
|
+
): { servers: ServerEntry[]; needsBaseUrl: boolean; omitted: number } {
|
|
198
|
+
const src = isUrl(source) ? source : undefined
|
|
199
|
+
const listed: any[] = Array.isArray(api.servers) ? api.servers : []
|
|
200
|
+
const servers: ServerEntry[] = []
|
|
201
|
+
const describe = (e: any) =>
|
|
202
|
+
typeof e?.description === 'string' && e.description.trim() !== ''
|
|
203
|
+
? { description: e.description.trim() as string }
|
|
204
|
+
: {}
|
|
205
|
+
|
|
206
|
+
if (listed.length === 0) {
|
|
207
|
+
// Swagger 2, or an OpenAPI 3 document that says nothing: the host, else wherever it was fetched from
|
|
208
|
+
if (api.host)
|
|
209
|
+
servers.push({
|
|
210
|
+
name: 'live',
|
|
211
|
+
url: `${api.schemes?.[0] ?? 'https'}://${api.host}${api.basePath ?? ''}`.replace(
|
|
212
|
+
/\/+$/,
|
|
213
|
+
'',
|
|
214
|
+
),
|
|
215
|
+
})
|
|
216
|
+
else if (src) servers.push({ name: 'live', url: new URL(src).origin })
|
|
217
|
+
return { servers, needsBaseUrl: false, omitted: 0 }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const first = resolveServer(listed[0], src)
|
|
221
|
+
if (first) servers.push({ name: 'live', url: first, ...describe(listed[0]) })
|
|
222
|
+
const needsBaseUrl =
|
|
223
|
+
!first && typeof listed[0]?.url === 'string' && !/^https?:\/\//i.test(listed[0].url)
|
|
224
|
+
|
|
225
|
+
const taken = new Set(['mock', 'live'])
|
|
226
|
+
const seen = new Set(servers.map((x) => x.url))
|
|
227
|
+
let omitted = 0
|
|
228
|
+
for (const entry of listed.slice(1)) {
|
|
229
|
+
const url = resolveServer(entry, src)
|
|
230
|
+
if (!url || seen.has(url)) continue
|
|
231
|
+
if (servers.length >= MAX_SERVERS) {
|
|
232
|
+
omitted++
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
seen.add(url)
|
|
236
|
+
const host = (() => {
|
|
237
|
+
try {
|
|
238
|
+
return new URL(url).host
|
|
239
|
+
} catch {
|
|
240
|
+
return ''
|
|
241
|
+
}
|
|
242
|
+
})()
|
|
243
|
+
const base = slug(describe(entry).description ?? '') || slug(host) || 'server'
|
|
244
|
+
let name = base
|
|
245
|
+
for (let n = 2; taken.has(name); n++) name = `${base}-${n}`
|
|
246
|
+
taken.add(name)
|
|
247
|
+
servers.push({ name, url, ...describe(entry) })
|
|
248
|
+
}
|
|
249
|
+
return { servers, needsBaseUrl, omitted }
|
|
250
|
+
}
|
|
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
|
+
|
|
278
|
+
/** Where the API lives, from `servers[0]` (OpenAPI 3) or `host`/`basePath` (Swagger 2). */
|
|
279
|
+
export function liveBaseUrl(api: any, source: string): string | undefined {
|
|
280
|
+
return openApiServers(api, source).servers.find((s) => s.name === 'live')?.url
|
|
281
|
+
}
|
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
|