@specbook/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specbook/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "The runner, mock engine, template engine and shared proxy behind Specbook: Storybook for APIs.",
5
5
  "keywords": [
6
6
  "specbook",
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
- function locate(stack: string | undefined): AssertionResult['location'] {
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 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]) }
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
@@ -11,12 +11,19 @@ export interface EnvironmentConfig {
11
11
  * "prod", amber otherwise (`mock` is always neutral).
12
12
  */
13
13
  risk?: 'production' | 'sandbox'
14
+ /** One line on what this environment is; shown as the tooltip of its entry in the pickers. */
15
+ description?: string
14
16
  }
15
17
 
16
18
  export interface ServiceConfig<E extends string = string> {
17
19
  /** Path to an OpenAPI/Swagger file (relative to the config's project root). */
18
20
  spec: string
19
- baseUrl: Partial<Record<E | 'live', string>>
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>>
20
27
  /** Default auth profile for this service. */
21
28
  auth?: string
22
29
  }
@@ -49,7 +56,10 @@ export interface BrandingConfig {
49
56
 
50
57
  export interface SpecbookConfig<E extends string = string> {
51
58
  project: string
52
- /** Non-sensitive variables, safe to commit. */
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
+ */
53
63
  vars?: Record<string, unknown>
54
64
  environments?: Partial<Record<E, EnvironmentConfig>>
55
65
  /** Secret references; OSS resolves them from env vars / dotenv files. */
@@ -58,6 +68,11 @@ export interface SpecbookConfig<E extends string = string> {
58
68
  auth?: Record<string, AuthProfile>
59
69
  secretProviders?: SecretProvider[]
60
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
61
76
  codegen?: { outDir?: string; importPrefix?: string }
62
77
  branding?: BrandingConfig
63
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: (o: { default?: string; label?: string; description?: string } = {}): Control<string> => ({
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: { default?: number; min?: number; max?: number; label?: string; description?: string } = {},
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; label?: string; description?: string } = {},
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: (o: { default?: string; label?: string; description?: string } = {}): Control<string> => ({
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
- if (d !== undefined) return d
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 [k, { kind: ctl.kind, default: ctl.opts.default }]
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,21 +1,33 @@
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'
7
9
  export { faker, fakerLocaleNames, installFaker, prepareFaker, setFaker } from './faker'
8
10
  export * from './mock'
11
+ export * from './oauth'
9
12
  export * from './proxy/index'
10
13
  export * from './reporters/index'
11
- export { defineOperation, request } from './request'
14
+ export { defineOperation, request, withMock } from './request'
12
15
  export * from './runner/index'
13
16
  export * from './secrets'
14
17
  export type { Sequence, SequenceMeta, StepCtx, StepNode } from './sequence'
15
18
  export { sequence } from './sequence'
16
19
  export type { SnippetInput, SnippetLang } from './snippets'
17
20
  export { snippetLangs, toSnippet } from './snippets'
18
- export { authFromSpec, type JsonAuthProfile, liveBaseUrl, type SpecAuth } from './spec-config'
21
+ export {
22
+ authFromSpec,
23
+ environmentsFromServers,
24
+ type JsonAuthProfile,
25
+ liveBaseUrl,
26
+ MAX_SERVERS,
27
+ openApiServers,
28
+ type ServerEntry,
29
+ type SpecAuth,
30
+ } from './spec-config'
19
31
  export {
20
32
  collectPlaceholders,
21
33
  deepMerge,