@specbook/ui 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.
Files changed (75) hide show
  1. package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
  2. package/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
  3. package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
  4. package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
  5. package/dist-viewer/assets/mount-DfwluJy4.js +2 -0
  6. package/dist-viewer/assets/panel-9eYU7Gz6.js +1 -0
  7. package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
  8. package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
  9. package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
  10. package/dist-viewer/index.html +4 -4
  11. package/dist-viewer/specbook.js +1 -1
  12. package/package.json +7 -5
  13. package/src/app.tsx +71 -13
  14. package/src/branding.ts +12 -4
  15. package/src/components/Catalog.tsx +128 -51
  16. package/src/components/EnvBadge.tsx +24 -4
  17. package/src/components/EnvironmentMenu.tsx +237 -127
  18. package/src/components/Fields.tsx +18 -1
  19. package/src/components/NewEnvironmentDialog.tsx +118 -0
  20. package/src/components/Palette.tsx +7 -2
  21. package/src/custom-environments.ts +63 -0
  22. package/src/embed/panel.tsx +287 -44
  23. package/src/embed/styles.ts +2 -0
  24. package/src/embed/target.ts +82 -0
  25. package/src/embed/theme.ts +75 -0
  26. package/src/embed.ts +44 -15
  27. package/src/engine.ts +416 -32
  28. package/src/environment-list.ts +16 -0
  29. package/src/main.tsx +14 -1
  30. package/src/nav.ts +12 -1
  31. package/src/oauth-callback.ts +87 -0
  32. package/src/oauth.ts +195 -0
  33. package/src/panel/Body.tsx +27 -4
  34. package/src/panel/Checks.tsx +79 -0
  35. package/src/panel/CodeSample.tsx +16 -8
  36. package/src/panel/Combo.tsx +52 -13
  37. package/src/panel/ControlsGrid.tsx +54 -9
  38. package/src/panel/ErrorBoundary.tsx +36 -0
  39. package/src/panel/Host.tsx +14 -2
  40. package/src/panel/OAuthSignIn.tsx +167 -0
  41. package/src/panel/Panel.tsx +50 -13
  42. package/src/panel/ParamGrid.tsx +15 -8
  43. package/src/panel/RequestPanel.tsx +93 -36
  44. package/src/panel/RequestView.tsx +2 -1
  45. package/src/panel/ResponseView.tsx +25 -14
  46. package/src/panel/SecretsBar.tsx +43 -9
  47. package/src/panel/SequenceDocs.tsx +7 -6
  48. package/src/panel/SequencePanel.tsx +124 -32
  49. package/src/panel/announce.ts +17 -0
  50. package/src/panel/icons.tsx +9 -0
  51. package/src/panel/short.ts +24 -0
  52. package/src/routing.ts +0 -9
  53. package/src/runtime.ts +19 -1
  54. package/src/secrets.ts +4 -2
  55. package/src/storage.ts +58 -0
  56. package/src/store.ts +10 -0
  57. package/src/styles/app.css +69 -6
  58. package/src/styles/embed.css +116 -0
  59. package/src/styles/panel.css +277 -2
  60. package/src/viewer/embed.ts +5 -0
  61. package/src/viewer/main.tsx +5 -1
  62. package/src/viewer/manifests.ts +2 -0
  63. package/src/viewer/protocol.ts +9 -0
  64. package/src/viewer/registry-data.ts +4 -0
  65. package/src/viewer/registry.ts +48 -3
  66. package/src/viewer/worker.ts +20 -7
  67. package/src/virtual.d.ts +8 -0
  68. package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
  69. package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
  70. package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
  71. package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
  72. package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
  73. package/dist-viewer/assets/panel-Coi9H1nv.css +0 -1
  74. package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
  75. package/dist-viewer/assets/worker-Dw3Ps3hk.js +0 -181
@@ -2,6 +2,7 @@ import { signal } from '@preact/signals'
2
2
  import { useEffect, useRef } from 'preact/hooks'
