@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.
- package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
- package/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
- package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
- package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
- package/dist-viewer/assets/mount-DfwluJy4.js +2 -0
- package/dist-viewer/assets/panel-9eYU7Gz6.js +1 -0
- package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
- package/dist-viewer/assets/registry-DIHX11uY.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 +7 -5
- package/src/app.tsx +71 -13
- package/src/branding.ts +12 -4
- package/src/components/Catalog.tsx +128 -51
- package/src/components/EnvBadge.tsx +24 -4
- 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 +287 -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 +416 -32
- package/src/environment-list.ts +16 -0
- package/src/main.tsx +14 -1
- package/src/nav.ts +12 -1
- package/src/oauth-callback.ts +87 -0
- package/src/oauth.ts +195 -0
- package/src/panel/Body.tsx +27 -4
- package/src/panel/Checks.tsx +79 -0
- package/src/panel/CodeSample.tsx +16 -8
- 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 +167 -0
- package/src/panel/Panel.tsx +50 -13
- package/src/panel/ParamGrid.tsx +15 -8
- package/src/panel/RequestPanel.tsx +93 -36
- package/src/panel/RequestView.tsx +2 -1
- package/src/panel/ResponseView.tsx +25 -14
- package/src/panel/SecretsBar.tsx +43 -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 +10 -0
- package/src/styles/app.css +69 -6
- package/src/styles/embed.css +116 -0
- package/src/styles/panel.css +277 -2
- package/src/viewer/embed.ts +5 -0
- package/src/viewer/main.tsx +5 -1
- package/src/viewer/manifests.ts +2 -0
- package/src/viewer/protocol.ts +9 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +48 -3
- package/src/viewer/worker.ts +20 -7
- package/src/virtual.d.ts +8 -0
- package/dist-viewer/assets/Host-DdTTHeXU.js +0 -37
- package/dist-viewer/assets/index-DnYdlDXQ.js +0 -1
- package/dist-viewer/assets/main-uV4JzQPd.js +0 -14
- package/dist-viewer/assets/mount-B-qFOcWp.js +0 -2
- package/dist-viewer/assets/panel-CoQ6iQpX.js +0 -1
- package/dist-viewer/assets/panel-Coi9H1nv.css +0 -1
- package/dist-viewer/assets/registry-DI74Wtj8.js +0 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/panel/Body.tsx
CHANGED
|
@@ -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({
|
|
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({
|
|
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
|
+
}
|
package/src/panel/CodeSample.tsx
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
|
57
|
-
|
|
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
|
-
|
|
86
|
+
kv.local.setItem(LANG_KEY, l)
|
|
79
87
|
} catch {
|
|
80
88
|
/* optional */
|
|
81
89
|
}
|