@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
@@ -0,0 +1,16 @@
1
+ import { engine } from './runtime'
2
+ import { environmentNote, environmentRisk, environments, envVarsVersion, version } from './store'
3
+
4
+ /** Reads the environment list, risks and notes again (after one is made or removed) and tells dependents. */
5
+ export async function refreshEnvironments(): Promise<void> {
6
+ const v = version.value
7
+ const [names, risks, notes] = await Promise.all([
8
+ engine.environments(v),
9
+ engine.environmentRisks(v),
10
+ engine.environmentNotes(v),
11
+ ])
12
+ environments.value = names
13
+ environmentRisk.value = risks
14
+ environmentNote.value = notes
15
+ envVarsVersion.value += 1
16
+ }
package/src/main.tsx CHANGED
@@ -1,5 +1,7 @@
1
+ import { loadManifests } from 'virtual:specbook/registry'
1
2
  import { render } from 'preact'
2
3
  import { App } from './app'
4
+ import { handleOAuthCallback } from './oauth-callback'
3
5
  import { applyTheme } from './theme'
4
6
  import 'prism-code-editor/layout.css'
5
7
  import 'prism-code-editor/autocomplete.css'
@@ -9,7 +11,18 @@ import './styles/app.css'
9
11
  import './styles/panel.css'
10
12
 
11
13
  applyTheme()
12
- render(<App />, document.getElementById('app') as HTMLElement)
14
+ // A sign-in's return leg is handed to the tab that started it; this page then has nothing to draw. Under the
15
+ // viewer shell or an embed (which import this) that was settled before they got here, and running it again
16
+ // would strip a host page's own `?code=&state=` that the embed deliberately left alone.
17
+ const underShell =
18
+ (globalThis as { __SPECBOOK_VIEWER__?: unknown }).__SPECBOOK_VIEWER__ !== undefined
19
+ void (underShell ? Promise.resolve(false) : handleOAuthCallback()).then(async (handled) => {
20
+ if (handled) return
21
+ // a built project starts with its services' lists not loaded: fetch them, then draw the app. The app's own code
22
+ // is part of this page's static imports, so it loads alongside, not after (an embed fetches only the lists it needs)
23
+ await loadManifests()
24
+ render(<App />, document.getElementById('app') as HTMLElement)
25
+ })
13
26
 