3
3
  import type { FieldDoc } from '../fielddoc'
4
4
  import { inlineSummary, plainText } from '../markdown'
5
+ import { kv } from '../storage'
5
6
  import { Combo } from './Combo'
6
7
  import type { FieldRow } from './draft'
7
8
 
@@ -28,6 +29,8 @@ interface Props {
28
29
  placeholder?: string
29
30
  /** the grid sits under a tab that already names it */
30
31
  bare?: boolean
32
+ /** values are shown, not edited (a saved request carries its own) */
33
+ readOnly?: boolean
31
34
  }
32
35
 
33
36
  type Shown = FieldRow & { virtual?: true }
@@ -41,7 +44,7 @@ const cols = signal(loadCols())
41
44
 
42
45
  function loadCols(): { key: number; val: number } {
43
46
  try {
44
- const c = JSON.parse(localStorage.getItem(COLS_KEY) ?? 'null')
47
+ const c = JSON.parse(kv.local.getItem(COLS_KEY) ?? 'null')
45
48
  if (c && c.key >= MIN_COL && c.val >= MIN_COL && c.key + c.val <= 100 - MIN_COL) return c
46
49
  } catch {
47
50
  /* fall through */
@@ -55,8 +58,8 @@ function setCols(next: { key: number; val: number } | undefined) {
55
58
  const val = Math.min(100 - key - MIN_COL, Math.max(MIN_COL, c.val))
56
59
  cols.value = { key: Math.round(key * 10) / 10, val: Math.round(val * 10) / 10 }
57
60
  try {
58
- if (next) localStorage.setItem(COLS_KEY, JSON.stringify(cols.value))
59
- else localStorage.removeItem(COLS_KEY)
61
+ if (next) kv.local.setItem(COLS_KEY, JSON.stringify(cols.value))
62
+ else kv.local.removeItem(COLS_KEY)
60
63
  } catch {
61
64
  /* optional */
62
65
  }
@@ -167,6 +170,7 @@ export function ParamGrid({
167
170
  bad,
168
171
  placeholder,
169
172
  bare,
173
+ readOnly,
170
174
  }: Props) {
171
175
  const root = useRef<HTMLElement>(null)
172
176
  const table = useRef<HTMLTableElement>(null)
@@ -252,7 +256,7 @@ export function ParamGrid({
252
256
  {all.map((r, i) => {
253
257
  const d = docOf(r.key)
254
258
  const isFile = info(r.key)?.file
255
- const locked = fixed || r.virtual || info(r.key)
259
+ const locked = fixed || readOnly || r.virtual || info(r.key)
256
260
  return (
257
261
  <tr class={`${r.enabled ? '' : 'off'} ${bad === r.key ? 'bad' : ''}`} key={i}>
258
262
  <td class="g-check">
@@ -260,7 +264,7 @@ export function ParamGrid({
260
264
  type="checkbox"
261
265
  aria-label={`Send ${r.key}`}
262
266
  checked={r.enabled}
263
- disabled={fixed}
267
+ disabled={fixed || readOnly}
264
268
  onChange={(e) => patch(i, { enabled: (e.target as HTMLInputElement).checked })}
265
269
  />
266
270
  </td>
@@ -292,6 +296,7 @@ export function ParamGrid({
292
296
  <label class="file-cell">
293
297
  <input
294
298
  type="file"
299
+ disabled={readOnly}
295
300
  aria-label={`File for ${r.key}`}
296
301
  onChange={(e) => {
297
302
  const file = (e.target as HTMLInputElement).files?.[0]
@@ -306,6 +311,7 @@ export function ParamGrid({
306
311
  options={d.enum.map(String)}
307
312
  label={r.key}
308
313
  cell={`${i}:${VALUE}`}
314
+ readOnly={readOnly}
309
315
  placeholder="value"
310
316
  onKeyDown={keys(i, VALUE)}
311
317
  onChange={(value) => patch(i, { value, enabled: true })}
@@ -315,6 +321,7 @@ export function ParamGrid({
315
321
  class="cell"
316
322
  data-cell={`${i}:${VALUE}`}
317
323
  value={r.value}
324
+ readOnly={readOnly}
318
325
  placeholder={placeholder ?? 'value'}
319
326
  aria-label={`Value of ${r.key}`}
320
327
  onInput={(e) =>
@@ -336,7 +343,7 @@ export function ParamGrid({
336
343
  )}
337
344
  </td>
338
345
  <td class="g-act">
339
- {!fixed && r.key && (
346
+ {!fixed && !readOnly && r.key && (
340
347
  <button
341
348
  type="button"
342
349
  class="icon"
@@ -347,7 +354,7 @@ export function ParamGrid({
347
354
  <Icon d="M5.5 5.5h7v7h-7zM3.5 10.5v-7h7" />
348
355
  </button>
349
356
  )}
350
- {!fixed && !r.virtual && (
357
+ {!fixed && !readOnly && !r.virtual && (
351
358
  <button
352
359
  type="button"
353
360
  class="icon"
@@ -362,7 +369,7 @@ export function ParamGrid({
362
369
  </tr>
363
370
  )
364
371
  })}
365
- {!fixed && (
372
+ {!fixed && !readOnly && (
366
373
  <tr class="ghost" key={ghost}>
367
374
  <td class="g-check" />
368
375
  <td class="g-key">
@@ -1,17 +1,19 @@
1
1
  import { collectPlaceholders, type Encoding, type Example, type RunResult } 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 type { Variant } from '../engine'
5
5
  import { fieldDoc, withNote } from '../fielddoc'
6
6
  import { engine, isStatic, registry, saveFile } from '../runtime'
7
7
  import { type Defs, type JsonSchema, propsOf, unwrap } from '../schema'
8
8
  import { statusClass, statusName } from '../status'
9
- import { env, locale, seed, version } from '../store'
9
+ import { kv } from '../storage'
10
+ import { env, envSettingsOpen, envVarsVersion, features, locale, seed, version } from '../store'
11
+ import { announcement, describeRun } from './announce'
10
12
  import { Body } from './Body'
11
13
  import { DraftCodeSample } from './CodeSample'
12
14
  import { type Draft, draftFrom, forStorage, previewUrl, requestParts } from './draft'
13
15
  import { useOperationNotes } from './fieldnotes'
14
- import { PaperPlane } from './icons'
16
+ import { PaperPlane, StopSquare } from './icons'
15
17
  import { type Documented, ParamGrid } from './ParamGrid'
16
18
  import { RequestView } from './RequestView'
17
19
  import { ResponseView } from './ResponseView'
@@ -33,6 +35,8 @@ interface Props {
33
35
  variants: Variant[]
34
36
  /** example name to open, '' for the first */
35
37
  initial: string
38
+ /** run once as soon as the request is ready (an embed that asks for it, never against production) */
39
+ autoRun?: boolean
36
40
  }
37
41
 
38
42
  const storeKey = (key: string, sel: string) => `specbook:draft:${key}:${sel}`
@@ -44,7 +48,7 @@ const slug = (s: string) =>
44
48
 
45
49
  function readDraft(key: string): Draft | undefined {
46
50
  try {
47
- const raw = localStorage.getItem(key)
51
+ const raw = kv.local.getItem(key)
48
52
  return raw ? (JSON.parse(raw) as Draft) : undefined
49
53
  } catch {
50
54
  return undefined
@@ -64,21 +68,28 @@ interface SavedExample {
64
68
 
65
69
  function takeSaved(key: string): SavedExample | undefined {
66
70
  try {
67
- const raw = sessionStorage.getItem(SAVED_KEY)
71
+ const raw = kv.session.getItem(SAVED_KEY)
68
72
  const saved = raw ? (JSON.parse(raw) as SavedExample) : undefined
69
73
  if (saved?.key !== key) return undefined
70
- sessionStorage.removeItem(SAVED_KEY)
74
+ kv.session.removeItem(SAVED_KEY)
71
75
  return saved
72
76
  } catch {
73
77
  return undefined
74
78
  }
75
79
  }
76
80
 
77
- export function RequestPanel({ service, op, variants, initial }: Props) {
81
+ export function RequestPanel({ service, op, variants, initial, autoRun }: Props) {
78
82
  const key = `${service}/${op.id}`
83
+ const panelRoot = useRef<HTMLDivElement>(null)
84
+ const autoRan = useRef(false)
85
+ /** ends the request that is out: a person pressing Stop, so a request that never answers is not the end of the panel */
86
+ const stopper = useRef<AbortController | undefined>(undefined)
79
87
  const examples = engine.examplesOf(key)
80
88
  const [notice, setNotice] = useState<SavedExample | undefined>(() => takeSaved(key))
81
89
  const [sel, setSel] = useState(() => {
90
+ // a link to a saved request ("saved:<file>") opens on it
91
+ if (initial?.startsWith('saved:') && variants.some((v) => `saved:${v.file}` === initial))
92
+ return initial
82
93
  const named = examples.find((e) => e.name === (notice?.name ?? initial))
83
94
  return named
84
95
  ? `ex:${named.name}`
@@ -110,11 +121,27 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
110
121
  const [baseUrl, setBaseUrl] = useState('')
111
122
  const [known, setKnown] = useState<string[]>()
112
123
 
124
+ const [addressError, setAddressError] = useState<string>()
113
125
  useEffect(() => {
126
+ let on = true
127
+ setAddressError(undefined)
128
+ // mock has no address; elsewhere it is whatever this environment's variables make it
129
+ if (env.value === 'mock') {
130
+ setBaseUrl('')
131
+ return
132
+ }
114
133
  engine
115
- .config(version.value)
116
- .then((c) => setBaseUrl(c.services?.[service]?.baseUrl?.[env.value] ?? ''))
117
- }, [service, env.value])
134
+ .baseUrlOf(service, env.value, version.value)
135
+ .then((url) => on && setBaseUrl(url ?? ''))
136
+ .catch((e: unknown) => {
137
+ if (!on) return
138
+ setBaseUrl('')
139
+ setAddressError(e instanceof Error ? e.message : String(e))
140
+ })
141
+ return () => {
142
+ on = false
143
+ }
144
+ }, [service, env.value, version.value, envVarsVersion.value])
118
145
 
119
146
  useEffect(() => {
120
147
  let live = true
@@ -174,8 +201,8 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
174
201
  try {
175
202
  const k = storeKey(key, sel)
176
203
  if (baseDraft && JSON.stringify(forStorage(d)) === JSON.stringify(forStorage(baseDraft)))
177
- localStorage.removeItem(k)
178
- else localStorage.setItem(k, JSON.stringify(forStorage(d)))
204
+ kv.local.removeItem(k)
205
+ else kv.local.setItem(k, JSON.stringify(forStorage(d)))
179
206
  } catch {
180
207
  /* optional */
181
208
  }
@@ -217,35 +244,49 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
217
244
 
218
245
  // placeholders nothing supplies: not in config or the environment's files. They stay listed while
219
246
  // you type, so the field you are filling does not disappear under you.
220
- const needed = known ? placeholders.filter((n) => !known.includes(n)) : []
247
+ // a saved request brings the values of its own `init`: they are what a run uses, so nothing is missing
248
+ const own = Object.keys(saved?.def.input.init ?? {})
249
+ const needed = known ? placeholders.filter((n) => !known.includes(n) && !own.includes(n)) : []
221
250
  const unfilled = needed.filter((n) => (vars[n] ?? '') === '')
222
251
 
223
252
  const play = async (forceStatus?: number) => {
224
- if (!draft) return
253
+ if (!draft || stopper.current) return
225
254
  setError(undefined)
226
255
  setRunning(true)
256
+ const stop = new AbortController()
257
+ stopper.current = stop
227
258
  try {
228
259
  const def = saved
229
260
  ? saved.def
230
261
  : await engine.operationRequest(key, version.value, requestParts(draft, encoding))
231
- setResult(
232
- await engine.run(def, {
233
- env: env.value,
234
- version: version.value,
235
- mockStatus: env.value === 'mock' ? (forceStatus ?? forced) : undefined,
236
- locale: locale.value,
237
- seed: seed.value,
238
- vars: Object.fromEntries(Object.entries(vars).filter(([, v]) => v !== '')),
239
- fileDir: base?.dir,
240
- }),
241
- )
262
+ const ran = await engine.run(def, {
263
+ env: env.value,
264
+ version: version.value,
265
+ mockStatus: env.value === 'mock' ? (forceStatus ?? forced) : undefined,
266
+ locale: locale.value,
267
+ seed: seed.value,
268
+ vars: Object.fromEntries(Object.entries(vars).filter(([, v]) => v !== '')),
269
+ fileDir: base?.dir,
270
+ signal: stop.signal,
271
+ })
272
+ setResult(ran)
273
+ announcement.value = describeRun(ran)
242
274
  } catch (e) {
243
275
  setError(e instanceof Error ? e.message : String(e))
244
276
  } finally {
277
+ stopper.current = undefined
245
278
  setRunning(false)
279
+ // what you sent a request to see is the response, so show it (the tab stays one click away)
280
+ setTab('response')
246
281
  }
247
282
  }
248
283
 
284
+ useEffect(() => {
285
+ if (!autoRun || !draft || autoRan.current) return
286
+ autoRan.current = true
287
+ void play()
288
+ }, [draft])
289
+
249
290
  const saveExample = async (name: string) => {
250
291
  if (!draft) return
251
292
  const parts = requestParts(draft, encoding)
@@ -263,7 +304,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
263
304
  // set before writing: the watcher reloads the page as soon as the file lands
264
305
  const saved: SavedExample = { key, path, name: file }
265
306
  try {
266
- sessionStorage.setItem(SAVED_KEY, JSON.stringify(saved))
307
+ kv.session.setItem(SAVED_KEY, JSON.stringify(saved))
267
308
  } catch {
268
309
  /* the confirmation below still shows when there is no reload */
269
310
  }
@@ -299,12 +340,14 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
299
340
  play()
300
341
  }
301
342
  }
302
- addEventListener('keydown', send)
303
- return () => removeEventListener('keydown', send)
343
+ // on the page's own root (a shadow root in an embed), not the window, so a host page's keys are left alone
344
+ const root: EventTarget = panelRoot.current?.getRootNode() ?? window
345
+ root.addEventListener('keydown', send as EventListener)
346
+ return () => root.removeEventListener('keydown', send as EventListener)
304
347
  })
305
348
 
306
349
  return (
307
- <div class="request-panel" data-op={key}>
350
+ <div class="request-panel" data-op={key} ref={panelRoot}>
308
351
  <div class="rq">
309
352
  <div class="rq-bar">
310
353
  <span class={`method ${op.method}`}>{op.method}</span>
@@ -330,15 +373,26 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
330
373
  <button
331
374
  type="button"
332
375
  class="btn primary send"
333
- title="Send (⌘/Ctrl + Enter)"
334
- aria-label="Send"
335
- disabled={running || !draft}
336
- onClick={() => play()}
376
+ title={running ? 'Stop' : 'Send (⌘/Ctrl + Enter)'}
377
+ aria-label={running ? 'Stop' : 'Send'}
378
+ disabled={!running && !draft}
379
+ onClick={() => (running ? stopper.current?.abort() : play())}
337
380
  >
338
- {running ? '…' : <PaperPlane />}
381
+ {running ? <StopSquare /> : <PaperPlane />}
339
382
  </button>
340
383
  </div>
341
384
 
385
+ {addressError && (
386
+ <p class="envmenu-warn" role="alert">
387
+ {addressError}.{' '}
388
+ {features.value.environmentSettings && (
389
+ <button type="button" class="link" onClick={() => (envSettingsOpen.value = true)}>
390
+ Open Environment settings
391
+ </button>
392
+ )}
393
+ </p>
394
+ )}
395
+
342
396
  {requestNote && (
343
397
  <details class="step-about" key={saved?.file}>
344
398
  <summary>About this request</summary>
@@ -466,8 +520,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
466
520
 
467
521
  {saved && (
468
522
  <p class="muted lock">
469
- A saved request: its own values, checks and mock responses apply, and it is read-only
470
- here.
523
+ A saved request: its own values, checks and mock hook apply, and it is read-only here.
471
524
  </p>
472
525
  )}
473
526
 
@@ -538,6 +591,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
538
591
  title="Query"
539
592
  rows={draft.query}
540
593
  documented={sections.query}
594
+ readOnly={!!saved}
541
595
  onChange={(query) => edit({ ...draft, query })}
542
596
  />
543
597
  )}
@@ -547,6 +601,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
547
601
  rows={draft.path}
548
602
  documented={sections.path}
549
603
  fixed
604
+ readOnly={!!saved}
550
605
  onChange={(path) => edit({ ...draft, path })}
551
606
  />
552
607
  )}
@@ -557,6 +612,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
557
612
  title="Headers"
558
613
  rows={draft.headers}
559
614
  documented={sections.headers}
615
+ readOnly={!!saved}
560
616
  onChange={(headers) => edit({ ...draft, headers })}
561
617
  />
562
618
  )}
@@ -571,6 +627,7 @@ export function RequestPanel({ service, op, variants, initial }: Props) {
571
627
  defs={defs}
572
628
  files={meta?.files}
573
629
  notes={opNotes.notes}
630
+ readOnly={!!saved}
574
631
  />
575
632
  )}
576
633
  </div>
@@ -4,8 +4,9 @@ import { fieldDoc, withNote } from '../fielddoc'
4
4
  import { type Defs, type JsonSchema, linePaths, pathString } from '../schema'
5
5
  import { BodyEditor } from './BodyEditor'
6
6
  import { FieldDoc } from './FieldDoc'
7
+ import { show } from './short'
7
8
 
8
- const pretty = (v: unknown) => (typeof v === 'string' ? v : JSON.stringify(v, null, 2))
9
+ const pretty = (v: unknown) => (typeof v === 'string' ? v : show(v, 2))
9
10
 
10
11
  /** Repeated keys for arrays, as the transport sends them. */
11
12
  export function queryString(query: Record<string, string | string[]>) {
@@ -1,12 +1,14 @@
1
1
  import type { StepResult } from '@specbook/core'
2
- import { useMemo, useState } from 'preact/hooks'
2
+ import { useEffect, useMemo, useState } from 'preact/hooks'
3
3
  import { fieldDoc } from '../fielddoc'
4
4
  import { type Defs, type JsonSchema, linePaths, validate } from '../schema'
5
5
  import { statusClass, statusName } from '../status'
6
6
  import { BodyEditor } from './BodyEditor'
7
+ import { Checks } from './Checks'
7
8
  import { FieldDoc } from './FieldDoc'
8
9
  import { PaperPlane } from './icons'
9
10
  import { queryString } from './RequestView'
11
+ import { show } from './short'
10
12
 
11
13
  interface Props {
12
14
  step?: StepResult
@@ -17,7 +19,7 @@ interface Props {
17
19
  hideRequest?: boolean
18
20
  }
19
21
 
20
- const pretty = (v: unknown) => JSON.stringify(v, null, 2)
22
+ const pretty = (v: unknown) => show(v, 2)
21
23
  const size = (n: number) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`)
22
24
 
23
25
  export function ResponseView({ step, error, schemas, defs, hideRequest }: Props) {
@@ -25,10 +27,14 @@ export function ResponseView({ step, error, schemas, defs, hideRequest }: Props)
25
27
  const [line, setLine] = useState(0)
26
28
  const response = step?.response
27
29
  const schema = response ? (schemas?.[String(response.status)] ?? schemas?.default) : undefined
28
- const issues = useMemo(
29
- () => (response && schema ? validate(response.body, schema, defs, { reportExtra: true }) : []),
30
- [response, schema, defs],
31
- )
30
+ const issues = useMemo(() => {
31
+ try {
32
+ return response && schema ? validate(response.body, schema, defs, { reportExtra: true }) : []
33
+ } catch {
34
+ // an answer the schema check cannot make sense of is shown as it came, without the check
35
+ return []
36
+ }
37
+ }, [response, schema, defs])
32
38
  const text =
33
39
  response === undefined || response.body === undefined || response.body === ''
34
40
  ? ''
@@ -42,6 +48,10 @@ export function ResponseView({ step, error, schemas, defs, hideRequest }: Props)
42
48
  }, [paths, line, schema, defs])
43
49
  const checks = step?.assertions ?? []
44
50
  const failed = checks.filter((a) => !a.pass).length
51
+ // a run that failed a check opens on it: that is what the person came to see
52
+ useEffect(() => {
53
+ if (step && failed > 0) setTab('checks')
54
+ }, [step])
45
55
  const headers = Object.entries(response?.headers ?? {})
46
56
  const empty = !response && !error && !step?.error
47
57
 
@@ -112,7 +122,14 @@ export function ResponseView({ step, error, schemas, defs, hideRequest }: Props)
112
122
  >
113
123
  {label}
114
124
  {n > 0 && (
115
- <span class={`count ${id === 'checks' && failed ? 'bad' : ''}`}>{n}</span>
125
+ <span
126
+ class={`count ${id === 'checks' && failed ? 'bad' : ''}`}
127
+ title={
128
+ id === 'checks' && failed ? `${failed} of ${n} checks failed` : undefined
129
+ }
130
+ >
131
+ {id === 'checks' && failed ? failed : n}
132
+ </span>
116
133
  )}
117
134
  </button>
118
135
  ))}
@@ -165,13 +182,7 @@ export function ResponseView({ step, error, schemas, defs, hideRequest }: Props)
165
182
  {checks.length === 0 ? (
166
183
  <p class="muted">This request has no checks.</p>
167
184
  ) : (
168
- <ul class="checks">
169
- {checks.map((a) => (
170
- <li class={a.pass ? 'ok' : 'bad'}>
171
- {a.pass ? '✓' : '✗'} {a.label}
172
- </li>
173
- ))}
174
- </ul>
185
+ <Checks assertions={checks} />
175
186
  )}
176
187
  </div>
177
188
  </div>
@@ -1,16 +1,24 @@
1
+ import { absoluteBase, type OAuthSettings } from '@specbook/core'
1
2
  import { useEffect, useState } from 'preact/hooks'
2
- import { absoluteBase } from '../routing'
3
+ import { expiryOf, noteExpiry } from '../oauth'
3
4
  import { engine } from '../runtime'
4
5
  import { originOf, SECRET_PLACEHOLDER, secretStore } from '../secrets'
5
- import { env, version } from '../store'
6
+ import { env, envVarsVersion, version } from '../store'
7
+ import { OAuthSignIn } from './OAuthSignIn'
6
8
 
7
9
  interface Need {
8
10
  service: string
9
11
  origin: string
10
12
  title: string
11
13
  names: string[]
14
+ /** the config's name for the profile (the key a sign-in's token exchange is addressed by) */
15
+ profile: string
16
+ oauth?: OAuthSettings
12
17
  }
13
18
 
19
+ const minutesLeft = (at: number | undefined) =>
20
+ at === undefined ? undefined : Math.max(0, Math.round((at - Date.now()) / 60_000))
21
+
14
22
  const SOURCE: Record<string, string> = {
15
23
  session: 'this session',
16
24
  process: 'environment variable',
@@ -51,15 +59,17 @@ export function SecretsBar({
51
59
  }
52
60
  let on = true
53
61
  if (!supplied) engine.managedSecrets(env.value).then((m) => on && setManaged(m))
54
- engine.config(version.value).then((cfg) => {
55
- if (!on) return
62
+ engine.config(version.value).then(async (cfg) => {
56
63
  const out: Need[] = []
64
+ const where = await engine.addressesIn(cfg, env.value)
57
65
  for (const [service, s] of Object.entries(cfg.services ?? {})) {
58
66
  if (services && !services.includes(service)) continue
59
67
  const profile = s.auth ? cfg.auth?.[s.auth] : undefined
60
- const base = s.baseUrl?.[env.value]
68
+ // the address this environment's variables give; one that cannot be worked out has no origin
69
+ const base = where.baseUrl(service)
61
70
  const origin = originOf(base ? absoluteBase(base, globalThis.location?.href) : undefined)
62
71
  if (!profile || !origin) continue
72
+ const oauth = s.auth ? where.oauth(s.auth) : undefined
63
73
  const names = [
64
74
  ...new Set(
65
75
  [...JSON.stringify(profile.apply).matchAll(SECRET_PLACEHOLDER)].map(
@@ -67,14 +77,22 @@ export function SecretsBar({
67
77
  ),
68
78
  ),
69
79
  ]
70
- if (names.length) out.push({ service, origin, title: profile.title ?? s.auth ?? '', names })
80
+ if (names.length)
81
+ out.push({
82
+ service,
83
+ origin,
84
+ title: profile.title ?? s.auth ?? '',
85
+ names,
86
+ profile: s.auth as string,
87
+ oauth,
88
+ })
71
89
  }
72
- setNeeds(out)
90
+ if (on) setNeeds(out)
73
91
  })
74
92
  return () => {
75
93
  on = false
76
94
  }
77
- }, [live, env.value, wanted, version.value])
95
+ }, [live, env.value, wanted, version.value, envVarsVersion.value])
78
96
 
79
97
  if (!live || needs.length === 0) return null
80
98
  const have = (n: Need, name: string) =>
@@ -109,12 +127,18 @@ export function SecretsBar({
109
127
  <code>{name}</code>
110
128
  {has ? (
111
129
  <>
112
- <span class="muted">✓ set</span>
130
+ <span class="muted">
131
+ ✓ set
132
+ {n.oauth && minutesLeft(expiryOf(n.origin, name)) !== undefined
133
+ ? ` · expires in ${minutesLeft(expiryOf(n.origin, name))} min`
134
+ : ''}
135
+ </span>
113
136
  <button
114
137
  type="button"
115
138
  class="link"
116
139
  onClick={() => {
117
140
  secretStore.remove(n.origin, name)
141
+ noteExpiry(n.origin, name, undefined)
118
142
  bump((x) => x + 1)
119
143
  }}
120
144
  >
@@ -124,6 +148,16 @@ export function SecretsBar({
124
148
  ) : (
125
149
  <>
126
150
  {from && <span class="muted">from {SOURCE[from] ?? from}</span>}
151
+ {n.oauth && (
152
+ <OAuthSignIn
153
+ service={n.service}
154
+ profile={n.profile}
155
+ origin={n.origin}
156
+ secret={name}
157
+ oauth={n.oauth}
158
+ onSignedIn={() => bump((x) => x + 1)}
159
+ />
160
+ )}
127
161
  <input
128
162
  type="password"
129
163
  autocomplete="off"
@@ -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 show = (v: unknown) => (typeof v === 'string' ? v : JSON.stringify(v))
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 params = useMemo(
25
+ const inputs = useMemo(
25
26
  () =>
26
- Object.entries((seq.meta.params ?? {}) as Record<string, unknown>).map(([name, c]) => {
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
- {params.length > 0 && (
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
- {params.map((p) => (
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>{show(p.initial)}</code>
68
+ <code>{showValue(p.initial)}</code>
68
69
  </td>
69
70
  <td>{p.spec.description}</td>
70
71
  </tr>