@specbook/ui 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/dist-viewer/assets/Host-DsO9W5X8.js +41 -0
- package/dist-viewer/assets/index-D59fkVA9.js +1 -0
- package/dist-viewer/assets/main-i06Egg5h.js +14 -0
- package/dist-viewer/assets/{mount-CGL2tOpN.js → mount-CLHBtIzR.js} +2 -2
- package/dist-viewer/assets/panel-l1xvY4kI.css +1 -0
- package/dist-viewer/assets/{panel-C8Ve3LeS.js → panel-nV09I7Wr.js} +1 -1
- package/dist-viewer/assets/registry-b_jzfqy0.js +2 -0
- package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
- package/dist-viewer/index.html +4 -4
- package/dist-viewer/specbook.js +1 -1
- package/package.json +6 -5
- package/src/app.tsx +67 -12
- package/src/branding.ts +12 -4
- package/src/components/Catalog.tsx +128 -51
- package/src/components/EnvBadge.tsx +21 -3
- package/src/components/EnvironmentMenu.tsx +237 -127
- package/src/components/Fields.tsx +18 -1
- package/src/components/NewEnvironmentDialog.tsx +118 -0
- package/src/components/Palette.tsx +7 -2
- package/src/custom-environments.ts +63 -0
- package/src/embed/panel.tsx +286 -44
- package/src/embed/styles.ts +2 -0
- package/src/embed/target.ts +82 -0
- package/src/embed/theme.ts +75 -0
- package/src/embed.ts +44 -15
- package/src/engine.ts +274 -32
- package/src/environment-list.ts +16 -0
- package/src/main.tsx +7 -2
- package/src/nav.ts +12 -1
- package/src/oauth.ts +9 -8
- package/src/panel/Body.tsx +27 -4
- package/src/panel/Checks.tsx +79 -0
- package/src/panel/CodeSample.tsx +16 -9
- package/src/panel/Combo.tsx +52 -13
- package/src/panel/ControlsGrid.tsx +54 -9
- package/src/panel/ErrorBoundary.tsx +36 -0
- package/src/panel/Host.tsx +14 -2
- package/src/panel/OAuthSignIn.tsx +6 -2
- package/src/panel/OperationDocs.tsx +1 -1
- package/src/panel/Panel.tsx +43 -10
- package/src/panel/ParamGrid.tsx +15 -8
- package/src/panel/RequestPanel.tsx +102 -39
- package/src/panel/RequestView.tsx +2 -1
- package/src/panel/ResponseView.tsx +25 -14
- package/src/panel/SecretsBar.tsx +10 -9
- package/src/panel/SequenceDocs.tsx +7 -6
- package/src/panel/SequencePanel.tsx +124 -32
- package/src/panel/announce.ts +17 -0
- package/src/panel/icons.tsx +9 -0
- package/src/panel/short.ts +24 -0
- package/src/routing.ts +0 -9
- package/src/runtime.ts +19 -1
- package/src/secrets.ts +4 -2
- package/src/storage.ts +58 -0
- package/src/store.ts +8 -0
- package/src/styles/app.css +82 -6
- package/src/styles/embed.css +116 -0
- package/src/styles/panel.css +235 -4
- package/src/viewer/embed.ts +9 -2
- package/src/viewer/manifests.ts +2 -0
- package/src/viewer/mount.ts +2 -0
- package/src/viewer/protocol.ts +6 -0
- package/src/viewer/registry.ts +48 -10
- package/src/viewer/worker.ts +9 -1
- package/src/virtual.d.ts +8 -0
- package/dist-viewer/assets/Host-CSrM9wY1.js +0 -37
- package/dist-viewer/assets/index-ez9c20v3.js +0 -1
- package/dist-viewer/assets/main-38QGGpNT.js +0 -14
- package/dist-viewer/assets/panel-C-vBZrep.css +0 -1
- package/dist-viewer/assets/registry-ChKCFBt1.js +0 -2
- package/dist-viewer/assets/worker-ClGb0tE1.js +0 -181
package/src/panel/SecretsBar.tsx
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { absoluteBase, type OAuthSettings } from '@specbook/core'
|
|
2
2
|
import { useEffect, useState } from 'preact/hooks'
|
|
3
3
|
import { expiryOf, noteExpiry } from '../oauth'
|
|
4
|
-
import { absoluteBase } from '../routing'
|
|
5
4
|
import { engine } from '../runtime'
|
|
6
5
|
import { originOf, SECRET_PLACEHOLDER, secretStore } from '../secrets'
|
|
7
|
-
import { env, version } from '../store'
|
|
6
|
+
import { env, envVarsVersion, version } from '../store'
|
|
8
7
|
import { OAuthSignIn } from './OAuthSignIn'
|
|
9
8
|
|
|
10
9
|
interface Need {
|
|
@@ -60,15 +59,17 @@ export function SecretsBar({
|
|
|
60
59
|
}
|
|
61
60
|
let on = true
|
|
62
61
|
if (!supplied) engine.managedSecrets(env.value).then((m) => on && setManaged(m))
|
|
63
|
-
engine.config(version.value).then((cfg) => {
|
|
64
|
-
if (!on) return
|
|
62
|
+
engine.config(version.value).then(async (cfg) => {
|
|
65
63
|
const out: Need[] = []
|
|
64
|
+
const where = await engine.addressesIn(cfg, env.value)
|
|
66
65
|
for (const [service, s] of Object.entries(cfg.services ?? {})) {
|
|
67
66
|
if (services && !services.includes(service)) continue
|
|
68
67
|
const profile = s.auth ? cfg.auth?.[s.auth] : undefined
|
|
69
|
-
|
|
68
|
+
// the address this environment's variables give; one that cannot be worked out has no origin
|
|
69
|
+
const base = where.baseUrl(service)
|
|
70
70
|
const origin = originOf(base ? absoluteBase(base, globalThis.location?.href) : undefined)
|
|
71
71
|
if (!profile || !origin) continue
|
|
72
|
+
const oauth = s.auth ? where.oauth(s.auth) : undefined
|
|
72
73
|
const names = [
|
|
73
74
|
...new Set(
|
|
74
75
|
[...JSON.stringify(profile.apply).matchAll(SECRET_PLACEHOLDER)].map(
|
|
@@ -83,15 +84,15 @@ export function SecretsBar({
|
|
|
83
84
|
title: profile.title ?? s.auth ?? '',
|
|
84
85
|
names,
|
|
85
86
|
profile: s.auth as string,
|
|
86
|
-
oauth
|
|
87
|
+
oauth,
|
|
87
88
|
})
|
|
88
89
|
}
|
|
89
|
-
setNeeds(out)
|
|
90
|
+
if (on) setNeeds(out)
|
|
90
91
|
})
|
|
91
92
|
return () => {
|
|
92
93
|
on = false
|
|
93
94
|
}
|
|
94
|
-
}, [live, env.value, wanted, version.value])
|
|
95
|
+
}, [live, env.value, wanted, version.value, envVarsVersion.value])
|
|
95
96
|
|
|
96
97
|
if (!live || needs.length === 0) return null
|
|
97
98
|
const have = (n: Need, name: string) =>
|
|
@@ -2,6 +2,7 @@ import { useMemo } from 'preact/hooks'
|
|
|
2
2
|
import { defaultFor, type FieldSpec, specFromControl } from '../components/Fields'
|
|
3
3
|
import type { SequenceInfo } from '../engine'
|
|
4
4
|
import { go } from '../nav'
|
|
5
|
+
import { show } from './short'
|
|
5
6
|
import { useSequenceNotes } from './stepnotes'
|
|
6
7
|
|
|
7
8
|
const KIND: Record<string, string> = {
|
|
@@ -11,7 +12,7 @@ const KIND: Record<string, string> = {
|
|
|
11
12
|
check: 'check',
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
const
|
|
15
|
+
const showValue = (v: unknown) => (typeof v === 'string' ? v : show(v))
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* What a sequence does, as documentation: its description (markdown), an optional markdown page from
|
|
@@ -21,9 +22,9 @@ export function SequenceDocs({ info }: { info: SequenceInfo }) {
|
|
|
21
22
|
const { seq } = info
|
|
22
23
|
const { intro, notes } = useSequenceNotes(info)
|
|
23
24
|
|
|
24
|
-
const
|
|
25
|
+
const inputs = useMemo(
|
|
25
26
|
() =>
|
|
26
|
-
Object.entries((seq.meta.
|
|
27
|
+
Object.entries((seq.meta.inputs ?? {}) as Record<string, unknown>).map(([name, c]) => {
|
|
27
28
|
const spec: FieldSpec = specFromControl(c)
|
|
28
29
|
return { name, spec, initial: defaultFor(spec) }
|
|
29
30
|
}),
|
|
@@ -42,7 +43,7 @@ export function SequenceDocs({ info }: { info: SequenceInfo }) {
|
|
|
42
43
|
|
|
43
44
|
{intro && <div class="doc-custom" dangerouslySetInnerHTML={{ __html: intro }} />}
|
|
44
45
|
|
|
45
|
-
{
|
|
46
|
+
{inputs.length > 0 && (
|
|
46
47
|
<section>
|
|
47
48
|
<h2>Controls</h2>
|
|
48
49
|
<table class="fields">
|
|
@@ -55,7 +56,7 @@ export function SequenceDocs({ info }: { info: SequenceInfo }) {
|
|
|
55
56
|
</tr>
|
|
56
57
|
</thead>
|
|
57
58
|
<tbody>
|
|
58
|
-
{
|
|
59
|
+
{inputs.map((p) => (
|
|
59
60
|
<tr>
|
|
60
61
|
<td>
|
|
61
62
|
<code>{p.name}</code>
|
|
@@ -64,7 +65,7 @@ export function SequenceDocs({ info }: { info: SequenceInfo }) {
|
|
|
64
65
|
<span class="chip">{p.spec.kind}</span>
|
|
65
66
|
</td>
|
|
66
67
|
<td>
|
|
67
|
-
<code>{
|
|
68
|
+
<code>{showValue(p.initial)}</code>
|
|
68
69
|
</td>
|
|
69
70
|
<td>{p.spec.description}</td>
|
|
70
71
|
</tr>
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import type { RunResult, StepResult } from '@specbook/core'
|
|
2
|
-
import { useEffect, useMemo, useState } from 'preact/hooks'
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
3
3
|
import { EnvBadge } from '../components/EnvBadge'
|
|
4
4
|
import { defaultFor, type FieldSpec, specFromControl } from '../components/Fields'
|
|
5
5
|
import type { SequenceInfo } from '../engine'
|
|
6
6
|
import { engine } from '../runtime'
|
|
7
7
|
import { env, locale, seed, version } from '../store'
|
|
8
|
+
import { announcement, describeRun } from './announce'
|
|
9
|
+
import { Failure } from './Checks'
|
|
8
10
|
import { ControlsGrid } from './ControlsGrid'
|
|
9
11
|
import { SecretsBar } from './SecretsBar'
|
|
10
12
|
import { StepExchange } from './StepExchange'
|
|
13
|
+
import { short, show } from './short'
|
|
11
14
|
import { useSequenceNotes } from './stepnotes'
|
|
12
15
|
|
|
13
16
|
/** `Check items #2` belongs to the step `Check items`. */
|
|
@@ -24,18 +27,24 @@ const ICON = {
|
|
|
24
27
|
paused: '⏸',
|
|
25
28
|
pending: '',
|
|
26
29
|
}
|
|
27
|
-
const short = (v: unknown) => {
|
|
28
|
-
const s = typeof v === 'string' ? v : JSON.stringify(v)
|
|
29
|
-
return s.length > 28 ? `${s.slice(0, 27)}…` : s
|
|
30
|
-
}
|
|
31
30
|
|
|
32
31
|
/** |timeline|request|: the journey on the left, the selected step's exchange on the right. */
|
|
33
|
-
export function SequencePanel({
|
|
32
|
+
export function SequencePanel({
|
|
33
|
+
info,
|
|
34
|
+
preset,
|
|
35
|
+
autoRun,
|
|
36
|
+
}: {
|
|
37
|
+
info: SequenceInfo
|
|
38
|
+
/** input values to start from instead of the defaults (an embed's data-specbook-inputs) */
|
|
39
|
+
preset?: Record<string, unknown>
|
|
40
|
+
/** run on the defaults as soon as the flow is ready */
|
|
41
|
+
autoRun?: boolean
|
|
42
|
+
}) {
|
|
34
43
|
const { seq } = info
|
|
35
44
|
const specs = useMemo(
|
|
36
45
|
() =>
|
|
37
46
|
Object.fromEntries(
|
|
38
|
-
Object.entries((seq.meta.
|
|
47
|
+
Object.entries((seq.meta.inputs ?? {}) as Record<string, unknown>).map(([k, c]) => [
|
|
39
48
|
k,
|
|
40
49
|
specFromControl(c),
|
|
41
50
|
]),
|
|
@@ -52,7 +61,7 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
|
|
|
52
61
|
}
|
|
53
62
|
return [...names]
|
|
54
63
|
}, [seq])
|
|
55
|
-
const [
|
|
64
|
+
const [inputs, setInputs] = useState<Record<string, unknown>>({})
|
|
56
65
|
const [result, setResult] = useState<RunResult>()
|
|
57
66
|
const [live, setLive] = useState<Live>({})
|
|
58
67
|
const [running, setRunning] = useState(false)
|
|
@@ -67,11 +76,41 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
|
|
|
67
76
|
const [sheet, setSheet] = useState(false)
|
|
68
77
|
const [error, setError] = useState<string>()
|
|
69
78
|
|
|
79
|
+
const hasGenerated = Object.values(specs).some((s) => s.generate)
|
|
80
|
+
// a flow with inputs can run on their defaults alone, as `specbook test` does
|
|
81
|
+
const hasInputs = Object.keys(specs).length > 0 || seq.nodes.some((n) => n.kind === 'input')
|
|
82
|
+
|
|
83
|
+
/** Every input at its default; a generated one needs faker, which is made ready first. */
|
|
84
|
+
const freshInputs = async () => {
|
|
85
|
+
if (hasGenerated)
|
|
86
|
+
await engine.fakerReady(version.value, locale.value, seed.value).catch(() => {})
|
|
87
|
+
return Object.fromEntries(Object.entries(specs).map(([k, v]) => [k, defaultFor(v)]))
|
|
88
|
+
}
|
|
89
|
+
|
|
70
90
|
useEffect(() => {
|
|
71
|
-
|
|
91
|
+
let current = true
|
|
92
|
+
const start = (v: Record<string, unknown>) => ({ ...v, ...(preset ?? {}) })
|
|
93
|
+
setInputs(start(Object.fromEntries(Object.entries(specs).map(([k, v]) => [k, defaultFor(v)]))))
|
|
94
|
+
// generated values need faker: fill them in as soon as it is ready
|
|
95
|
+
if (hasGenerated)
|
|
96
|
+
void freshInputs().then((v) => {
|
|
97
|
+
if (!current) return
|
|
98
|
+
setInputs(start(v))
|
|
99
|
+
if (autoRun) void run({ inputs: start(v), headless: true })
|
|
100
|
+
})
|
|
101
|
+
else if (autoRun)
|
|
102
|
+
void run({
|
|
103
|
+
inputs: start(
|
|
104
|
+
Object.fromEntries(Object.entries(specs).map(([k, v]) => [k, defaultFor(v)])),
|
|
105
|
+
),
|
|
106
|
+
headless: true,
|
|
107
|
+
})
|
|
72
108
|
setResult(undefined)
|
|
73
109
|
setLive({})
|
|
74
110
|
setPause(undefined)
|
|
111
|
+
return () => {
|
|
112
|
+
current = false
|
|
113
|
+
}
|
|
75
114
|
}, [info.file])
|
|
76
115
|
|
|
77
116
|
const planned = seq.nodes.map((n) => n.name)
|
|
@@ -80,7 +119,20 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
|
|
|
80
119
|
: planned.map((n) => ({ name: n }))
|
|
81
120
|
const current = rows[picked]?.step
|
|
82
121
|
|
|
83
|
-
|
|
122
|
+
/** ends the run that is going: what is out is stopped, and nothing after it starts */
|
|
123
|
+
const stopper = useRef<AbortController | undefined>(undefined)
|
|
124
|
+
const [stopped, setStopped] = useState(false)
|
|
125
|
+
const stop = () => {
|
|
126
|
+
setStopped(true)
|
|
127
|
+
stopper.current?.abort()
|
|
128
|
+
// a run waiting for a choice is released, and then finds it is stopped
|
|
129
|
+
pause?.resolve(choice)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const run = async (options: { inputs?: Record<string, unknown>; headless?: boolean } = {}) => {
|
|
133
|
+
const ctl = new AbortController()
|
|
134
|
+
stopper.current = ctl
|
|
135
|
+
setStopped(false)
|
|
84
136
|
setRunning(true)
|
|
85
137
|
setResult(undefined)
|
|
86
138
|
setError(undefined)
|
|
@@ -91,32 +143,42 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
|
|
|
91
143
|
const r = await engine.run(seq, {
|
|
92
144
|
env: env.value,
|
|
93
145
|
version: version.value,
|
|
94
|
-
|
|
146
|
+
inputs: options.inputs ?? inputs,
|
|
95
147
|
locale: locale.value,
|
|
96
148
|
seed: seed.value,
|
|
149
|
+
signal: ctl.signal,
|
|
97
150
|
onEvent: (e) => {
|
|
98
151
|
if (e.type === 'step:start') setLive((l) => ({ ...l, [e.name]: 'running' }))
|
|
99
152
|
if (e.type === 'step:end') setLive((l) => ({ ...l, [e.name]: e.status }))
|
|
100
153
|
},
|
|
101
|
-
onInput:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
154
|
+
onInput: options.headless
|
|
155
|
+
? undefined
|
|
156
|
+
: ({ name, controls }) =>
|
|
157
|
+
new Promise((resolve) => {
|
|
158
|
+
const c = controls as Record<string, FieldSpec>
|
|
159
|
+
setLive((l) => ({ ...l, [name]: 'paused' }))
|
|
160
|
+
setChoice(Object.fromEntries(Object.entries(c).map(([k, v]) => [k, defaultFor(v)])))
|
|
161
|
+
setPause({
|
|
162
|
+
name,
|
|
163
|
+
controls: c,
|
|
164
|
+
resolve: (values) => {
|
|
165
|
+
setPause(undefined)
|
|
166
|
+
resolve(values)
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
}),
|
|
115
170
|
})
|
|
116
171
|
setResult(r)
|
|
172
|
+
announcement.value = describeRun(r)
|
|
173
|
+
// a run that did not pass shows its first failing step, not its first step
|
|
174
|
+
const bad = r.steps
|
|
175
|
+
.filter((s) => s.kind !== 'auth')
|
|
176
|
+
.findIndex((s) => s.status === 'fail' || s.status === 'error')
|
|
177
|
+
if (bad >= 0) setPicked(bad)
|
|
117
178
|
} catch (e) {
|
|
118
179
|
setError(e instanceof Error ? e.message : String(e))
|
|
119
180
|
} finally {
|
|
181
|
+
stopper.current = undefined
|
|
120
182
|
setRunning(false)
|
|
121
183
|
setPause(undefined)
|
|
122
184
|
}
|
|
@@ -128,17 +190,43 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
|
|
|
128
190
|
<div class={`sequence-panel ${sheet && current ? 'sheet-open' : ''}`} data-seq={seq.meta.id}>
|
|
129
191
|
<div class="seq-left">
|
|
130
192
|
<SecretsBar services={serviceNames} />
|
|
131
|
-
<ControlsGrid title="
|
|
193
|
+
<ControlsGrid title="Inputs" specs={specs} values={inputs} onChange={setInputs} />
|
|
132
194
|
<div class="seq-run">
|
|
133
|
-
<button type="button" class="btn primary" disabled={running} onClick={run}>
|
|
195
|
+
<button type="button" class="btn primary" disabled={running} onClick={() => run()}>
|
|
134
196
|
{running ? 'Running…' : '▶ Run'}
|
|
135
197
|
</button>
|
|
198
|
+
{running && (
|
|
199
|
+
<button type="button" class="btn" onClick={stop}>
|
|
200
|
+
Stop
|
|
201
|
+
</button>
|
|
202
|
+
)}
|
|
203
|
+
{hasInputs && (
|
|
204
|
+
<button
|
|
205
|
+
type="button"
|
|
206
|
+
class="btn"
|
|
207
|
+
disabled={running}
|
|
208
|
+
title="Skip every question and use each input's default, as specbook test does"
|
|
209
|
+
onClick={async () => {
|
|
210
|
+
const fresh = await freshInputs()
|
|
211
|
+
setInputs(fresh)
|
|
212
|
+
await run({ inputs: fresh, headless: true })
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
Run with defaults
|
|
216
|
+
</button>
|
|
217
|
+
)}
|
|
136
218
|
<EnvBadge />
|
|
137
219
|
{result && (
|
|
138
220
|
<span class={`badge ${overall}`}>
|
|
139
221
|
{ICON[overall as 'pass']}{' '}
|
|
140
|
-
{overall === 'pass'
|
|
141
|
-
|
|
222
|
+
{overall === 'pass'
|
|
223
|
+
? 'Passed'
|
|
224
|
+
: stopped
|
|
225
|
+
? 'Stopped'
|
|
226
|
+
: overall === 'fail'
|
|
227
|
+
? 'Failed'
|
|
228
|
+
: 'Error'}{' '}
|
|
229
|
+
· {result.totals.passed}/{result.totals.steps} steps · {result.durationMs}ms
|
|
142
230
|
</span>
|
|
143
231
|
)}
|
|
144
232
|
</div>
|
|
@@ -187,16 +275,20 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
|
|
|
187
275
|
))}
|
|
188
276
|
{step &&
|
|
189
277
|
Object.entries(step.captured).map(([k, v]) => (
|
|
190
|
-
<span class="cap make" title={
|
|
278
|
+
<span class="cap make" title={show(v)}>
|
|
191
279
|
→ {k} <b>{short(v)}</b>
|
|
192
280
|
</span>
|
|
193
281
|
))}
|
|
194
282
|
{step?.error && <div class="error-text">{step.error}</div>}
|
|
195
283
|
{failed.map((a) => (
|
|
196
|
-
<
|
|
284
|
+
<Failure a={a} />
|
|
197
285
|
))}
|
|
198
286
|
{status === 'skipped' && (
|
|
199
|
-
<div class="muted">
|
|
287
|
+
<div class="muted">
|
|
288
|
+
{stopped
|
|
289
|
+
? 'Skipped: you stopped the run.'
|
|
290
|
+
: 'Skipped: an earlier step did not pass.'}
|
|
291
|
+
</div>
|
|
200
292
|
)}
|
|
201
293
|
{pause && live[row.name] === 'paused' && (
|
|
202
294
|
<div class="pause">
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { signal } from '@preact/signals'
|
|
2
|
+
import type { RunResult } from '@specbook/core'
|
|
3
|
+
|
|
4
|
+
/** What a screen reader is told when a run finishes: the panel renders it in a polite live region. */
|
|
5
|
+
export const announcement = signal('')
|
|
6
|
+
|
|
7
|
+
/** One sentence for a finished run: a request by its response, a flow by how many steps passed. */
|
|
8
|
+
export function describeRun(r: RunResult): string {
|
|
9
|
+
if (r.kind === 'request') {
|
|
10
|
+
const s = r.steps.find((x) => x.response) ?? r.steps[0]
|
|
11
|
+
if (!s?.response) return r.status === 'error' ? 'The request could not be sent.' : 'Done.'
|
|
12
|
+
const checks = s.assertions.filter((a) => !a.pass).length
|
|
13
|
+
return `${s.response.status}, ${r.durationMs} milliseconds${checks ? `, ${checks} check${checks === 1 ? '' : 's'} failed` : ''}.`
|
|
14
|
+
}
|
|
15
|
+
const word = r.status === 'pass' ? 'passed' : r.status === 'fail' ? 'failed' : 'could not run'
|
|
16
|
+
return `Flow ${word}, ${r.totals.passed} of ${r.totals.steps} steps passed.`
|
|
17
|
+
}
|
package/src/panel/icons.tsx
CHANGED
|
@@ -83,3 +83,12 @@ export function StarIcon({ size = 14 }: { size?: number }) {
|
|
|
83
83
|
</svg>
|
|
84
84
|
)
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/** A filled square, the usual "stop" mark. */
|
|
88
|
+
export function StopSquare({ size = 16 }: { size?: number }) {
|
|
89
|
+
return (
|
|
90
|
+
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true">
|
|
91
|
+
<rect x="6" y="6" width="12" height="12" rx="2" />
|
|
92
|
+
</svg>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value as text, whatever it is. A run hands the page whatever the project's code and the API produced, and JSON cannot
|
|
3
|
+
* write all of it: `undefined` and functions come back as nothing, a circular object or a BigInt throws. A line that cannot
|
|
4
|
+
* be drawn must not stop the panel, so this never throws and never returns nothing.
|
|
5
|
+
*/
|
|
6
|
+
export function show(v: unknown, space?: number): string {
|
|
7
|
+
try {
|
|
8
|
+
const s = JSON.stringify(v, null, space)
|
|
9
|
+
if (s !== undefined) return s
|
|
10
|
+
} catch {
|
|
11
|
+
/* circular, or something JSON has no form for: name it instead */
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
return String(v)
|
|
15
|
+
} catch {
|
|
16
|
+
return Object.prototype.toString.call(v)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A value for a line of text: its text, cut at 28 characters. */
|
|
21
|
+
export function short(v: unknown): string {
|
|
22
|
+
const s = typeof v === 'string' ? v : show(v)
|
|
23
|
+
return s.length > 28 ? `${s.slice(0, 27)}…` : s
|
|
24
|
+
}
|
package/src/routing.ts
CHANGED
|
@@ -29,15 +29,6 @@ export function routeFor(o: {
|
|
|
29
29
|
return 'direct'
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** A config `baseUrl` may be relative (`/api/v1`): it is resolved against the page. */
|
|
33
|
-
export function absoluteBase(baseUrl: string, pageHref?: string): string {
|
|
34
|
-
try {
|
|
35
|
-
return new URL(baseUrl, pageHref).toString()
|
|
36
|
-
} catch {
|
|
37
|
-
return baseUrl
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
32
|
const names = (req: PreparedRequest): string[] => {
|
|
42
33
|
const out = new Set<string>()
|
|
43
34
|
const scan = (v: unknown) => {
|
package/src/runtime.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as reg from 'virtual:specbook/registry'
|
|
2
|
+
import { createEnvironmentStore } from './custom-environments'
|
|
2
3
|
import { createEngine, pickRunnable } from './engine'
|
|
3
4
|
import { detectExtension, type ExtensionBridge } from './extension'
|
|
4
5
|
import { version } from './store'
|
|
@@ -14,10 +15,27 @@ const extension = async () => {
|
|
|
14
15
|
return bridge
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
/**
|
|
19
|
+
* A page with no project files keeps the environments a person makes in the browser: per spec in the
|
|
20
|
+
* hosted viewer (its registry says where), per page for a built site. An embed has none.
|
|
21
|
+
*/
|
|
22
|
+
const storeAt = (reg as { environmentStore?: { scope: string; session: boolean } | null })
|
|
23
|
+
.environmentStore
|
|
24
|
+
const environmentStore =
|
|
25
|
+
reg.mode === 'static' && storeAt !== null
|
|
26
|
+
? createEnvironmentStore(
|
|
27
|
+
storeAt?.scope ?? `${globalThis.location?.origin}${globalThis.location?.pathname}`,
|
|
28
|
+
storeAt?.session ? 'session' : 'local',
|
|
29
|
+
)
|
|
30
|
+
: undefined
|
|
31
|
+
|
|
32
|
+
export const engine = createEngine(reg as never, { extension, environmentStore })
|
|
18
33
|
|
|
19
34
|
export const isStatic = reg.mode === 'static'
|
|
20
35
|
|
|
36
|
+
/** A build that publishes only the requests and flows the project uses (`specbook build --minimal`). */
|
|
37
|
+
export const isMinimal = !!(reg as { minimal?: boolean }).minimal
|
|
38
|
+
|
|
21
39
|
/** Dev only: writes a project file through the plugin, then busts import caches. */
|
|
22
40
|
export async function saveFile(path: string, content: string): Promise<void> {
|
|
23
41
|
const r = await fetch('/__specbook/save', {
|
package/src/secrets.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* closes) with an in-memory fallback; another backend (an encrypted store, say) can sit behind the same
|
|
7
7
|
* interface. Requests carry `{{secrets.NAME}}` placeholders until the transport sends them.
|
|
8
8
|
*/
|
|
9
|
+
import { kv } from './storage'
|
|
10
|
+
|
|
9
11
|
const KEY = 'specbook.secrets.v1'
|
|
10
12
|
|
|
11
13
|
type Table = Record<string, Record<string, string>>
|
|
@@ -20,7 +22,7 @@ let memory: Table = {}
|
|
|
20
22
|
|
|
21
23
|
const read = (): Table => {
|
|
22
24
|
try {
|
|
23
|
-
const raw =
|
|
25
|
+
const raw = kv.session.getItem(KEY)
|
|
24
26
|
return raw ? (JSON.parse(raw) as Table) : memory
|
|
25
27
|
} catch {
|
|
26
28
|
return memory
|
|
@@ -30,7 +32,7 @@ const read = (): Table => {
|
|
|
30
32
|
const write = (t: Table) => {
|
|
31
33
|
memory = t
|
|
32
34
|
try {
|
|
33
|
-
|
|
35
|
+
kv.session.setItem(KEY, JSON.stringify(t))
|
|
34
36
|
} catch {
|
|
35
37
|
/* private mode or blocked storage: the in-memory copy still serves this page */
|
|
36
38
|
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Where the UI keeps what a person typed. In the app and the viewer it is the browser's storage, kept as before. In an embed
|
|
2
|
+
// it is memory: a docs page shares its origin's `localStorage` with its own code, and a visitor's drafts and credentials
|
|
3
|
+
// must not land there, or be read from there.
|
|
4
|
+
|
|
5
|
+
export type Store = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
|
6
|
+
|
|
7
|
+
function memory(): Store {
|
|
8
|
+
const m = new Map<string, string>()
|
|
9
|
+
return {
|
|
10
|
+
getItem: (k) => m.get(k) ?? null,
|
|
11
|
+
setItem: (k, v) => void m.set(k, String(v)),
|
|
12
|
+
removeItem: (k) => void m.delete(k),
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Reading the global can itself throw (blocked site data), so it is done when asked and a failure falls back to memory. */
|
|
17
|
+
function browser(which: 'localStorage' | 'sessionStorage'): Store {
|
|
18
|
+
try {
|
|
19
|
+
const s = globalThis[which]
|
|
20
|
+
if (s) return s
|
|
21
|
+
} catch {
|
|
22
|
+
/* blocked */
|
|
23
|
+
}
|
|
24
|
+
return memory()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let local: Store | undefined
|
|
28
|
+
let session: Store | undefined
|
|
29
|
+
let isolated = false
|
|
30
|
+
|
|
31
|
+
export const kv = {
|
|
32
|
+
get local(): Store {
|
|
33
|
+
if (!isolated) return browser('localStorage')
|
|
34
|
+
if (!local) local = memory()
|
|
35
|
+
return local
|
|
36
|
+
},
|
|
37
|
+
get session(): Store {
|
|
38
|
+
if (!isolated) return browser('sessionStorage')
|
|
39
|
+
if (!session) session = memory()
|
|
40
|
+
return session
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** From here on nothing is read from or written to the page's own storage. */
|
|
45
|
+
export function useMemoryStorage(): void {
|
|
46
|
+
isolated = true
|
|
47
|
+
local = undefined
|
|
48
|
+
session = undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Back to the browser's storage (for tests). */
|
|
52
|
+
export function useBrowserStorage(): void {
|
|
53
|
+
isolated = false
|
|
54
|
+
local = undefined
|
|
55
|
+
session = undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const storageIsolated = () => isolated
|
package/src/store.ts
CHANGED
|
@@ -10,3 +10,11 @@ export const environmentRisk = signal<Record<string, 'production' | 'sandbox'>>(
|
|
|
10
10
|
export const environmentNote = signal<Record<string, string>>({})
|
|
11
11
|
export const locale = signal<string | undefined>(undefined)
|
|
12
12
|
export const seed = signal<number | undefined>(undefined)
|
|
13
|
+
/** Bumped when an environment's variables change, so what is derived from them (addresses, origins) is redone. */
|
|
14
|
+
export const envVarsVersion = signal(0)
|
|
15
|
+
/** Whether Environment settings is open; set from elsewhere to point a person at it. */
|
|
16
|
+
export const envSettingsOpen = signal(false)
|
|
17
|
+
/** The New environment dialog. */
|
|
18
|
+
export const newEnvironmentOpen = signal(false)
|
|
19
|
+
/** What the shell around the panels offers. An embed has no dialog for creating an environment or for settings. */
|
|
20
|
+
export const features = signal({ createEnvironment: true, environmentSettings: true })
|