14
27
  if (import.meta.hot) {
15
28
  import.meta.hot.on('specbook:registry-changed', () => location.reload())
package/src/nav.ts CHANGED
@@ -70,7 +70,15 @@ export const paletteOpen = signal(false)
70
70
  export const catalogService = signal('')
71
71
  export const catalogFilter = signal('')
72
72
 
73
+ let navigator: ((r: Route) => void) | undefined
74
+
75
+ /** A host that is not the app (an embed) takes links itself instead of writing the page's address. */
76
+ export const setNavigator = (fn: ((r: Route) => void) | undefined) => {
77
+ navigator = fn
78
+ }
79
+
73
80
  export function go(r: Route) {
81
+ if (navigator) return navigator(r)
74
82
  location.hash = hashFor(r)
75
83
  route.value = r
76
84
  }
@@ -88,13 +96,15 @@ export interface Entry {
88
96
  /** the spec requires credentials */
89
97
  secured?: boolean
90
98
  /** sequences */
99
+ /** flows and requests */
91
100
  tags?: string[]
92
101
  /** the flow group heading */
93
102
  flowGroup?: string
94
103
  description?: string
95
104
  /** extra text matched by search, never shown */
96
105
  hay: string
97
- kind: 'seq' | 'op'
106
+ /** `req`: a saved request, which opens its operation on that request */
107
+ kind: 'seq' | 'op' | 'req'
98
108
  }
99
109
 
100
110
  export function search(entries: Entry[], query: string, limit = 40): Entry[] {
@@ -109,6 +119,7 @@ export function search(entries: Entry[], query: string, limit = 40): Entry[] {
109
119
  let score = 0
110
120
  for (const t of terms) score += title.startsWith(t) ? 3 : title.includes(t) ? 2 : 1
111
121
  if (e.kind === 'seq') score += 0.5
122
+ if (e.kind === 'req') score += 0.4
112
123
  scored.push([score, e])
113
124
  }
114
125
  return scored
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The return leg of a sign-in. The identity provider redirects the popup to a page with `?code=&state=`
3
+ * (or `?error=`). Run first on boot: when the tab that started the sign-in is waiting for this `state`
4
+ * it takes the result and this popup closes without drawing the app.
5
+ *
6
+ * Two ways to reach the waiting tab, because each survives what the other does not:
7
+ * - a same-origin `BroadcastChannel`: providers that send `Cross-Origin-Opener-Policy` sever
8
+ * `window.opener`, but a channel still connects the two pages;
9
+ * - `window.opener.postMessage`: a viewer inside an iframe on another site has partitioned storage, so its
10
+ * channel never hears a top-level popup, but its window is still the popup's opener.
11
+ *
12
+ * A stray visit to a URL that merely looks like a callback is told nobody is waiting and carries on as a
13
+ * normal page load. The hosted site's own callback page (`landing/oauth/callback/callback.js`) does the same
14
+ * in plain JS; keep the two in step.
15
+ */
16
+ export const OAUTH_CHANNEL = 'specbook-oauth'
17
+
18
+ export type OAuthMessage =
19
+ | { type: 'callback'; state: string; code?: string; error?: string; description?: string }
20
+ | { type: 'ack'; state: string }
21
+
22
+ /** How long a callback page waits to be told that someone is waiting for it. */
23
+ const ACK_WAIT_MS = 1500
24
+
25
+ export interface CallbackOptions {
26
+ /**
27
+ * Remove `?code=&state=` from the address even when no tab claims it (default). An embedded viewer sets
28
+ * this to false: the page belongs to someone else, and its query may be their own application's sign-in.
29
+ */
30
+ stripWhenUnclaimed?: boolean
31
+ }
32
+
33
+ /** true when this page load was a sign-in's return and has been handed over: do not boot the app. */
34
+ export function handleOAuthCallback(options: CallbackOptions = {}): Promise<boolean> {
35
+ const strip = options.stripWhenUnclaimed ?? true
36
+ const here = globalThis.location
37
+ if (!here || (typeof BroadcastChannel === 'undefined' && !globalThis.opener))
38
+ return Promise.resolve(false)
39
+ const q = new URLSearchParams(here.search)
40
+ const state = q.get('state')
41
+ if (!state || !(q.has('code') || q.has('error'))) return Promise.resolve(false)
42
+
43
+ const message: OAuthMessage = {
44
+ type: 'callback',
45
+ state,
46
+ ...(q.get('code') ? { code: q.get('code') as string } : {}),
47
+ ...(q.get('error') ? { error: q.get('error') as string } : {}),
48
+ ...(q.get('error_description') ? { description: q.get('error_description') as string } : {}),
49
+ }
50
+ // the code and state are single-use and do not belong in history or a copied address
51
+ const clean = () => history.replaceState(null, '', here.pathname + here.hash)
52
+ if (strip) clean()
53
+
54
+ return new Promise((resolve) => {
55
+ const channel =
56
+ typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(OAUTH_CHANNEL)
57
+ let finished = false
58
+ const finish = (handled: boolean) => {
59
+ if (finished) return
60
+ finished = true
61
+ clearTimeout(timer)
62
+ channel?.close()
63
+ removeEventListener('message', onMessage)
64
+ resolve(handled)
65
+ }
66
+ const claimed = () => {
67
+ if (finished) return
68
+ clean()
69
+ finish(true)
70
+ document.body.textContent = 'Signed in. You can close this window.'
71
+ window.close()
72
+ }
73
+ const isAck = (d: OAuthMessage | undefined) => d?.type === 'ack' && d.state === state
74
+ const onMessage = (e: MessageEvent<OAuthMessage>) => {
75
+ if (e.origin === here.origin && isAck(e.data)) claimed()
76
+ }
77
+ const timer = setTimeout(() => finish(false), ACK_WAIT_MS)
78
+ if (channel) channel.onmessage = (e: MessageEvent<OAuthMessage>) => isAck(e.data) && claimed()
79
+ addEventListener('message', onMessage)
80
+ channel?.postMessage(message)
81
+ try {
82
+ window.opener?.postMessage(message, here.origin)
83
+ } catch {
84
+ /* the opener is cross-origin or gone: the channel is the only way */
85
+ }
86
+ })
87
+ }
package/src/oauth.ts ADDED
@@ -0,0 +1,195 @@
1
+ import {
2
+ authorizeUrl,
3
+ createPkce,
4
+ newState,
5
+ OAuthError,
6
+ parseCallback,
7
+ parseTokenResponse,
8
+ tokenRequestFields,
9
+ } from '@specbook/core'
10
+ import { OAUTH_CHANNEL, type OAuthMessage } from './oauth-callback'
11
+ import { kv, type Store } from './storage'
12
+
13
+ /**
14
+ * Page side of "Sign in": opens the provider in a popup, waits for the return leg
15
+ * (`oauth-callback.ts`), exchanges the code and hands back the access token. The verifier and the `state`
16
+ * live in this closure only: not in the URL, not in storage, not in a log.
17
+ */
18
+
19
+ /**
20
+ * The address a provider sends the person back to, and the one to register with it.
21
+ *
22
+ * The hosted site declares a stable, unversioned callback page (a `<meta>` its assembly adds to the viewer's
23
+ * `index.html`), so what people register does not change with the viewer's channel (`/v1/`, `/v2/`).
24
+ * Anywhere else (a self-hosted copy, an embed on a host page, the dev server) it is the page's own address.
25
+ */
26
+ export const redirectUri = (): string => {
27
+ const declared = document
28
+ .querySelector<HTMLMetaElement>('meta[name="specbook-oauth-callback"]')
29
+ ?.content.trim()
30
+ if (declared) {
31
+ try {
32
+ return new URL(declared, location.origin).href
33
+ } catch {
34
+ /* not an address: fall back to the page's own */
35
+ }
36
+ }
37
+ return `${location.origin}${location.pathname}`
38
+ }
39
+
40
+ export interface SignInInput {
41
+ authorizationUrl: string
42
+ clientId: string
43
+ scopes: string[]
44
+ /** posts the form to the token endpoint and returns the parsed response body */
45
+ exchange(fields: Record<string, string>, signal?: AbortSignal): Promise<unknown>
46
+ signal?: AbortSignal
47
+ /** how long to wait for the person to finish (default five minutes) */
48
+ timeoutMs?: number
49
+ }
50
+
51
+ /**
52
+ * Call this straight from a click: the popup has to open before anything is awaited or the browser
53
+ * blocks it.
54
+ */
55
+ export async function signIn(
56
+ i: SignInInput,
57
+ ): Promise<{ accessToken: string; expiresIn?: number; scope?: string }> {
58
+ // opened blank and pointed at the provider once the challenge exists, which keeps Safari's
59
+ // user-activation rule happy
60
+ const popup = window.open('', OAUTH_CHANNEL, 'popup,width=520,height=720')
61
+ if (!popup)
62
+ throw new OAuthError(
63
+ 'The browser blocked the sign-in window. Allow pop-ups for this page and try again.',
64
+ )
65
+
66
+ const channel = new BroadcastChannel(OAUTH_CHANNEL)
67
+ let onWindowMessage: ((e: MessageEvent<OAuthMessage>) => void) | undefined
68
+ try {
69
+ const { verifier, challenge } = await createPkce()
70
+ const state = newState()
71
+ const redirect = redirectUri()
72
+
73
+ const returned = new Promise<OAuthMessage & { type: 'callback' }>((resolve, reject) => {
74
+ const timer = setTimeout(
75
+ () => reject(new OAuthError('Sign-in timed out. Try again.')),
76
+ i.timeoutMs ?? 5 * 60_000,
77
+ )
78
+ const stop = () => clearTimeout(timer)
79
+ i.signal?.addEventListener('abort', () => {
80
+ stop()
81
+ reject(new OAuthError('Sign-in was cancelled.'))
82
+ })
83
+ // the return arrives over the channel or from the popup as its opener; both carry the same message,
84
+ // and the one that arrives second is a repeat of the first
85
+ const take = (m: OAuthMessage | undefined, ack: (a: OAuthMessage) => void) => {
86
+ if (m?.type !== 'callback' || m.state !== state) return
87
+ stop()
88
+ ack({ type: 'ack', state })
89
+ resolve(m)
90
+ }
91
+ channel.onmessage = (e: MessageEvent<OAuthMessage>) =>
92
+ take(e.data, (a) => channel.postMessage(a))
93
+ onWindowMessage = (e: MessageEvent<OAuthMessage>) => {
94
+ // only this origin's own callback page: another site cannot hand us a code
95
+ if (e.origin !== location.origin) return
96
+ take(e.data, (a) => (e.source as Window | null)?.postMessage(a, location.origin))
97
+ }
98
+ addEventListener('message', onWindowMessage)
99
+ })
100
+
101
+ popup.location.href = authorizeUrl({
102
+ authorizationUrl: i.authorizationUrl,
103
+ clientId: i.clientId,
104
+ redirectUri: redirect,
105
+ scopes: i.scopes,
106
+ state,
107
+ challenge,
108
+ })
109
+
110
+ const back = await returned
111
+ const { code } = parseCallback(
112
+ {
113
+ state: back.state,
114
+ ...(back.code ? { code: back.code } : {}),
115
+ ...(back.error ? { error: back.error } : {}),
116
+ ...(back.description ? { error_description: back.description } : {}),
117
+ },
118
+ state,
119
+ )
120
+ const body = await i.exchange(
121
+ tokenRequestFields({ code, verifier, clientId: i.clientId, redirectUri: redirect }),
122
+ i.signal,
123
+ )
124
+ return parseTokenResponse(body)
125
+ } finally {
126
+ channel.close()
127
+ if (onWindowMessage) removeEventListener('message', onWindowMessage)
128
+ try {
129
+ popup.close()
130
+ } catch {
131
+ /* a severed popup cannot be closed from here; the callback page closes itself */
132
+ }
133
+ }
134
+ }
135
+
136
+ // ---- small per-browser conveniences: a client id is public, and an expiry note is not a secret ----
137
+
138
+ const CLIENTS = 'specbook.oauth.clients.v1'
139
+ const EXPIRY = 'specbook.oauth.expiry.v1'
140
+
141
+ const readJson = (store: Store, key: string): Record<string, any> => {
142
+ try {
143
+ return JSON.parse(store.getItem(key) ?? '{}')
144
+ } catch {
145
+ return {}
146
+ }
147
+ }
148
+ const writeJson = (store: Store, key: string, value: unknown) => {
149
+ try {
150
+ store.setItem(key, JSON.stringify(value))
151
+ } catch {
152
+ /* blocked or full: the page still works without it */
153
+ }
154
+ }
155
+
156
+ export const recallClientId = (authorizationUrl: string): string | undefined => {
157
+ try {
158
+ return readJson(kv.local, CLIENTS)[authorizationUrl]
159
+ } catch {
160
+ return undefined
161
+ }
162
+ }
163
+
164
+ export const rememberClientId = (authorizationUrl: string, clientId: string): void => {
165
+ try {
166
+ writeJson(kv.local, CLIENTS, {
167
+ ...readJson(kv.local, CLIENTS),
168
+ [authorizationUrl]: clientId,
169
+ })
170
+ } catch {
171
+ /* no storage */
172
+ }
173
+ }
174
+
175
+ const expiryKey = (origin: string, name: string) => `${origin}\n${name}`
176
+
177
+ /** Remembered for this tab only, to say "expires in 59 min"; the token itself stays in the secret store. */
178
+ export const noteExpiry = (origin: string, name: string, expiresIn: number | undefined): void => {
179
+ try {
180
+ const t = readJson(kv.session, EXPIRY)
181
+ if (expiresIn === undefined) delete t[expiryKey(origin, name)]
182
+ else t[expiryKey(origin, name)] = Date.now() + expiresIn * 1000
183
+ writeJson(kv.session, EXPIRY, t)
184
+ } catch {
185
+ /* no storage */
186
+ }
187
+ }
188
+
189
+ export const expiryOf = (origin: string, name: string): number | undefined => {
190
+ try {
191
+ return readJson(kv.session, EXPIRY)[expiryKey(origin, name)]
192
+ } catch {
193
+ return undefined
194
+ }
195
+ }
@@ -20,10 +20,20 @@ interface Props {
20
20
  files?: string[]
21
21
  /** the project's own notes for body fields, by `data.<path>` */
22
22
  notes?: Map<string, string>
23
+ /** shown, not edited */
24
+ readOnly?: boolean
23
25
  }
24
26
 
25
27
  /** Keeps the typed texts locally, so invalid variables JSON isn't rewritten while the user is typing. */
26
- function GraphqlBody({ draft, onChange }: { draft: Draft; onChange: (d: Draft) => void }) {
28
+ function GraphqlBody({
29
+ draft,
30
+ onChange,
31
+ readOnly,
32
+ }: {
33
+ draft: Draft
34
+ onChange: (d: Draft) => void
35
+ readOnly?: boolean
36
+ }) {
27
37
  const [g, setG] = useState(() => splitGraphql(draft.body) as Graphql)
28
38
  const emitted = useRef(draft.body)
29
39
  useEffect(() => {
@@ -46,6 +56,7 @@ function GraphqlBody({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
46
56
  <span class="chip">graphql</span>
47
57
  </div>
48
58
  <BodyEditor
59
+ readOnly={readOnly}
49
60
  label="GraphQL query"
50
61
  language="graphql"
51
62
  value={g.query}
@@ -56,6 +67,7 @@ function GraphqlBody({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
56
67
  <span class="chip">json</span>
57
68
  </div>
58
69
  <BodyEditor
70
+ readOnly={readOnly}
59
71
  label="GraphQL variables"
60
72
  language="json"
61
73
  value={g.variables}
@@ -67,7 +79,16 @@ function GraphqlBody({ draft, onChange }: { draft: Draft; onChange: (d: Draft) =
67
79
 
68
80
  const language = (e: Encoding) => (e === 'graphql' ? 'graphql' : e === 'text' ? 'text' : 'json')
69
81
 
70
- export function Body({ encoding, draft, onChange, schema, defs, files = [], notes }: Props) {
82
+ export function Body({
83
+ encoding,
84
+ draft,
85
+ onChange,
86
+ schema,
87
+ defs,
88
+ files = [],
89
+ notes,
90
+ readOnly,
91
+ }: Props) {
71
92
  const [line, setLine] = useState(0)
72
93
  const paths = useMemo(() => linePaths(draft.body), [draft.body])
73
94
  const documented = encoding === 'json' || encoding === 'form'
@@ -136,6 +157,7 @@ export function Body({ encoding, draft, onChange, schema, defs, files = [], note
136
157
  title={encoding === 'binary' ? 'File' : 'Body'}
137
158
  bare={!!viewToggle}
138
159
  rows={draft.fields}
160
+ readOnly={readOnly}
139
161
  onChange={set}
140
162
  documented={[...new Set(fileKeys)].map((name) => ({ name, file: true }))}
141
163
  docFor={(k) => withNote(fieldDoc(schema, defs, keyPath(k), ['data']), notes, 'data', k)}
@@ -153,7 +175,7 @@ export function Body({ encoding, draft, onChange, schema, defs, files = [], note
153
175
  }
154
176
 
155
177
  if (encoding === 'graphql' && splitGraphql(draft.body))
156
- return <GraphqlBody draft={draft} onChange={onChange} />
178
+ return <GraphqlBody draft={draft} onChange={onChange} readOnly={readOnly} />
157
179
 
158
180
  return (
159
181
  <section class="body" aria-label="Body">
@@ -161,7 +183,7 @@ export function Body({ encoding, draft, onChange, schema, defs, files = [], note
161
183
  <h3>Body</h3>
162
184
  <span class="chip">{encoding}</span>
163
185
  <span class="spacer" />
164
- {encoding !== 'text' && (
186
+ {encoding !== 'text' && !readOnly && (
165
187
  <button type="button" class="link" onClick={format}>
166
188
  Format
167
189
  </button>
@@ -169,6 +191,7 @@ export function Body({ encoding, draft, onChange, schema, defs, files = [], note
169
191
  {viewToggle}
170
192
  </div>
171
193
  <BodyEditor
194
+ readOnly={readOnly}
172
195
  label="Request body"
173
196
  language={language(encoding === 'form' ? 'json' : encoding)}
174
197
  value={draft.body}
@@ -0,0 +1,79 @@
1
+ import { type AssertionResult, type DiffLine, diffAssertion } from '@specbook/core'
2
+ import { registry } from '../runtime'
3
+
4
+ /** The project file an assertion failed in, as `file:line`, when its stack names one of the project's requests or flows. */
5
+ export function whereOf(a: AssertionResult): string | undefined {
6
+ const loc = a.location
7
+ if (!loc) return undefined
8
+ const files = [...registry.requests, ...registry.sequences].map((e) => e.file)
9
+ // the stack names a file by its address in the page; the longest project path it ends with is the file
10
+ const hit = files.filter((f) => loc.file.includes(f)).sort((a, b) => b.length - a.length)[0]
11
+ return hit ? `${hit}:${loc.line}` : undefined
12
+ }
13
+
14
+ const GUTTER = { expected: '−', received: '+', same: ' ', skip: ' ' } as const
15
+ const SPOKEN = { expected: 'expected', received: 'received', same: '', skip: '' } as const
16
+
17
+ function Diff({ lines }: { lines: DiffLine[] }) {
18
+ return (
19
+ // biome-ignore lint/a11y/noNoninteractiveTabindex: a block that scrolls sideways must be reachable by keyboard
20
+ <section class="diff" tabIndex={0} aria-label="Expected and received values">
21
+ {lines.map((l) => (
22
+ <div class={`diff-line diff-${l.kind}`}>
23
+ <span class="diff-gutter" aria-hidden="true">
24
+ {GUTTER[l.kind]}
25
+ </span>
26
+ {SPOKEN[l.kind] && <span class="visually-hidden">{SPOKEN[l.kind]}: </span>}
27
+ <span class="diff-text">{l.text}</span>
28
+ </div>
29
+ ))}
30
+ </section>
31
+ )
32
+ }
33
+
34
+ /** One failed assertion: what it checks, what was expected and received as a colored diff, and where it is. */
35
+ export function Failure({ a }: { a: AssertionResult }) {
36
+ const lines = diffAssertion(a)
37
+ const where = whereOf(a)
38
+ return (
39
+ <fieldset class="check-fail">
40
+ <legend class="visually-hidden">Failed check: {a.label}</legend>
41
+ <div class="check-head">
42
+ <span class="check-mark" aria-hidden="true">
43
+ ✗
44
+ </span>
45
+ <strong>{a.label}</strong>
46
+ <span class="chip">{a.operator}</span>
47
+ </div>
48
+ {lines.length > 0 && (
49
+ <>
50
+ <div class="diff-legend" aria-hidden="true">
51
+ <span class="diff-key diff-expected">− Expected</span>
52
+ <span class="diff-key diff-received">+ Received</span>
53
+ </div>
54
+ <Diff lines={lines} />
55
+ </>
56
+ )}
57
+ {where && <div class="check-where">at {where}</div>}
58
+ </fieldset>
59
+ )
60
+ }
61
+
62
+ /** The checks of a step: passing ones in a line, failing ones as cards with their diff. */
63
+ export function Checks({ assertions }: { assertions: AssertionResult[] }) {
64
+ return (
65
+ <ul class="checks">
66
+ {assertions.map((a) =>
67
+ a.pass ? (
68
+ <li class="ok">
69
+ <span class="tick">✓</span> {a.label}
70
+ </li>
71
+ ) : (
72
+ <li class="bad">
73
+ <Failure a={a} />
74
+ </li>
75
+ ),
76
+ )}
77
+ </ul>
78
+ )
79
+ }
@@ -11,14 +11,15 @@ import {
11
11
  import type { ComponentChildren } from 'preact'
12
12
  import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
13
13
  import { engine } from '../runtime'
14
- import { version } from '../store'
14
+ import { kv } from '../storage'
15
+ import { env, envVarsVersion, version } from '../store'
15
16
  import { type Draft, requestParts } from './draft'
16
17
 
17
18
  const LANG_KEY = 'specbook:code-lang'
18
19
 
19
20
  function savedLang(): SnippetLang {
20
21
  try {
21
- const v = localStorage.getItem(LANG_KEY)
22
+ const v = kv.local.getItem(LANG_KEY)
22
23
  if (v === 'curl' || v === 'fetch') return v
23
24
  } catch {
24
25
  /* optional */
@@ -50,18 +51,25 @@ export function CodeSample({ service, profile, secured, make, sent }: Props) {
50
51
 
51
52
  useEffect(() => {
52
53
  let live = true
53
- engine.config(version.value).then((c) => {
54
- if (!live) return
54
+ engine.config(version.value).then(async (c) => {
55
55
  const svc = c.services?.[service]
56
- const urls = svc?.baseUrl ?? {}
57
- setBase(urls.live ?? Object.values(urls)[0])
56
+ const addressIn = (e: string) =>
57
+ engine.baseUrlOf(service, e, version.value).catch(() => undefined)
58
+ // the environment being used, so a sample for the sandbox points at the sandbox; mock, or an
59
+ // environment with no address, uses `live`, else the first address the service lists
60
+ let address = env.value === 'mock' ? undefined : await addressIn(env.value)
61
+ address ??= await addressIn('live')
62
+ if (!address && svc && typeof svc.baseUrl === 'object')
63
+ address = Object.values(svc.baseUrl)[0]
64
+ if (!live) return
65
+ setBase(address)
58
66
  const name = profile === 'service' ? svc?.auth : profile
59
67
  setApplied(name ? (c.auth?.[name] as AuthProfile | undefined)?.apply : undefined)
60
68
  })
61
69
  return () => {
62
70
  live = false
63
71
  }
64
- }, [service, profile])
72
+ }, [service, profile, env.value, envVarsVersion.value])
65
73
 
66
74
  const built = useMemo<{ text?: string; error?: string }>(() => {
67
75
  try {
@@ -75,7 +83,7 @@ export function CodeSample({ service, profile, secured, make, sent }: Props) {
75
83
  setView(l)
76
84
  if (l === 'sent') return
77
85
  try {
78
- localStorage.setItem(LANG_KEY, l)
86
+ kv.local.setItem(LANG_KEY, l)
79
87
  } catch {
80
88
  /* optional */
81
89
  }