@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
|
@@ -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 {
|
package/src/runner/index.ts
CHANGED
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
|
|
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
|
-
/**
|
|
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
|
-
| {
|
|
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
|
|
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
|
-
/**
|
|
68
|
-
|
|
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 (
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
['
|
|
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' ?
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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(
|
|
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, {
|
|
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
|
|
699
|
+
let inputs: Record<string, unknown>
|
|
615
700
|
try {
|
|
616
|
-
|
|
701
|
+
inputs = resolveControls(seq.meta.inputs ?? {}, o.inputs)
|
|
617
702
|
} catch (e: any) {
|
|
618
703
|
steps.push({
|
|
619
|
-
name: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
788
|
+
: (o.inputs ?? {})
|
|
704
789
|
const values = resolveControls(controls, given)
|
|
705
|
-
|
|
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
|
+
}
|