@specbook/core 0.2.0 → 0.4.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 +5 -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 +113 -27
- package/src/runner/suite.ts +122 -0
- package/src/runner/transport.ts +65 -5
- package/src/secrets.ts +31 -2
- package/src/sequence.ts +4 -4
- package/src/spec-config.ts +77 -11
- package/src/template.ts +13 -4
- package/src/types.ts +49 -32
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/template.ts
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { faker } from './faker'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
const
|
|
5
|
+
// a name is words joined by dots, and a position in an array can be a dot (`tags.0.name`) or brackets (`tags[0].name`)
|
|
6
|
+
const NAME = String.raw`[\w.$\[\]-]+`
|
|
7
|
+
const WHOLE = new RegExp(String.raw`^\s*\{\{\s*(${NAME})\s*\}\}\s*$`)
|
|
8
|
+
const EMBED = new RegExp(String.raw`\{\{\s*(${NAME})\s*\}\}`, 'g')
|
|
9
|
+
|
|
10
|
+
/** The variable a placeholder starts from: `tags` for `tags[0].name`, `pet` for `pet.category.name`. */
|
|
11
|
+
export const rootName = (name: string): string => name.split(/[.[]/, 1)[0] as string
|
|
7
12
|
|
|
8
13
|
/** Namespaces resolved at the transport boundary, never in the request model. */
|
|
9
14
|
export const DEFERRED_NAMESPACES = ['secrets', 'session'] as const
|
|
@@ -39,11 +44,15 @@ export function usesFaker(value: unknown): boolean {
|
|
|
39
44
|
return [...collectPlaceholders(value)].some(isFakerName)
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
/** `tags[0].name` is `tags.0.name`: a position in brackets is the same step as one after a dot. */
|
|
48
|
+
const steps = (path: string) => path.replace(/\[(\d+)\]/g, '.$1').split('.')
|
|
49
|
+
|
|
42
50
|
function lookup(scope: Record<string, unknown>, path: string): { found: boolean; value?: unknown } {
|
|
43
51
|
if (isFakerName(path)) return fromFaker(path)
|
|
44
52
|
let cur: unknown = scope
|
|
45
|
-
for (const part of path
|
|
46
|
-
|
|
53
|
+
for (const part of steps(path)) {
|
|
54
|
+
// a value's own properties only: `toString` and `constructor` are not data, so a placeholder cannot reach them
|
|
55
|
+
if (cur !== null && typeof cur === 'object' && Object.hasOwn(cur as object, part)) {
|
|
47
56
|
cur = (cur as Record<string, unknown>)[part]
|
|
48
57
|
} else {
|
|
49
58
|
return { found: false }
|
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
|