@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/package.json
CHANGED
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
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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: (
|
|
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: {
|
|
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
|
|
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: (
|
|
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
|
-
|
|
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 [
|
|
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,
|
|
@@ -30,6 +33,7 @@ export {
|
|
|
30
33
|
deepMerge,
|
|
31
34
|
render,
|
|
32
35
|
renderString,
|
|
36
|
+
rootName,
|
|
33
37
|
TemplateError,
|
|
34
38
|
usesFaker,
|
|
35
39
|
} from './template'
|