@specbook/core 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +6 -2
- package/package.json +58 -3
- package/src/assert.ts +81 -0
- package/src/config.ts +69 -0
- package/src/controls.ts +167 -0
- package/src/example.ts +80 -0
- package/src/faker.ts +80 -0
- package/src/index.ts +27 -0
- package/src/mock-reflect.ts +242 -0
- package/src/mock.ts +115 -0
- package/src/proxy/forward.ts +192 -0
- package/src/proxy/index.ts +35 -0
- package/src/proxy/policy.ts +144 -0
- package/src/proxy/substitute.ts +94 -0
- package/src/proxy/wire.ts +181 -0
- package/src/reporters/index.ts +105 -0
- package/src/request.ts +68 -0
- package/src/runner/encode.ts +126 -0
- package/src/runner/index.ts +3 -0
- package/src/runner/run.ts +751 -0
- package/src/runner/transport.ts +95 -0
- package/src/secrets.ts +84 -0
- package/src/sequence.ts +83 -0
- package/src/snippets.ts +224 -0
- package/src/spec-config.ts +84 -0
- package/src/template.ts +111 -0
- package/src/types.ts +255 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ajai Shankar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @specbook/core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The runner, mock engine, template engine and shared proxy behind Specbook: Storybook for APIs.
|
|
4
|
+
|
|
5
|
+
Part of [Specbook](https://github.com/specbook-dev/specbook), Storybook for APIs. See the repository for documentation.
|
|
6
|
+
|
|
7
|
+
MIT licensed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specbook/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The runner, mock engine, template engine and shared proxy behind Specbook: Storybook for APIs.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"specbook",
|
|
7
|
+
"openapi",
|
|
8
|
+
"api-mocking",
|
|
9
|
+
"api-testing",
|
|
10
|
+
"mock-server"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"homepage": "https://ui.specbook.dev",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/specbook-dev/specbook/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/specbook-dev/specbook.git",
|
|
20
|
+
"directory": "packages/core"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./src/index.ts",
|
|
30
|
+
"default": "./src/index.ts"
|
|
31
|
+
},
|
|
32
|
+
"./assert": {
|
|
33
|
+
"types": "./src/assert.ts",
|
|
34
|
+
"default": "./src/assert.ts"
|
|
35
|
+
},
|
|
36
|
+
"./runner": {
|
|
37
|
+
"types": "./src/runner/index.ts",
|
|
38
|
+
"default": "./src/runner/index.ts"
|
|
39
|
+
},
|
|
40
|
+
"./reporters": {
|
|
41
|
+
"types": "./src/reporters/index.ts",
|
|
42
|
+
"default": "./src/reporters/index.ts"
|
|
43
|
+
},
|
|
44
|
+
"./faker": {
|
|
45
|
+
"types": "./src/faker.ts",
|
|
46
|
+
"default": "./src/faker.ts"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"uvu": "0.5.6"
|
|
51
|
+
},
|
|
52
|
+
"files": [
|
|
53
|
+
"src",
|
|
54
|
+
"!**/*.test.*"
|
|
55
|
+
],
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsc --noEmit",
|
|
58
|
+
"typecheck": "tsc --noEmit",
|
|
59
|
+
"test": "vitest run"
|
|
60
|
+
}
|
|
6
61
|
}
|
package/src/assert.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Instrumented uvu/assert: every call is recorded as pass/fail. Soft mode swallows failures.
|
|
2
|
+
import * as uvu from 'uvu/assert'
|
|
3
|
+
import type { Assert, AssertionResult, AssertMode } from './types'
|
|
4
|
+
|
|
5
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape sequences
|
|
6
|
+
const ANSI = /\u001b\[[0-9;]*m/g
|
|
7
|
+
export const stripAnsi = (s: string) => s.replace(ANSI, '')
|
|
8
|
+
|
|
9
|
+
/** Plain uvu/assert, for use outside a run. */
|
|
10
|
+
export const assert = uvu as unknown as Assert
|
|
11
|
+
|
|
12
|
+
function locate(stack: string | undefined): AssertionResult['location'] {
|
|
13
|
+
if (!stack) return undefined
|
|
14
|
+
const lines = stack.split('\n').slice(1)
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
if (line.includes('/core/src/assert') || line.includes('node_modules')) continue
|
|
17
|
+
const m = /(?:\(|at )?([^\s()]+):(\d+):(\d+)\)?\s*$/.exec(line)
|
|
18
|
+
if (m) return { file: m[1] as string, line: Number(m[2]), column: Number(m[3]) }
|
|
19
|
+
}
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Asserter {
|
|
24
|
+
assert: Assert
|
|
25
|
+
results: AssertionResult[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createAsserter(mode: AssertMode = 'soft'): Asserter {
|
|
29
|
+
const results: AssertionResult[] = []
|
|
30
|
+
|
|
31
|
+
const wrap = (name: string, fn: (...a: any[]) => void) => {
|
|
32
|
+
const msgIndex = fn.length - 1
|
|
33
|
+
return (...args: any[]) => {
|
|
34
|
+
const given = typeof args[msgIndex] === 'string' ? (args[msgIndex] as string) : undefined
|
|
35
|
+
try {
|
|
36
|
+
fn(...args)
|
|
37
|
+
results.push({
|
|
38
|
+
label: given ?? name,
|
|
39
|
+
operator: name,
|
|
40
|
+
pass: true,
|
|
41
|
+
location: locate(new Error().stack),
|
|
42
|
+
})
|
|
43
|
+
} catch (e: any) {
|
|
44
|
+
if (e?.name !== 'Assertion') throw e
|
|
45
|
+
results.push({
|
|
46
|
+
label: stripAnsi(String(e.message ?? given ?? name)),
|
|
47
|
+
operator: name,
|
|
48
|
+
pass: false,
|
|
49
|
+
actual: e.actual,
|
|
50
|
+
expected: e.expects,
|
|
51
|
+
diff: e.details ? stripAnsi(String(e.details)) : undefined,
|
|
52
|
+
location: locate(new Error().stack),
|
|
53
|
+
})
|
|
54
|
+
if (mode === 'hard') throw e
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const u = uvu as unknown as Record<string, any>
|
|
60
|
+
const out: Record<string, any> = {}
|
|
61
|
+
for (const key of [
|
|
62
|
+
'ok',
|
|
63
|
+
'is',
|
|
64
|
+
'equal',
|
|
65
|
+
'type',
|
|
66
|
+
'instance',
|
|
67
|
+
'match',
|
|
68
|
+
'throws',
|
|
69
|
+
'unreachable',
|
|
70
|
+
'snapshot',
|
|
71
|
+
'fixture',
|
|
72
|
+
]) {
|
|
73
|
+
if (typeof u[key] === 'function') out[key] = wrap(key, u[key])
|
|
74
|
+
}
|
|
75
|
+
const not = wrap('not', u.not)
|
|
76
|
+
for (const key of Object.keys(u.not ?? {})) {
|
|
77
|
+
if (typeof u.not[key] === 'function') (not as any)[key] = wrap(`not.${key}`, u.not[key])
|
|
78
|
+
}
|
|
79
|
+
out.not = not
|
|
80
|
+
return { assert: out as Assert, results }
|
|
81
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { FakerConfig } from './faker'
|
|
2
|
+
import type { AuthProfile } from './runner/run'
|
|
3
|
+
import type { SecretProvider, SecretRef } from './secrets'
|
|
4
|
+
|
|
5
|
+
/** What the UI shows for an environment beyond its variables. */
|
|
6
|
+
export interface EnvironmentConfig {
|
|
7
|
+
vars?: Record<string, unknown>
|
|
8
|
+
/**
|
|
9
|
+
* How careful the UI should look. `production` shows the environment in red wherever it is named;
|
|
10
|
+
* `sandbox` keeps it amber even when the name contains "prod". Unset: red if the name contains
|
|
11
|
+
* "prod", amber otherwise (`mock` is always neutral).
|
|
12
|
+
*/
|
|
13
|
+
risk?: 'production' | 'sandbox'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ServiceConfig<E extends string = string> {
|
|
17
|
+
/** Path to an OpenAPI/Swagger file (relative to the config's project root). */
|
|
18
|
+
spec: string
|
|
19
|
+
baseUrl: Partial<Record<E | 'live', string>>
|
|
20
|
+
/** Default auth profile for this service. */
|
|
21
|
+
auth?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** How the docs site presents itself. Everything is optional; unset fields fall back to the API's own title. */
|
|
25
|
+
export interface BrandingConfig {
|
|
26
|
+
/** Header, overview and browser-tab title. */
|
|
27
|
+
name?: string
|
|
28
|
+
/** The overview's headline, under the logo: what this site is for. The name stays in the header and tab. */
|
|
29
|
+
title?: string
|
|
30
|
+
tagline?: string
|
|
31
|
+
/** Accent colour for buttons, selection and links (any hex colour). */
|
|
32
|
+
accent?: string
|
|
33
|
+
/** Accent in dark mode, when the light one needs to differ. */
|
|
34
|
+
accentDark?: string
|
|
35
|
+
/**
|
|
36
|
+
* Logo and favicon as URLs. Without these, drop `logo.svg` / `favicon.svg` (or png, jpg, webp)
|
|
37
|
+
* into `.specbook/branding/` and they are picked up.
|
|
38
|
+
*/
|
|
39
|
+
logo?: string
|
|
40
|
+
favicon?: string
|
|
41
|
+
/** Links shown under the overview text. */
|
|
42
|
+
links?: { label: string; href: string }[]
|
|
43
|
+
/**
|
|
44
|
+
* Show "Powered by Specbook" under the overview of a published site (default). Set `false` to hide it.
|
|
45
|
+
* It is never shown by the dev server, or on a `*.specbook.dev` page.
|
|
46
|
+
*/
|
|
47
|
+
badge?: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SpecbookConfig<E extends string = string> {
|
|
51
|
+
project: string
|
|
52
|
+
/** Non-sensitive variables, safe to commit. */
|
|
53
|
+
vars?: Record<string, unknown>
|
|
54
|
+
environments?: Partial<Record<E, EnvironmentConfig>>
|
|
55
|
+
/** Secret references; OSS resolves them from env vars / dotenv files. */
|
|
56
|
+
secrets?: Record<string, SecretRef>
|
|
57
|
+
services: Record<string, ServiceConfig<E>>
|
|
58
|
+
auth?: Record<string, AuthProfile>
|
|
59
|
+
secretProviders?: SecretProvider[]
|
|
60
|
+
faker?: FakerConfig
|
|
61
|
+
codegen?: { outDir?: string; importPrefix?: string }
|
|
62
|
+
branding?: BrandingConfig
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function defineConfig<const E extends string = string>(
|
|
66
|
+
c: SpecbookConfig<E>,
|
|
67
|
+
): SpecbookConfig<E> {
|
|
68
|
+
return c
|
|
69
|
+
}
|
package/src/controls.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Tiny Storybook-style controls DSL: runtime descriptors + inferred value types.
|
|
2
|
+
|
|
3
|
+
export interface Control<T = unknown> {
|
|
4
|
+
readonly kind:
|
|
5
|
+
| 'text'
|
|
6
|
+
| 'number'
|
|
7
|
+
| 'boolean'
|
|
8
|
+
| 'date'
|
|
9
|
+
| 'select'
|
|
10
|
+
| 'multiselect'
|
|
11
|
+
| 'object'
|
|
12
|
+
| 'array'
|
|
13
|
+
readonly opts: Record<string, any>
|
|
14
|
+
/** Phantom type carrier. */
|
|
15
|
+
readonly _type?: T
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type Controls = Record<string, Control<any>>
|
|
19
|
+
export type InferControls<P> = { [K in keyof P]: P[K] extends Control<infer T> ? T : never }
|
|
20
|
+
|
|
21
|
+
interface SelectOpts<O, V> {
|
|
22
|
+
/** shown beside the control in the UI */
|
|
23
|
+
description?: string
|
|
24
|
+
label?: (o: O) => string
|
|
25
|
+
value?: (o: O) => V
|
|
26
|
+
default?: number | V | ((options: readonly O[]) => V)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const c = {
|
|
30
|
+
text: (o: { default?: string; label?: string; description?: string } = {}): Control<string> => ({
|
|
31
|
+
kind: 'text',
|
|
32
|
+
opts: o,
|
|
33
|
+
}),
|
|
34
|
+
number: (
|
|
35
|
+
o: { default?: number; min?: number; max?: number; label?: string; description?: string } = {},
|
|
36
|
+
): Control<number> => ({
|
|
37
|
+
kind: 'number',
|
|
38
|
+
opts: o,
|
|
39
|
+
}),
|
|
40
|
+
boolean: (
|
|
41
|
+
o: { default?: boolean; label?: string; description?: string } = {},
|
|
42
|
+
): Control<boolean> => ({
|
|
43
|
+
kind: 'boolean',
|
|
44
|
+
opts: o,
|
|
45
|
+
}),
|
|
46
|
+
date: (o: { default?: string; label?: string; description?: string } = {}): Control<string> => ({
|
|
47
|
+
kind: 'date',
|
|
48
|
+
opts: o,
|
|
49
|
+
}),
|
|
50
|
+
select: <const O, V = O>(
|
|
51
|
+
options: readonly O[],
|
|
52
|
+
o: SelectOpts<O, V> = {},
|
|
53
|
+
): Control<NoInfer<V>> => ({
|
|
54
|
+
kind: 'select',
|
|
55
|
+
opts: { ...o, options },
|
|
56
|
+
}),
|
|
57
|
+
multiselect: <const O, V = O>(
|
|
58
|
+
options: readonly O[],
|
|
59
|
+
o: SelectOpts<O, V> = {},
|
|
60
|
+
): Control<NoInfer<V>[]> => ({
|
|
61
|
+
kind: 'multiselect',
|
|
62
|
+
opts: { ...o, options },
|
|
63
|
+
}),
|
|
64
|
+
object: <S extends Controls>(shape: S): Control<InferControls<S>> => ({
|
|
65
|
+
kind: 'object',
|
|
66
|
+
opts: { shape },
|
|
67
|
+
}),
|
|
68
|
+
array: <T>(
|
|
69
|
+
item: Control<T>,
|
|
70
|
+
o: { default?: T[]; min?: number; max?: number } = {},
|
|
71
|
+
): Control<T[]> => ({ kind: 'array', opts: { ...o, item } }),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class ControlError extends Error {}
|
|
75
|
+
|
|
76
|
+
function optionValue(ctl: Control, option: unknown): unknown {
|
|
77
|
+
const fn = ctl.opts.value as ((o: unknown) => unknown) | undefined
|
|
78
|
+
return fn ? fn(option) : option
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function same(a: unknown, b: unknown): boolean {
|
|
82
|
+
return a === b || JSON.stringify(a) === JSON.stringify(b)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Resolve one control to a value: given > default > first option / empty. */
|
|
86
|
+
export function resolveControl(name: string, ctl: Control, given: unknown): unknown {
|
|
87
|
+
switch (ctl.kind) {
|
|
88
|
+
case 'select': {
|
|
89
|
+
const options = ctl.opts.options as unknown[]
|
|
90
|
+
if (options.length === 0) throw new ControlError(`No options available for "${name}"`)
|
|
91
|
+
const values = options.map((o) => optionValue(ctl, o))
|
|
92
|
+
if (given !== undefined) {
|
|
93
|
+
if (!values.some((v) => same(v, given))) {
|
|
94
|
+
throw new ControlError(`Value for "${name}" is not one of the available options`)
|
|
95
|
+
}
|
|
96
|
+
return given
|
|
97
|
+
}
|
|
98
|
+
const d = ctl.opts.default
|
|
99
|
+
if (
|
|
100
|
+
typeof d === 'number' &&
|
|
101
|
+
Number.isInteger(d) &&
|
|
102
|
+
d >= 0 &&
|
|
103
|
+
d < values.length &&
|
|
104
|
+
!(typeof values[0] === 'number')
|
|
105
|
+
)
|
|
106
|
+
return values[d]
|
|
107
|
+
if (typeof d === 'function') return d(options)
|
|
108
|
+
if (d !== undefined) return d
|
|
109
|
+
return values[0]
|
|
110
|
+
}
|
|
111
|
+
case 'multiselect': {
|
|
112
|
+
const options = ctl.opts.options as unknown[]
|
|
113
|
+
if (given !== undefined) return given
|
|
114
|
+
const d = ctl.opts.default
|
|
115
|
+
if (typeof d === 'function') return d(options)
|
|
116
|
+
return d ?? []
|
|
117
|
+
}
|
|
118
|
+
case 'object': {
|
|
119
|
+
const shape = ctl.opts.shape as Controls
|
|
120
|
+
const g = (given ?? {}) as Record<string, unknown>
|
|
121
|
+
return Object.fromEntries(
|
|
122
|
+
Object.entries(shape).map(([k, v]) => [k, resolveControl(k, v, g[k])]),
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
case 'array': {
|
|
126
|
+
const v = (given ?? ctl.opts.default ?? []) as unknown[]
|
|
127
|
+
const { min, max } = ctl.opts
|
|
128
|
+
if (min !== undefined && v.length < min)
|
|
129
|
+
throw new ControlError(`"${name}" needs at least ${min} items`)
|
|
130
|
+
if (max !== undefined && v.length > max)
|
|
131
|
+
throw new ControlError(`"${name}" allows at most ${max} items`)
|
|
132
|
+
return v.map((item) => resolveControl(name, ctl.opts.item, item))
|
|
133
|
+
}
|
|
134
|
+
case 'boolean':
|
|
135
|
+
return given ?? ctl.opts.default ?? false
|
|
136
|
+
case 'number':
|
|
137
|
+
return given ?? ctl.opts.default ?? 0
|
|
138
|
+
default:
|
|
139
|
+
return given ?? ctl.opts.default ?? ''
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resolveControls(
|
|
144
|
+
controls: Controls,
|
|
145
|
+
given: Record<string, unknown> = {},
|
|
146
|
+
): Record<string, unknown> {
|
|
147
|
+
return Object.fromEntries(
|
|
148
|
+
Object.entries(controls).map(([k, ctl]) => [k, resolveControl(k, ctl, given[k])]),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Serializable description for a UI to render an input step. */
|
|
153
|
+
export function describeControls(controls: Controls) {
|
|
154
|
+
return Object.fromEntries(
|
|
155
|
+
Object.entries(controls).map(([k, ctl]) => {
|
|
156
|
+
if (ctl.kind === 'select' || ctl.kind === 'multiselect') {
|
|
157
|
+
const labelFn = ctl.opts.label as ((o: unknown) => string) | undefined
|
|
158
|
+
const options = (ctl.opts.options as unknown[]).map((o) => ({
|
|
159
|
+
label: labelFn ? labelFn(o) : String(optionValue(ctl, o)),
|
|
160
|
+
value: optionValue(ctl, o),
|
|
161
|
+
}))
|
|
162
|
+
return [k, { kind: ctl.kind, options }]
|
|
163
|
+
}
|
|
164
|
+
return [k, { kind: ctl.kind, default: ctl.opts.default }]
|
|
165
|
+
}),
|
|
166
|
+
)
|
|
167
|
+
}
|
package/src/example.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { flattenForm, isBlob } from './runner/encode'
|
|
2
|
+
import type { OpMeta } from './types'
|
|
3
|
+
|
|
4
|
+
/** A complete request an API author (or the spec) defines for an operation. */
|
|
5
|
+
export interface Example {
|
|
6
|
+
name: string
|
|
7
|
+
title?: string
|
|
8
|
+
summary?: string
|
|
9
|
+
source: 'spec' | 'generated' | 'project'
|
|
10
|
+
path?: Record<string, unknown>
|
|
11
|
+
/** Arrays send the same key repeatedly. */
|
|
12
|
+
query?: Record<string, unknown>
|
|
13
|
+
headers?: Record<string, string>
|
|
14
|
+
body?: unknown
|
|
15
|
+
/** Directory file-field paths are relative to; set by whoever loads the example. */
|
|
16
|
+
dir?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ExampleLoader = (op: OpMeta, name: string) => Promise<Example | undefined>
|
|
20
|
+
export type FileReader = (path: string, dir?: string) => Promise<File>
|
|
21
|
+
|
|
22
|
+
/** One line of an inline grid editor. Disabled rows stay visible but are not sent. */
|
|
23
|
+
export interface Row {
|
|
24
|
+
key: string
|
|
25
|
+
value: string
|
|
26
|
+
enabled: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const text = (v: unknown) =>
|
|
30
|
+
typeof v === 'string' ? v : typeof v === 'object' && v !== null ? JSON.stringify(v) : String(v)
|
|
31
|
+
|
|
32
|
+
export function toRows(o: Record<string, unknown> = {}): Row[] {
|
|
33
|
+
return Object.entries(o).flatMap(([key, v]) =>
|
|
34
|
+
(Array.isArray(v) ? v : [v])
|
|
35
|
+
.filter((x) => x !== undefined && x !== null)
|
|
36
|
+
.map((x) => ({ key, value: text(x), enabled: true })),
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function fromRows(rows: Row[]): Record<string, unknown> {
|
|
41
|
+
const out: Record<string, unknown> = {}
|
|
42
|
+
for (const { key, value, enabled } of rows) {
|
|
43
|
+
if (!enabled || key === '') continue
|
|
44
|
+
const had = out[key]
|
|
45
|
+
out[key] = had === undefined ? value : [...(Array.isArray(had) ? had : [had]), value]
|
|
46
|
+
}
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Rows for a form/multipart body: nested values become bracket keys (`metadata[order]`). Files stay as values. */
|
|
51
|
+
export function toFormRows(body: unknown): Row[] {
|
|
52
|
+
if (body === null || typeof body !== 'object') return []
|
|
53
|
+
return flattenForm(body).map(([key, value]) => ({ key, value, enabled: true }))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SEGMENT = /[^[\]]+|\[\]/g
|
|
57
|
+
|
|
58
|
+
function assign(node: any, segs: string[], value: unknown): any {
|
|
59
|
+
const [head, ...rest] = segs as [string, ...string[]]
|
|
60
|
+
if (head === '[]') {
|
|
61
|
+
const list: unknown[] = node ?? []
|
|
62
|
+
list.push(rest.length ? assign(undefined, rest, value) : value)
|
|
63
|
+
return list
|
|
64
|
+
}
|
|
65
|
+
const target = node ?? (/^\d+$/.test(head) ? [] : {})
|
|
66
|
+
const had = target[head]
|
|
67
|
+
if (rest.length) target[head] = assign(had, rest, value)
|
|
68
|
+
else target[head] = had === undefined ? value : [...([] as unknown[]).concat(had), value]
|
|
69
|
+
return target
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Inverse of `toFormRows`; disabled rows are dropped and a repeated plain key becomes an array. */
|
|
73
|
+
export function fromFormRows(rows: (Row & { file?: File })[]): Record<string, unknown> {
|
|
74
|
+
let out: Record<string, unknown> = {}
|
|
75
|
+
for (const { key, value, enabled, file } of rows) {
|
|
76
|
+
if (!enabled || key === '') continue
|
|
77
|
+
out = assign(out, key.match(SEGMENT) ?? [key], file && isBlob(file) ? file : value)
|
|
78
|
+
}
|
|
79
|
+
return out
|
|
80
|
+
}
|
package/src/faker.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Locale-aware faker proxy. `@faker-js/faker` is a peer dependency of the user's project;
|
|
2
|
+
// the runtime (CLI / UI) installs the concrete instance for the selected locale before a run.
|
|
3
|
+
|
|
4
|
+
type FakerLike = Record<string, any>
|
|
5
|
+
|
|
6
|
+
// Held on globalThis so the CLI's bundled copy of core and the project's own import share one instance.
|
|
7
|
+
const KEY = '__specbookFaker'
|
|
8
|
+
const store = globalThis as unknown as Record<string, FakerLike | undefined>
|
|
9
|
+
|
|
10
|
+
export const faker: FakerLike = new Proxy({} as FakerLike, {
|
|
11
|
+
get(_t, key) {
|
|
12
|
+
const current = store[KEY]
|
|
13
|
+
if (!current) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
'faker is not initialised. Install "@faker-js/faker" in your project; specbook sets it up before each run.',
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
return current[key as string]
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
export function setFaker(instance: FakerLike | undefined): void {
|
|
23
|
+
store[KEY] = instance
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface FakerConfig {
|
|
27
|
+
/** locale name -> fallback chain of faker locale export names, e.g. { de: ['de', 'en', 'base'] } */
|
|
28
|
+
locales?: Record<string, string[]>
|
|
29
|
+
default?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface FakerModule {
|
|
33
|
+
Faker: new (opts: { locale: unknown[] }) => FakerLike
|
|
34
|
+
[locale: string]: unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The locale exports a run needs, in fallback order: the chain for `locale`, then `base`. */
|
|
38
|
+
export function fakerLocaleNames(config: FakerConfig = {}, locale?: string): string[] {
|
|
39
|
+
const name = locale ?? config.default ?? 'en'
|
|
40
|
+
return [...new Set([...(config.locales?.[name] ?? [name, 'en']), 'base'])]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The module `installFaker` needs. A bundled build offers each locale as `locales[name]()` so only
|
|
45
|
+
* the ones a run falls back through are fetched; a plain faker module already has them all.
|
|
46
|
+
*/
|
|
47
|
+
export async function prepareFaker(
|
|
48
|
+
mod: Record<string, any>,
|
|
49
|
+
config: FakerConfig = {},
|
|
50
|
+
locale?: string,
|
|
51
|
+
): Promise<FakerModule> {
|
|
52
|
+
const locales = mod.locales as Record<string, () => Promise<unknown>> | undefined
|
|
53
|
+
if (!locales) return mod as FakerModule
|
|
54
|
+
const ready: Record<string, unknown> = { Faker: mod.Faker }
|
|
55
|
+
await Promise.all(
|
|
56
|
+
fakerLocaleNames(config, locale).map(async (n) => {
|
|
57
|
+
const load = locales[n]
|
|
58
|
+
if (load) ready[n] = await load()
|
|
59
|
+
}),
|
|
60
|
+
)
|
|
61
|
+
return ready as FakerModule
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Build a Faker for `locale` from the (dynamically imported) module, install it, and optionally seed it. */
|
|
65
|
+
export function installFaker(
|
|
66
|
+
mod: FakerModule,
|
|
67
|
+
config: FakerConfig = {},
|
|
68
|
+
opts: { locale?: string; seed?: number } = {},
|
|
69
|
+
): FakerLike {
|
|
70
|
+
const name = opts.locale ?? config.default ?? 'en'
|
|
71
|
+
const locales = fakerLocaleNames(config, name).map((n) => {
|
|
72
|
+
const l = mod[n]
|
|
73
|
+
if (!l) throw new Error(`Unknown faker locale "${n}" (from "${name}")`)
|
|
74
|
+
return l
|
|
75
|
+
})
|
|
76
|
+
const instance = new mod.Faker({ locale: locales })
|
|
77
|
+
if (opts.seed !== undefined) instance.seed(opts.seed)
|
|
78
|
+
setFaker(instance)
|
|
79
|
+
return instance
|
|
80
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export { assert, createAsserter, stripAnsi } from './assert'
|
|
2
|
+
export * from './config'
|
|
3
|
+
export * from './controls'
|
|
4
|
+
export type { Example, ExampleLoader, FileReader, Row } from './example'
|
|
5
|
+
export { fromFormRows, fromRows, toFormRows, toRows } from './example'
|
|
6
|
+
export type { FakerConfig } from './faker'
|
|
7
|
+
export { faker, fakerLocaleNames, installFaker, prepareFaker, setFaker } from './faker'
|
|
8
|
+
export * from './mock'
|
|
9
|
+
export * from './proxy/index'
|
|
10
|
+
export * from './reporters/index'
|
|
11
|
+
export { defineOperation, request } from './request'
|
|
12
|
+
export * from './runner/index'
|
|
13
|
+
export * from './secrets'
|
|
14
|
+
export type { Sequence, SequenceMeta, StepCtx, StepNode } from './sequence'
|
|
15
|
+
export { sequence } from './sequence'
|
|
16
|
+
export type { SnippetInput, SnippetLang } from './snippets'
|
|
17
|
+
export { snippetLangs, toSnippet } from './snippets'
|
|
18
|
+
export { authFromSpec, type JsonAuthProfile, liveBaseUrl, type SpecAuth } from './spec-config'
|
|
19
|
+
export {
|
|
20
|
+
collectPlaceholders,
|
|
21
|
+
deepMerge,
|
|
22
|
+
render,
|
|
23
|
+
renderString,
|
|
24
|
+
TemplateError,
|
|
25
|
+
usesFaker,
|
|
26
|
+
} from './template'
|
|
27
|
+
export * from './types'
|