executable-stories-showcase 0.0.0-stage → 0.0.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/LICENSE +202 -0
- package/README.md +17 -2
- package/dist/adapters/cookies.d.ts +11 -0
- package/dist/adapters/cookies.js +27 -0
- package/dist/adapters/cookies.js.map +1 -0
- package/dist/adapters/redux.d.ts +23 -0
- package/dist/adapters/redux.js +28 -0
- package/dist/adapters/redux.js.map +1 -0
- package/dist/adapters/tanstack-query.d.ts +17 -0
- package/dist/adapters/tanstack-query.js +43 -0
- package/dist/adapters/tanstack-query.js.map +1 -0
- package/dist/adapters/xstate.d.ts +33 -0
- package/dist/adapters/xstate.js +36 -0
- package/dist/adapters/xstate.js.map +1 -0
- package/dist/adapters/zustand.d.ts +17 -0
- package/dist/adapters/zustand.js +16 -0
- package/dist/adapters/zustand.js.map +1 -0
- package/dist/capture-GCESXDQD.js +10 -0
- package/dist/capture-GCESXDQD.js.map +1 -0
- package/dist/capture.d.ts +26 -0
- package/dist/catalog.d.ts +27 -0
- package/dist/chunk-7VEOUHJP.js +40 -0
- package/dist/chunk-7VEOUHJP.js.map +1 -0
- package/dist/chunk-BK4EEQXV.js +98 -0
- package/dist/chunk-BK4EEQXV.js.map +1 -0
- package/dist/chunk-MCC3CAWQ.js +208 -0
- package/dist/chunk-MCC3CAWQ.js.map +1 -0
- package/dist/chunk-PKGBYTBX.js +46 -0
- package/dist/chunk-PKGBYTBX.js.map +1 -0
- package/dist/chunk-QDLRQPTA.js +125 -0
- package/dist/chunk-QDLRQPTA.js.map +1 -0
- package/dist/chunk-XLNYICOI.js +234 -0
- package/dist/chunk-XLNYICOI.js.map +1 -0
- package/dist/chunk-Z664X6AQ.js +148 -0
- package/dist/chunk-Z664X6AQ.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +531 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +7 -0
- package/dist/link.d.ts +32 -0
- package/dist/network-4UFPO74C.js +7 -0
- package/dist/network-4UFPO74C.js.map +1 -0
- package/dist/network.d.ts +3 -0
- package/dist/next.d.ts +21 -0
- package/dist/next.js +30 -0
- package/dist/next.js.map +1 -0
- package/dist/no-msw-browser.d.ts +2 -0
- package/dist/no-msw-browser.js +8 -0
- package/dist/no-msw-browser.js.map +1 -0
- package/dist/panel.d.ts +30 -0
- package/dist/panel.js +608 -0
- package/dist/panel.js.map +1 -0
- package/dist/playwright.d.ts +31 -0
- package/dist/playwright.js +92 -0
- package/dist/playwright.js.map +1 -0
- package/dist/ready-4MENAMNI.js +13 -0
- package/dist/ready-4MENAMNI.js.map +1 -0
- package/dist/ready.d.ts +39 -0
- package/dist/record.d.ts +49 -0
- package/dist/redact.d.ts +19 -0
- package/dist/runtime.d.ts +191 -0
- package/dist/save.d.ts +27 -0
- package/dist/schema.d.ts +161 -0
- package/dist/vite.d.ts +11 -0
- package/dist/vite.js +26 -0
- package/dist/vite.js.map +1 -0
- package/package.json +118 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/panel.ts"],"sourcesContent":["import { z } from 'zod';\nimport { captureScenario } from './capture.js';\nimport { isLoopbackSaveUrl, PARAM, readInline, SAVE_ENDPOINT, scenarioHref } from './link.js';\nimport { checkReadySelector, closestReadySelector, suggestReadySelectors } from './ready.js';\nimport type { ScenarioSession } from './runtime.js';\nimport { parseScenario, type Scenario } from './schema.js';\n\nconst OPEN_KEY = 'scenario-panel:open';\n\n// \"Saved …\" survives the reload Vite does when a new file lands in the scenarios glob.\nconst FLASH_KEY = 'scenario-panel:flash';\n\n// The panel posts the captured scenario to `url`, so only a loopback save server is accepted.\nconst SaveProbeSchema = z.object({ dir: z.string(), url: z.string().refine(isLoopbackSaveUrl).optional() });\n\nconst SaveResultSchema = z.object({ path: z.string().optional(), error: z.string().optional() });\n\nconst FlashSchema = z.object({ message: z.string(), at: z.number() });\n\nexport interface PanelOptions {\n /** Corner to dock in, e.g. to avoid a chat widget. Default \"bottom-right\". */\n position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';\n /** Default: the maximum, so the panel stays above app overlays. */\n zIndex?: number;\n /** \"auto\" follows the OS colour scheme. Default \"auto\". */\n theme?: 'auto' | 'light' | 'dark';\n /**\n * Relative `$schema` path stamped on Download JSON, e.g. `../scenario.schema.json`.\n * Copy link stays schema-free.\n */\n schemaPath?: string;\n}\n\n/**\n * Add the floating scenario panel to the page. Framework-agnostic.\n * Remove it with `panel.remove()`; it unsubscribes from the session.\n */\nexport function mountPanel(session: ScenarioSession, options: PanelOptions = {}) {\n if (!customElements.get('scenario-panel')) {\n customElements.define('scenario-panel', ScenarioPanel);\n }\n\n const el = new ScenarioPanel();\n el.session = session;\n el.schemaPath = options.schemaPath;\n const [v, h] = (options.position ?? 'bottom-right').split('-');\n el.dataset.v = v;\n el.dataset.h = h;\n\n if (options.zIndex !== undefined) el.style.zIndex = String(options.zIndex);\n\n if (options.theme && options.theme !== 'auto') el.dataset.theme = options.theme;\n document.body.append(el);\n\n return el;\n}\n\n// Kept for imports from before captureScenario moved to the main entry.\nexport { type CapturedScenario, type CaptureOptions, captureScenario } from './capture.js';\n\n// Next.js loads client modules on the server too, where HTMLElement is undefined. Only\n// mountPanel creates a panel, and it runs in a browser, so on the server any base will do.\nconst Base: typeof HTMLElement = globalThis.HTMLElement ?? Object;\n\nclass ScenarioPanel extends Base {\n session!: ScenarioSession;\n schemaPath?: string;\n private render?: () => void;\n private unsubscribe?: () => void;\n private stopPick?: () => void;\n\n disconnectedCallback() {\n this.unsubscribe?.();\n this.unsubscribe = undefined;\n this.stopPick?.();\n this.stopPick = undefined;\n }\n\n connectedCallback() {\n // Re-attached (e.g. moved in the DOM): resubscribe and redraw.\n if (this.render) {\n this.unsubscribe = this.session.onChange(this.render);\n this.render();\n\n return;\n }\n\n const session = this.session;\n const { catalog } = session;\n const root = this.attachShadow({ mode: 'open' });\n\n root.innerHTML = `\n <style>${STYLES}</style>\n <button class=\"toggle\" aria-expanded=\"false\" aria-controls=\"drawer\"></button>\n <section id=\"drawer\" aria-label=\"Scenarios\" hidden>\n <header>\n <h2>Scenarios</h2>\n <button class=\"close\" aria-label=\"Close scenarios panel\">×</button>\n </header>\n <p class=\"error\" role=\"alert\"></p>\n <input type=\"search\" placeholder=\"Filter by name, description or tag\" aria-label=\"Filter scenarios\">\n <ul class=\"list\"></ul>\n <div class=\"unmocked\" role=\"status\"></div>\n <section class=\"live\" aria-label=\"Live state\"></section>\n <details class=\"save\">\n <summary>Save as scenario</summary>\n <p class=\"hint\">Saves the current URL, network responses and live state. <b>Copy link</b> shares it as is; <b>Download JSON</b> writes a file for your scenarios folder that keeps only what differs from the base.</p>\n <label>Name <input name=\"save-name\" placeholder=\"e.g. checkout-card-declined\" aria-label=\"Scenario name\"></label>\n <label>Description <input name=\"save-description\" aria-label=\"Scenario description\"></label>\n <label>Ready: what's on screen once this state has rendered <input name=\"save-ready\" placeholder='Use Mark ready, or type text=\"…\"' aria-label=\"Ready selector\"></label>\n <p class=\"ready-check\" role=\"status\"></p>\n <div class=\"row\">\n <button type=\"button\" data-act=\"mark-ready\">Mark ready</button>\n <button type=\"button\" data-act=\"clear-ready\">Clear</button>\n </div>\n <div class=\"candidates\" role=\"group\" aria-label=\"Suggested ready selectors\"></div>\n <div class=\"pick-box\" hidden><span></span></div>\n <label>Extends\n <select name=\"save-base\" aria-label=\"Base scenario to extend\"></select>\n </label>\n <div class=\"row\">\n <button data-act=\"save-project\" hidden>Save to project</button>\n <button data-act=\"save-link\">Copy link</button>\n <button data-act=\"save-json\">Download JSON</button>\n </div>\n <p class=\"save-status\" role=\"status\"></p>\n <input class=\"saved\" readonly aria-label=\"Saved scenario link\" hidden>\n </details>\n <details class=\"edit\">\n <summary>Edit current scenario</summary>\n <textarea spellcheck=\"false\" aria-label=\"Scenario JSON\"></textarea>\n <button data-act=\"apply\">Apply edited scenario</button>\n </details>\n <footer>\n <button data-act=\"copy\">Copy link</button>\n <a class=\"reset\">Reset</a>\n </footer>\n </section>`;\n\n const find = <T extends Element>(selector: string, type: new() => T): T => {\n const el = root.querySelector(selector);\n\n if (!(el instanceof type)) throw new Error(`executable-stories-showcase panel: missing ${selector}`);\n\n return el;\n };\n\n const toggle = find('.toggle', HTMLButtonElement);\n const drawer = find('#drawer', HTMLElement);\n const error = find('.error', HTMLParagraphElement);\n\n const run = (fn: () => void | Promise<void>) => async () => {\n error.textContent = '';\n\n try {\n await fn();\n } catch (err) {\n error.textContent = err instanceof Error ? err.message : String(err);\n }\n };\n\n const href = (s: Scenario) => {\n try {\n return { href: scenarioHref(catalog.resolve(s), { strict: session.strict }), invalid: false };\n } catch {\n return { href: `?${PARAM}=${encodeURIComponent(s.name)}`, invalid: true };\n }\n };\n\n find('.list', HTMLUListElement).innerHTML = catalog.list\n .map((s) => ({ s, ...href(s) }))\n .map(\n ({ s, href, invalid }) => `\n <li data-name=\"${esc(s.name)}\" data-search=\"${\n esc([s.name, s.description, ...(s.tags ?? [])].join(' ').toLowerCase())\n }\">\n <a href=\"${esc(href)}\">\n <strong>${esc(s.name)}${invalid ? ' <em>(invalid, opens with error)</em>' : ''}</strong>\n ${s.description ? `<span>${esc(s.description)}</span>` : ''}\n ${s.tags?.length ? `<small>${s.tags.map(esc).join(' · ')}</small>` : ''}\n </a>\n <button class=\"add\" aria-label=\"Combine ${\n esc(s.name)\n } with the current scenario\" title=\"Combine with the current scenario\">+</button>\n </li>`,\n )\n .join('');\n root.querySelectorAll<HTMLLIElement>('li[data-name]').forEach((li) => {\n const name = li.dataset.name!;\n li.querySelector('a')!.onclick = (e) => {\n if (e.metaKey || e.ctrlKey || e.shiftKey) return; // let the browser open a new tab\n e.preventDefault();\n void run(() => session.open(name))();\n };\n\n li.querySelector<HTMLButtonElement>('.add')!.onclick = run(() => {\n const names = new Set((session.active?.name ?? '').split(',').filter(Boolean));\n names.add(name);\n\n return session.open([...names].join(','));\n });\n });\n const filter = find('input[type=search]', HTMLInputElement);\n filter.oninput = () => {\n const q = filter.value.toLowerCase().trim();\n root.querySelectorAll<HTMLLIElement>('li[data-search]').forEach((li) => {\n li.hidden = !li.dataset.search!.includes(q);\n });\n };\n\n const editors = new Map<string, { textarea: HTMLTextAreaElement; dirty: boolean; }>();\n\n const renderLive = () => {\n const keys = session.keys();\n\n for (const [key, ed] of editors) {\n if (!keys.includes(key)) {\n ed.textarea.closest('details')!.remove();\n editors.delete(key);\n }\n }\n\n for (const key of keys) {\n let ed = editors.get(key);\n\n if (!ed) {\n const details = document.createElement('details');\n details.innerHTML = `\n <summary>Live state: <code>${esc(key)}</code></summary>\n <textarea spellcheck=\"false\" aria-label=\"Live state ${esc(key)}\"></textarea>\n <div class=\"row\"><button data-act=\"live-apply\">Apply</button><button data-act=\"live-revert\">Revert</button></div>`;\n find('.live', HTMLElement).append(details);\n const entry = { textarea: details.querySelector('textarea')!, dirty: false };\n entry.textarea.oninput = () => (entry.dirty = true);\n details.ontoggle = () => renderLive();\n details.querySelector<HTMLButtonElement>('[data-act=live-apply]')!.onclick = run(async () => {\n await session.applyState(key, JSON.parse(entry.textarea.value));\n entry.dirty = false;\n renderLive();\n });\n details.querySelector<HTMLButtonElement>('[data-act=live-revert]')!.onclick = () => {\n entry.dirty = false;\n renderLive();\n };\n\n editors.set(key, ed = entry);\n }\n\n // Follow the app while open (state can be large), never overwriting what someone is typing.\n if (!ed.dirty && ed.textarea.closest('details')!.open) {\n ed.textarea.value = JSON.stringify(session.read(key) ?? null, null, 2);\n }\n }\n };\n\n const saveName = find('input[name=save-name]', HTMLInputElement);\n const saveDescription = find('input[name=save-description]', HTMLInputElement);\n const saveReady = find('input[name=save-ready]', HTMLInputElement);\n const saveBase = find('select[name=save-base]', HTMLSelectElement);\n const readyCheck = find('.ready-check', HTMLParagraphElement);\n const saveProject = find('[data-act=save-project]', HTMLButtonElement);\n const saveStatus = find('.save-status', HTMLParagraphElement);\n const candidates = find('.candidates', HTMLElement);\n const saved = find('.saved', HTMLInputElement);\n const saveDetails = find('.save', HTMLDetailsElement);\n const markReadyBtn = find('[data-act=mark-ready]', HTMLButtonElement);\n\n const fillBaseOptions = () => {\n const preferred = catalog.get('default')?.name\n ?? [session.active?.extends ?? []].flat()[0]\n ?? '';\n\n const previous = saveBase.value;\n\n saveBase.innerHTML = `<option value=\"\">(none: save everything)</option>`\n + catalog.list.map((s) => `<option value=\"${esc(s.name)}\">${esc(s.name)}</option>`).join('');\n\n if (previous && [...saveBase.options].some((o) => o.value === previous)) {\n saveBase.value = previous;\n } else {\n saveBase.value = preferred;\n }\n };\n\n fillBaseOptions();\n\n const renderCandidates = () => {\n const picks = suggestReadySelectors(document.body, { limit: 3 });\n candidates.innerHTML = picks.length\n ? `<p class=\"hint\">Suggestions</p><div class=\"row\">${\n picks.map((s) => `<button type=\"button\" data-candidate=\"${esc(s)}\">${esc(s)}</button>`).join('')\n }</div>`\n : '';\n candidates.querySelectorAll<HTMLButtonElement>('[data-candidate]').forEach((btn) => {\n btn.onclick = () => {\n saveReady.value = btn.dataset.candidate ?? '';\n updateReadyCheck();\n };\n });\n };\n\n saveDetails.ontoggle = () => {\n if (saveDetails.open) {\n fillBaseOptions();\n // Offer, don't fill: the top suggestion is often chrome present in every state.\n renderCandidates();\n updateReadyCheck();\n void probeSaveEndpoint();\n }\n };\n\n const stopPickMode = () => {\n this.stopPick?.();\n this.stopPick = undefined;\n markReadyBtn.setAttribute('aria-pressed', 'false');\n markReadyBtn.textContent = 'Mark ready';\n };\n\n const pickBox = find('.pick-box', HTMLDivElement);\n const pickLabel = find('.pick-box span', HTMLSpanElement);\n\n const outline = (el: Element | undefined, label: string) => {\n if (!el) {\n pickBox.hidden = true;\n\n return;\n }\n\n const r = el.getBoundingClientRect();\n Object.assign(pickBox.style, {\n top: `${r.top}px`,\n left: `${r.left}px`,\n width: `${r.width}px`,\n height: `${r.height}px`,\n });\n pickBox.dataset.below = String(r.top < 28);\n pickLabel.textContent = label;\n pickBox.hidden = false;\n };\n\n // Elements under the pointer, outside the panel, innermost first.\n const appTarget = (e: Event) => {\n const path = e.composedPath();\n\n if (path.includes(this)) return undefined;\n\n return path.find((n): n is Element =>\n n instanceof Element && n !== document.documentElement && n !== document.body\n );\n };\n\n const READY_MESSAGES = {\n empty:\n \"No ready selector: the link still works, but shoot and tests won't wait for this state to render.\",\n none: 'No match on this page: tests would time out waiting for it.',\n unchecked: \"Can't check this kind of selector here; Playwright still can.\",\n };\n\n let readyMatch: Element | undefined;\n\n const updateReadyCheck = () => {\n const value = saveReady.value.trim();\n const check = value ? checkReadySelector(value) : undefined;\n readyMatch = check?.status === 'match' ? check.first : undefined;\n\n let tone = 'muted';\n let message: string;\n\n if (!check) message = READY_MESSAGES.empty;\n else if (check.status === 'invalid') [tone, message] = ['bad', check.error];\n else if (check.status === 'none') [tone, message] = ['bad', READY_MESSAGES.none];\n else if (check.status === 'unchecked') message = READY_MESSAGES.unchecked;\n else if (!check.visible) {\n message = 'Matches, but the first match is hidden: tests wait until it shows.';\n } else if (check.count > 1) {\n message = `Matches ${check.count} elements; tests wait for the first (outlined).`;\n } else [tone, message] = ['ok', '✓ Matches 1 element on this page.'];\n\n readyCheck.dataset.tone = tone;\n readyCheck.textContent = message;\n\n // While the field has focus, show which element tests will wait on.\n if (this.shadowRoot?.activeElement === saveReady && !this.stopPick) outline(readyMatch, 'ready');\n };\n\n saveReady.oninput = updateReadyCheck;\n saveReady.onfocus = updateReadyCheck;\n\n saveReady.onblur = () => {\n if (!this.stopPick) outline(undefined, '');\n };\n\n updateReadyCheck();\n\n const startPickMode = () => {\n stopPickMode();\n markReadyBtn.setAttribute('aria-pressed', 'true');\n markReadyBtn.textContent = 'Click the UI… (Esc cancels)';\n document.documentElement.style.cursor = 'crosshair';\n\n const onMove = (e: MouseEvent) => {\n const target = appTarget(e);\n const hit = target && closestReadySelector(target);\n\n if (hit) outline(hit.element, hit.selector);\n else outline(target, 'No selector here: try text, a heading or a button');\n };\n\n // Swallow presses so the app doesn't react (open menus, follow links) while picking.\n const block = (e: Event) => {\n if (!appTarget(e)) return;\n e.preventDefault();\n e.stopPropagation();\n };\n\n const onClick = (e: MouseEvent) => {\n const target = appTarget(e);\n\n if (!target) return;\n block(e);\n const hit = closestReadySelector(target);\n\n // Stay in pick mode so the user can try another element; the outline says why.\n if (!hit) return;\n\n saveReady.value = hit.selector;\n updateReadyCheck();\n stopPickMode();\n saveReady.focus();\n };\n\n const onKey = (e: KeyboardEvent) => {\n if (e.key !== 'Escape') return;\n // Esc only cancels picking; without this the drawer's Esc handler also closes the panel.\n e.stopPropagation();\n stopPickMode();\n };\n\n const pressEvents = ['pointerdown', 'mousedown', 'pointerup', 'mouseup'] as const;\n\n document.addEventListener('mousemove', onMove, true);\n document.addEventListener('click', onClick, true);\n document.addEventListener('keydown', onKey, true);\n\n for (const type of pressEvents) document.addEventListener(type, block, true);\n\n this.stopPick = () => {\n document.removeEventListener('mousemove', onMove, true);\n document.removeEventListener('click', onClick, true);\n document.removeEventListener('keydown', onKey, true);\n\n for (const type of pressEvents) document.removeEventListener(type, block, true);\n\n document.documentElement.style.removeProperty('cursor');\n pickBox.hidden = true;\n };\n };\n\n markReadyBtn.onclick = () => {\n if (markReadyBtn.getAttribute('aria-pressed') === 'true') stopPickMode();\n else startPickMode();\n };\n\n find('[data-act=clear-ready]', HTMLButtonElement).onclick = () => {\n saveReady.value = '';\n updateReadyCheck();\n };\n\n const capture = () => {\n const baseName = saveBase.value;\n const base = baseName ? catalog.get(baseName) : undefined;\n\n return captureScenario(session, {\n name: saveName.value.trim(),\n description: saveDescription.value.trim(),\n ready: saveReady.value.trim() || undefined,\n base,\n schemaPath: this.schemaPath,\n });\n };\n\n let probe: Promise<void> | undefined;\n // The Next.js route answers with a loopback save server's URL; Vite saves on its own origin.\n let saveUrl = SAVE_ENDPOINT;\n\n // The Vite plugin or the Next.js route answers with JSON; anything else (a build, an app\n // without either) returns HTML or a 404, and the button stays hidden.\n const probeSaveEndpoint = () =>\n probe ??= fetch(SAVE_ENDPOINT, { headers: { accept: 'application/json' } })\n .then(async (res) => {\n const isJson = res.ok && res.headers.get('content-type')?.includes('json');\n const probed = isJson ? SaveProbeSchema.safeParse(await res.json()) : undefined;\n\n if (!probed?.success) return;\n saveUrl = probed.data.url ?? SAVE_ENDPOINT;\n saveProject.textContent = `Save to ${probed.data.dir}`;\n saveProject.hidden = false;\n })\n .catch(() => {});\n\n const saveToProject = async (overwrite: boolean): Promise<void> => {\n const { forCatalog } = capture();\n\n const res = await fetch(saveUrl, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ scenario: forCatalog, overwrite }),\n });\n\n const body = SaveResultSchema.catch({}).parse(await res.json().catch(() => ({})));\n\n if (res.status === 409 && !overwrite) {\n if (confirm(`${body.path} already exists. Replace it?`)) return saveToProject(true);\n\n return;\n }\n\n if (!res.ok) throw new Error(body.error ?? `Save failed (HTTP ${res.status})`);\n\n const message = `Saved ${body.path}. Commit it with your change.`;\n saveStatus.textContent = message;\n\n try {\n sessionStorage.setItem(FLASH_KEY, JSON.stringify({ message, at: Date.now() }));\n } catch {\n // Storage blocked: the message won't survive a reload.\n }\n };\n\n saveProject.onclick = run(async () => {\n saveStatus.textContent = '';\n await session.settleRecording();\n await saveToProject(false);\n });\n\n try {\n const flash = FlashSchema.safeParse(JSON.parse(sessionStorage.getItem(FLASH_KEY) ?? 'null')).data;\n sessionStorage.removeItem(FLASH_KEY);\n\n // Only right after a save; a stale one would describe some other session.\n if (flash && Date.now() - flash.at < 10_000) {\n saveStatus.textContent = flash.message;\n saveDetails.open = true;\n }\n } catch {\n // Storage blocked or corrupt: nothing to show.\n }\n\n find('[data-act=save-link]', HTMLButtonElement).onclick = run(async () => {\n await session.settleRecording();\n const { flat } = capture();\n const link = scenarioHref(flat, { inline: true, strict: session.strict });\n saved.hidden = false;\n saved.value = link;\n saved.select();\n await navigator.clipboard?.writeText(link).catch(() => {});\n updateReadyCheck();\n });\n find('[data-act=save-json]', HTMLButtonElement).onclick = run(async () => {\n await session.settleRecording();\n const { forCatalog } = capture();\n const a = document.createElement('a');\n a.href = URL.createObjectURL(\n new Blob([`${JSON.stringify(forCatalog, null, 2)}\\n`], { type: 'application/json' }),\n );\n a.download = `${forCatalog.name}.json`;\n a.click();\n URL.revokeObjectURL(a.href);\n updateReadyCheck();\n });\n\n const editor = find('.edit textarea', HTMLTextAreaElement);\n\n if (readInline(location.hash)) find('.edit', HTMLDetailsElement).open = true;\n find('[data-act=apply]', HTMLButtonElement).onclick = run(() =>\n session.openInline(parseScenario(JSON.parse(editor.value)))\n );\n\n find('[data-act=copy]', HTMLButtonElement).onclick = run(async () => {\n try {\n await navigator.clipboard.writeText(location.href);\n } catch {\n prompt('Copy this link', location.href);\n }\n });\n // An absolute same-origin URL: a path starting with \"//\" must not read as another host.\n const reset = new URL(location.href);\n reset.search = '';\n reset.hash = '';\n reset.searchParams.set(PARAM, '');\n find('.reset', HTMLAnchorElement).href = reset.href;\n\n const setOpen = (open: boolean) => {\n drawer.hidden = !open;\n toggle.setAttribute('aria-expanded', String(open));\n\n try {\n sessionStorage.setItem(OPEN_KEY, open ? '1' : '');\n } catch {\n // sessionStorage throws when storage is blocked; the panel just forgets\n }\n };\n\n let stored: string | null = null;\n\n try {\n stored = sessionStorage.getItem(OPEN_KEY);\n } catch {\n // sessionStorage throws when storage is blocked; the panel just forgets\n }\n\n setOpen(!!stored);\n toggle.onclick = () => setOpen(!!drawer.hidden);\n find('.close', HTMLButtonElement).onclick = () => {\n setOpen(false);\n toggle.focus();\n };\n\n drawer.onkeydown = (e) => {\n if (e.key === 'Escape') {\n setOpen(false);\n toggle.focus();\n }\n };\n\n let shownScenario: string | undefined;\n\n const render = () => {\n const { active, strict, unhandled, recording } = session;\n const mode = recording ? ` (recording ${session.recorded().length})` : strict ? ' (strict)' : '';\n toggle.innerHTML = `Scenario: <b>${esc(active?.name ?? 'none')}</b>${mode}`;\n const names = (active?.name ?? '').split(',');\n root.querySelectorAll<HTMLLIElement>('li[data-name]').forEach((li) => {\n const a = li.querySelector('a')!;\n\n if (names.includes(li.dataset.name!)) a.setAttribute('aria-current', 'true');\n else a.removeAttribute('aria-current');\n });\n // While recording, unmocked requests are the point: they're what gets saved.\n find('.unmocked', HTMLElement).innerHTML = unhandled.length && !recording\n ? `<p>${unhandled.length} request(s) not covered by this scenario${\n strict ? ' (answered 501)' : ''\n }:</p>\n <ul>${unhandled.map((u) => `<li><code>${esc(u)}</code></li>`).join('')}</ul>`\n : '';\n\n // Set as a property, not markup, so page text selectors never match the panel's JSON.\n if (active?.name !== shownScenario) {\n shownScenario = active?.name;\n editor.value = JSON.stringify(active ?? { name: 'custom', extends: catalog.list[0]?.name }, null, 2);\n fillBaseOptions();\n }\n\n renderLive();\n\n // The app re-rendered: the ready selector may match now, or no longer.\n if (saveDetails.open) updateReadyCheck();\n };\n\n this.render = render;\n render();\n this.unsubscribe = session.onChange(render);\n }\n}\n\nconst esc = (s: string) => s.replace(/[&<>\"']/g, (c) => `&#${c.charCodeAt(0)};`);\n\nconst STYLES = `\n :host {\n --bg: #ffffff; --fg: #1b1b1f; --muted: #5d5d66; --line: #dcdce2;\n --accent: #4747eb; --accent-fg: #ffffff; --hover: #f2f2f7; --danger: #b42318;\n all: initial; position: fixed; right: 16px; bottom: 16px; z-index: 2147483647;\n font: 14px/1.4 system-ui, sans-serif; color: var(--fg);\n }\n @media (prefers-color-scheme: dark) {\n :host(:not([data-theme=\"light\"])) {\n --bg: #1c1c21; --fg: #ececf1; --muted: #a3a3ad; --line: #34343c;\n --accent: #8c8cff; --accent-fg: #101014; --hover: #26262d; --danger: #ff8a80;\n }\n }\n :host([data-theme=\"dark\"]) {\n --bg: #1c1c21; --fg: #ececf1; --muted: #a3a3ad; --line: #34343c;\n --accent: #8c8cff; --accent-fg: #101014; --hover: #26262d; --danger: #ff8a80;\n }\n :host([data-v=\"top\"]) { bottom: auto; top: 16px; }\n :host([data-h=\"left\"]) { right: auto; left: 16px; }\n :host([data-v=\"top\"]) #drawer { bottom: auto; top: 48px; }\n :host([data-h=\"left\"]) #drawer { right: auto; left: 0; }\n :host([data-h=\"left\"]) .toggle { margin-left: 0; }\n * { box-sizing: border-box; font: inherit; color: inherit; }\n button, a { cursor: pointer; }\n :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }\n .toggle {\n display: block; margin-left: auto; padding: 8px 14px; border: 0; border-radius: 999px;\n background: var(--accent); color: var(--accent-fg); box-shadow: 0 4px 16px #0003;\n }\n .toggle b, strong { font-weight: 700; }\n #drawer {\n position: absolute; right: 0; bottom: 48px; width: min(400px, calc(100vw - 32px));\n max-height: min(680px, calc(100vh - 96px)); overflow: auto; padding: 12px;\n background: var(--bg); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 12px 40px #0004;\n }\n #drawer[hidden] { display: none; }\n header, footer, .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }\n .row { justify-content: flex-start; margin-top: 8px; flex-wrap: wrap; }\n h2 { margin: 0; font-size: 16px; font-weight: 700; }\n .close { border: 0; background: none; font-size: 22px; line-height: 1; padding: 4px 8px; border-radius: 6px; }\n input, textarea, select {\n width: 100%; margin: 6px 0; padding: 8px 10px; border: 1px solid var(--line);\n border-radius: 8px; background: var(--bg);\n }\n label { display: block; margin-top: 6px; color: var(--muted); font-size: 12px; }\n label input, label select { color: var(--fg); font-size: 14px; }\n ul { list-style: none; margin: 0; padding: 0; }\n li[hidden] { display: none; }\n .list li { display: flex; align-items: stretch; gap: 4px; }\n .list a {\n flex: 1; min-width: 0; overflow-wrap: anywhere; display: flex; flex-direction: column; gap: 2px; padding: 8px 10px; border-radius: 8px;\n text-decoration: none; border-left: 3px solid transparent;\n }\n .list a:hover { background: var(--hover); }\n .list a[aria-current] { border-left-color: var(--accent); background: var(--hover); }\n .list span { color: var(--muted); }\n .list small { color: var(--muted); font-size: 12px; }\n .add { width: 32px; border: 0; border-radius: 8px; background: none; color: var(--muted); font-size: 18px; }\n .add:hover { background: var(--hover); color: var(--fg); }\n details { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }\n summary { cursor: pointer; font-weight: 600; }\n code { font: 12px ui-monospace, monospace; }\n textarea { height: 200px; font: 12px/1.45 ui-monospace, monospace; resize: vertical; }\n .hint { margin: 6px 0 0; color: var(--muted); font-size: 12px; }\n .error { color: var(--danger); white-space: pre-wrap; margin: 8px 0; font-size: 12px; }\n .error:empty, .unmocked:empty, .candidates:empty { display: none; }\n .ready-check, .save-status { color: var(--muted); font-size: 12px; margin: 2px 0 0; }\n .ready-check[data-tone=ok] { color: var(--accent); }\n .ready-check[data-tone=bad] { color: var(--danger); }\n .save-status:empty { display: none; }\n details button[hidden] { display: none; }\n .candidates { margin-top: 6px; }\n .pick-box {\n position: fixed; pointer-events: none; box-sizing: border-box;\n border: 2px solid var(--accent); border-radius: 4px; background: #4747eb14;\n }\n .pick-box[hidden] { display: none; }\n .pick-box span {\n position: absolute; left: -2px; bottom: 100%; margin-bottom: 4px; max-width: 360px;\n padding: 2px 6px; border-radius: 4px; background: var(--accent); color: var(--accent-fg);\n font: 12px/1.4 ui-monospace, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n }\n .pick-box[data-below=true] span { bottom: auto; top: 100%; margin: 4px 0 0; }\n .candidates button { font-size: 12px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }\n .unmocked { margin-top: 10px; padding: 8px 10px; border-radius: 8px; background: var(--hover); color: var(--danger); font-size: 12px; }\n .unmocked p { margin: 0 0 4px; }\n .unmocked ul { padding-left: 16px; list-style: disc; }\n .unmocked code { overflow-wrap: anywhere; }\n em { font-style: normal; color: var(--danger); font-weight: 400; }\n details button, footer button {\n padding: 6px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--hover);\n }\n button[aria-pressed=\"true\"] { border-color: var(--accent); color: var(--accent); }\n footer { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }\n footer a { color: var(--accent); }\n`;\n"],"mappings":"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","names":["href"]}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
import type { ScenarioInput } from './schema.js';
|
|
3
|
+
export interface OpenOptions {
|
|
4
|
+
/** App path to open. Default "/". */
|
|
5
|
+
path?: string;
|
|
6
|
+
/** Fail unmocked requests instead of letting them reach a real backend. Default true. */
|
|
7
|
+
strict?: boolean;
|
|
8
|
+
/** Show the dev panel. False hides it, for screenshots that go into product docs. Default true. */
|
|
9
|
+
panel?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Open the app in a catalog scenario and wait until the UI shows it: the scenario is
|
|
13
|
+
* active and its `ready` selector, if any, is visible.
|
|
14
|
+
*/
|
|
15
|
+
export declare function openScenario(page: Page, name: string, options?: OpenOptions): Promise<void>;
|
|
16
|
+
/**
|
|
17
|
+
* Like `openScenario`, for a scenario defined in the test rather than the catalog.
|
|
18
|
+
* It opens as an inline link, so off localhost the app needs `startScenarios({ allowLinks: true })`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function openInlineScenario(page: Page, scenario: ScenarioInput, options?: OpenOptions): Promise<void>;
|
|
21
|
+
declare global {
|
|
22
|
+
/** Exposed by `openScenario({ panel: false })`: whether this page should hide the panel. */
|
|
23
|
+
var executableStoriesShowcasePanelHidden: (() => Promise<boolean>) | undefined;
|
|
24
|
+
}
|
|
25
|
+
/** Requests the active scenario didn't cover, e.g. to assert none in strict runs. */
|
|
26
|
+
export declare const unhandledRequests: (page: Page) => Promise<string[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Draw a mouse pointer that follows Playwright's mouse, since recorded videos don't show one.
|
|
29
|
+
* It glides between points and pulses on click. Call before the page loads, e.g. before `openScenario`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function showCursor(page: Page): Promise<void>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DATA_KEY,
|
|
3
|
+
PARAM,
|
|
4
|
+
STRICT_PARAM
|
|
5
|
+
} from "./chunk-PKGBYTBX.js";
|
|
6
|
+
|
|
7
|
+
// src/playwright.ts
|
|
8
|
+
async function openScenario(page, name, options = {}) {
|
|
9
|
+
await open(page, name, new URLSearchParams({ [PARAM]: name }), "", options);
|
|
10
|
+
}
|
|
11
|
+
async function openInlineScenario(page, scenario, options = {}) {
|
|
12
|
+
const hash = `#${new URLSearchParams({ [DATA_KEY]: JSON.stringify(scenario) })}`;
|
|
13
|
+
await open(page, scenario.name, new URLSearchParams(), hash, { path: scenario.path, ...options });
|
|
14
|
+
}
|
|
15
|
+
async function open(page, name, search, hash, { path = "/", strict = true, panel = true }) {
|
|
16
|
+
if (!/^\/(?![/\\])/.test(path)) {
|
|
17
|
+
throw new Error(`[executable-stories-showcase] path "${path}" must be a path on the app, such as /orders/42`);
|
|
18
|
+
}
|
|
19
|
+
if (strict) search.set(STRICT_PARAM, "");
|
|
20
|
+
await setPanel(page, panel);
|
|
21
|
+
await page.goto("about:blank");
|
|
22
|
+
await page.goto(`${path}?${search}${hash}`);
|
|
23
|
+
const html = page.locator(`html[data-scenario="${name}"]`);
|
|
24
|
+
await html.waitFor({ state: "attached" });
|
|
25
|
+
const ready = await html.getAttribute("data-scenario-ready");
|
|
26
|
+
if (ready) await page.locator(ready).first().waitFor();
|
|
27
|
+
}
|
|
28
|
+
var panelHidden = /* @__PURE__ */ new WeakMap();
|
|
29
|
+
async function setPanel(page, show) {
|
|
30
|
+
const state = panelHidden.get(page);
|
|
31
|
+
if (state) {
|
|
32
|
+
state.hidden = !show;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (show) return;
|
|
36
|
+
const created = { hidden: true };
|
|
37
|
+
panelHidden.set(page, created);
|
|
38
|
+
await page.exposeFunction("executableStoriesShowcasePanelHidden", () => created.hidden);
|
|
39
|
+
await page.addInitScript(() => {
|
|
40
|
+
const style = document.createElement("style");
|
|
41
|
+
style.textContent = "scenario-panel { display: none !important; }";
|
|
42
|
+
const attach = () => document.documentElement.append(style);
|
|
43
|
+
if (document.documentElement) attach();
|
|
44
|
+
else {
|
|
45
|
+
new MutationObserver((_, observer) => {
|
|
46
|
+
if (!document.documentElement) return;
|
|
47
|
+
observer.disconnect();
|
|
48
|
+
attach();
|
|
49
|
+
}).observe(document, { childList: true });
|
|
50
|
+
}
|
|
51
|
+
void globalThis.executableStoriesShowcasePanelHidden?.().then((hidden) => {
|
|
52
|
+
if (!hidden) style.remove();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
var unhandledRequests = (page) => page.evaluate(() => globalThis.executableStoriesShowcaseUnhandled ?? []);
|
|
57
|
+
async function showCursor(page) {
|
|
58
|
+
await page.addInitScript(() => {
|
|
59
|
+
const cursor = document.createElement("div");
|
|
60
|
+
cursor.setAttribute("aria-hidden", "true");
|
|
61
|
+
cursor.style.cssText = [
|
|
62
|
+
"position:fixed",
|
|
63
|
+
"left:0",
|
|
64
|
+
"top:0",
|
|
65
|
+
"width:18px",
|
|
66
|
+
"height:18px",
|
|
67
|
+
"margin:-9px 0 0 -9px",
|
|
68
|
+
"border-radius:50%",
|
|
69
|
+
"background:rgba(20,20,20,.55)",
|
|
70
|
+
"border:2px solid #fff",
|
|
71
|
+
"box-shadow:0 1px 4px rgba(0,0,0,.4)",
|
|
72
|
+
"pointer-events:none",
|
|
73
|
+
"z-index:2147483647",
|
|
74
|
+
"transform:translate(-40px,-40px)",
|
|
75
|
+
"transition:transform .25s ease-out,scale .1s"
|
|
76
|
+
].join(";");
|
|
77
|
+
const place = (e) => cursor.style.transform = `translate(${e.clientX}px,${e.clientY}px)`;
|
|
78
|
+
addEventListener("mousemove", place, true);
|
|
79
|
+
addEventListener("mousedown", () => cursor.style.scale = "0.7", true);
|
|
80
|
+
addEventListener("mouseup", () => cursor.style.scale = "1", true);
|
|
81
|
+
const attach = () => document.documentElement.append(cursor);
|
|
82
|
+
if (document.documentElement) attach();
|
|
83
|
+
else addEventListener("DOMContentLoaded", attach);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
export {
|
|
87
|
+
openInlineScenario,
|
|
88
|
+
openScenario,
|
|
89
|
+
showCursor,
|
|
90
|
+
unhandledRequests
|
|
91
|
+
};
|
|
92
|
+
//# sourceMappingURL=playwright.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/playwright.ts"],"sourcesContent":["import type { Page } from '@playwright/test';\nimport { DATA_KEY, PARAM, STRICT_PARAM } from './link.js';\nimport type { ScenarioInput } from './schema.js';\n\nexport interface OpenOptions {\n /** App path to open. Default \"/\". */\n path?: string;\n /** Fail unmocked requests instead of letting them reach a real backend. Default true. */\n strict?: boolean;\n /** Show the dev panel. False hides it, for screenshots that go into product docs. Default true. */\n panel?: boolean;\n}\n\n/**\n * Open the app in a catalog scenario and wait until the UI shows it: the scenario is\n * active and its `ready` selector, if any, is visible.\n */\nexport async function openScenario(page: Page, name: string, options: OpenOptions = {}) {\n await open(page, name, new URLSearchParams({ [PARAM]: name }), '', options);\n}\n\n/**\n * Like `openScenario`, for a scenario defined in the test rather than the catalog.\n * It opens as an inline link, so off localhost the app needs `startScenarios({ allowLinks: true })`.\n */\nexport async function openInlineScenario(page: Page, scenario: ScenarioInput, options: OpenOptions = {}) {\n const hash = `#${new URLSearchParams({ [DATA_KEY]: JSON.stringify(scenario) })}`;\n await open(page, scenario.name, new URLSearchParams(), hash, { path: scenario.path, ...options });\n}\n\nasync function open(\n page: Page,\n name: string,\n search: URLSearchParams,\n hash: string,\n { path = '/', strict = true, panel = true }: OpenOptions,\n) {\n // Relative to the test's baseURL; `//host`, `/\\host` or a full URL would open another site.\n if (!/^\\/(?![/\\\\])/.test(path)) {\n throw new Error(`[executable-stories-showcase] path \"${path}\" must be a path on the app, such as /orders/42`);\n }\n\n if (strict) search.set(STRICT_PARAM, '');\n await setPanel(page, panel);\n // Leave the page first: a hash-only change would not reload the app.\n await page.goto('about:blank');\n await page.goto(`${path}?${search}${hash}`);\n const html = page.locator(`html[data-scenario=\"${name}\"]`);\n await html.waitFor({ state: 'attached' });\n const ready = await html.getAttribute('data-scenario-ready');\n\n if (ready) await page.locator(ready).first().waitFor();\n}\n\ndeclare global {\n /** Exposed by `openScenario({ panel: false })`: whether this page should hide the panel. */\n var executableStoriesShowcasePanelHidden: (() => Promise<boolean>) | undefined;\n}\n\n// Per page: whether the panel is hidden. The init script asks on every load, so a later\n// open can show the panel again on any Playwright version.\nconst panelHidden = new WeakMap<Page, { hidden: boolean; }>();\n\nasync function setPanel(page: Page, show: boolean) {\n const state = panelHidden.get(page);\n\n if (state) {\n state.hidden = !show;\n\n return;\n }\n\n if (show) return;\n const created = { hidden: true };\n panelHidden.set(page, created);\n await page.exposeFunction('executableStoriesShowcasePanelHidden', () => created.hidden);\n // Hide before the app's scripts run, so the panel never shows, not even in a video;\n // then show it again if this page no longer wants it hidden.\n await page.addInitScript(() => {\n const style = document.createElement('style');\n style.textContent = 'scenario-panel { display: none !important; }';\n // Init scripts can run before <html> exists: attach as soon as it does.\n const attach = () => document.documentElement.append(style);\n\n if (document.documentElement) attach();\n else {\n new MutationObserver((_, observer) => {\n if (!document.documentElement) return;\n observer.disconnect();\n attach();\n }).observe(document, { childList: true });\n }\n\n void globalThis.executableStoriesShowcasePanelHidden?.().then((hidden) => {\n if (!hidden) style.remove();\n });\n });\n}\n\n/** Requests the active scenario didn't cover, e.g. to assert none in strict runs. */\nexport const unhandledRequests = (page: Page): Promise<string[]> =>\n page.evaluate(() => globalThis.executableStoriesShowcaseUnhandled ?? []);\n\n/**\n * Draw a mouse pointer that follows Playwright's mouse, since recorded videos don't show one.\n * It glides between points and pulses on click. Call before the page loads, e.g. before `openScenario`.\n */\nexport async function showCursor(page: Page) {\n await page.addInitScript(() => {\n const cursor = document.createElement('div');\n cursor.setAttribute('aria-hidden', 'true');\n cursor.style.cssText = [\n 'position:fixed',\n 'left:0',\n 'top:0',\n 'width:18px',\n 'height:18px',\n 'margin:-9px 0 0 -9px',\n 'border-radius:50%',\n 'background:rgba(20,20,20,.55)',\n 'border:2px solid #fff',\n 'box-shadow:0 1px 4px rgba(0,0,0,.4)',\n 'pointer-events:none',\n 'z-index:2147483647',\n 'transform:translate(-40px,-40px)',\n 'transition:transform .25s ease-out,scale .1s',\n ].join(';');\n\n const place = (e: MouseEvent) => (cursor.style.transform = `translate(${e.clientX}px,${e.clientY}px)`);\n addEventListener('mousemove', place, true);\n addEventListener('mousedown', () => (cursor.style.scale = '0.7'), true);\n addEventListener('mouseup', () => (cursor.style.scale = '1'), true);\n\n const attach = () => document.documentElement.append(cursor);\n\n if (document.documentElement) attach();\n else addEventListener('DOMContentLoaded', attach);\n });\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkReadySelector,
|
|
3
|
+
closestReadySelector,
|
|
4
|
+
selectorForElement,
|
|
5
|
+
suggestReadySelectors
|
|
6
|
+
} from "./chunk-XLNYICOI.js";
|
|
7
|
+
export {
|
|
8
|
+
checkReadySelector,
|
|
9
|
+
closestReadySelector,
|
|
10
|
+
selectorForElement,
|
|
11
|
+
suggestReadySelectors
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=ready-4MENAMNI.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/dist/ready.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Playwright selector for an element the user marked as the scenario's ready signal. */
|
|
2
|
+
export declare function selectorForElement(el: Element): string | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* The nearest element at or above `el` that has a ready selector, so clicking an icon or a
|
|
5
|
+
* wrapper still marks something.
|
|
6
|
+
*/
|
|
7
|
+
export declare function closestReadySelector(el: Element): {
|
|
8
|
+
element: Element;
|
|
9
|
+
selector: string;
|
|
10
|
+
} | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Ranked Playwright selectors for visible, distinctive UI, offered when the user
|
|
13
|
+
* hasn't marked ready. Prefers test ids, then named roles, then unique short text.
|
|
14
|
+
*/
|
|
15
|
+
export declare function suggestReadySelectors(root?: ParentNode | null, { limit }?: {
|
|
16
|
+
limit?: number;
|
|
17
|
+
}): string[];
|
|
18
|
+
export type ReadyCheck =
|
|
19
|
+
/** `first` is what `openScenario` waits on; `visible` says whether it's showing now. */
|
|
20
|
+
{
|
|
21
|
+
status: 'match';
|
|
22
|
+
count: number;
|
|
23
|
+
first: Element;
|
|
24
|
+
visible: boolean;
|
|
25
|
+
} | {
|
|
26
|
+
status: 'none';
|
|
27
|
+
}
|
|
28
|
+
/** A selector form this page can't evaluate (xpath, chains, regex…); Playwright still can. */
|
|
29
|
+
| {
|
|
30
|
+
status: 'unchecked';
|
|
31
|
+
} | {
|
|
32
|
+
status: 'invalid';
|
|
33
|
+
error: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Evaluate a ready selector against the live page the way Playwright would, for the forms
|
|
37
|
+
* the panel generates and plain CSS. Anything else comes back `unchecked`, never a guess.
|
|
38
|
+
*/
|
|
39
|
+
export declare function checkReadySelector(selector: string, doc?: Document): ReadyCheck;
|
package/dist/record.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Json, type NetworkEntry } from './schema.js';
|
|
2
|
+
/** One real response, placed by when its request started. */
|
|
3
|
+
export interface RecordedCall {
|
|
4
|
+
/** Request start order: responses can arrive in any order. */
|
|
5
|
+
order: number;
|
|
6
|
+
method: NetworkEntry['method'];
|
|
7
|
+
path: string;
|
|
8
|
+
query: Record<string, string>;
|
|
9
|
+
status: number;
|
|
10
|
+
body?: Json;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Records responses that reached the real network, from MSW's lifecycle events. Only JSON is
|
|
14
|
+
* kept: pages, scripts and other assets aren't app state.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createRecorder(): {
|
|
17
|
+
start(requestId: string): void;
|
|
18
|
+
/** A mocked response: nothing to record. */
|
|
19
|
+
drop(requestId: string): void;
|
|
20
|
+
bypass(requestId: string, request: Request, response: Response): Promise<void>;
|
|
21
|
+
/** Responses whose bodies have been read, as network entries. */
|
|
22
|
+
entries: () => {
|
|
23
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
|
|
24
|
+
path: string;
|
|
25
|
+
query?: Record<string, string> | undefined;
|
|
26
|
+
response?: {
|
|
27
|
+
status: number;
|
|
28
|
+
body?: import("zod").JSONType | undefined;
|
|
29
|
+
text?: string | undefined;
|
|
30
|
+
headers?: Record<string, string> | undefined;
|
|
31
|
+
delay?: number | "infinite" | undefined;
|
|
32
|
+
} | undefined;
|
|
33
|
+
sequence?: {
|
|
34
|
+
status: number;
|
|
35
|
+
body?: import("zod").JSONType | undefined;
|
|
36
|
+
text?: string | undefined;
|
|
37
|
+
headers?: Record<string, string> | undefined;
|
|
38
|
+
delay?: number | "infinite" | undefined;
|
|
39
|
+
}[] | undefined;
|
|
40
|
+
remove?: true | undefined;
|
|
41
|
+
}[];
|
|
42
|
+
/** Wait for bodies still being read. */
|
|
43
|
+
settled: () => Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Recorded calls as scenario network entries: repeated calls to one endpoint become a
|
|
47
|
+
* `sequence` in request order, or a single `response` when every call answered the same.
|
|
48
|
+
*/
|
|
49
|
+
export declare function toNetwork(calls: RecordedCall[]): NetworkEntry[];
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Json } from './schema.js';
|
|
2
|
+
/**
|
|
3
|
+
* Remove secrets from a recorded response body: strings under secret-like keys become
|
|
4
|
+
* "[redacted]" and numbers become 0, and signed JWTs anywhere lose their signature but keep
|
|
5
|
+
* their claims.
|
|
6
|
+
* Returns the paths it changed, e.g. `user.accessToken`.
|
|
7
|
+
*/
|
|
8
|
+
export interface Redacted {
|
|
9
|
+
value: Json;
|
|
10
|
+
/** Paths that changed, e.g. `user.accessToken`. */
|
|
11
|
+
redacted: string[];
|
|
12
|
+
}
|
|
13
|
+
export declare function redactSecrets(value: Json): Redacted;
|
|
14
|
+
/**
|
|
15
|
+
* Drops URL params whose value redaction would change (`?api_key=…`, `?token=…`), and OAuth's
|
|
16
|
+
* one-time `code`, `state` and `nonce`. Saved params are matched or replayed as written, so `"[redacted]"`
|
|
17
|
+
* would be wrong; without the param, a recorded request matches whatever value the app sends.
|
|
18
|
+
*/
|
|
19
|
+
export declare function withoutSecretParams(params: Record<string, string>, where: string): Record<string, string>;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { RequestHandler } from 'msw';
|
|
2
|
+
import type { SetupWorker } from 'msw/browser';
|
|
3
|
+
import type { z } from 'zod';
|
|
4
|
+
import { type Catalog, type ScenarioRule } from './catalog.js';
|
|
5
|
+
import { type Json, type NetworkEntry, type Scenario, type ScenarioInput, type Serializable } from './schema.js';
|
|
6
|
+
interface AdapterBase {
|
|
7
|
+
/** Key under `scenario.state` this adapter reads. */
|
|
8
|
+
key: string;
|
|
9
|
+
/** Current state as JSON, for the panel's live editor and "Save as scenario". */
|
|
10
|
+
read?(): Json;
|
|
11
|
+
/** Call `onChange` when the state changes; return an unsubscribe. */
|
|
12
|
+
subscribe?(onChange: () => void): () => void;
|
|
13
|
+
/** False when new state only takes effect on a reload, e.g. a snapshot an actor starts from. */
|
|
14
|
+
inPlace?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* False to apply this state from catalog scenarios only, and skip it for inline links.
|
|
17
|
+
* For state that outlives the scenario, such as a cookie the real backend reads.
|
|
18
|
+
*/
|
|
19
|
+
fromLinks?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** Decodes scenario JSON with `schema`, so `apply` receives typed state. */
|
|
22
|
+
export interface TypedAdapter<T> extends AdapterBase {
|
|
23
|
+
/** Also feeds `scenarioJsonSchema`. */
|
|
24
|
+
schema: z.ZodType<T>;
|
|
25
|
+
apply(state: T): void | Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
/** Receives the scenario's JSON as it is. */
|
|
28
|
+
export interface JsonAdapter extends AdapterBase {
|
|
29
|
+
schema?: undefined;
|
|
30
|
+
apply(state: Json): void | Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
/** Connects one slice of app state (a store, a cache, a reducer) to scenarios and the panel. */
|
|
33
|
+
export type Adapter<T = Json> = TypedAdapter<T> | JsonAdapter;
|
|
34
|
+
export interface Connection<T extends Serializable> {
|
|
35
|
+
/** Push the app's current state (for state the library can't `read`, e.g. useReducer). */
|
|
36
|
+
update(state: T): void;
|
|
37
|
+
disconnect(): void;
|
|
38
|
+
}
|
|
39
|
+
export interface StartOptions {
|
|
40
|
+
/** Scenario objects, e.g. `import.meta.glob<ScenarioInput>('./scenarios/*.json')`, or built in code. */
|
|
41
|
+
scenarios: ScenarioInput[];
|
|
42
|
+
/** Scenario to use when the URL names none. Omit to leave the app untouched. */
|
|
43
|
+
defaultScenario?: string;
|
|
44
|
+
/** Adapters available before render. Components can `connect` more later. */
|
|
45
|
+
adapters?: Adapter<unknown>[];
|
|
46
|
+
rules?: ScenarioRule[];
|
|
47
|
+
/**
|
|
48
|
+
* Open inline share links (`#scenario-data=...`). Anyone can write one, and it fakes API
|
|
49
|
+
* responses for whoever opens it, so by default they only open on localhost.
|
|
50
|
+
* Pass `true` on a staging or preview deploy that should open them; `false` refuses them everywhere.
|
|
51
|
+
*/
|
|
52
|
+
allowLinks?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Answer unmatched requests with 501 instead of passing them to the real network.
|
|
55
|
+
* Assets and page loads always pass. Also switched on by `?scenario-strict`.
|
|
56
|
+
*/
|
|
57
|
+
strict?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Let unmatched requests reach the real network and record their JSON responses, so
|
|
60
|
+
* "Save as scenario" can replay them offline. Also switched on by `?scenario-record`.
|
|
61
|
+
*/
|
|
62
|
+
record?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Requests that may reach the real network in strict mode (URL substring or RegExp),
|
|
65
|
+
* e.g. images from a CDN or a maps API the screen needs.
|
|
66
|
+
*/
|
|
67
|
+
allow?: (string | RegExp)[];
|
|
68
|
+
/**
|
|
69
|
+
* Requests the scenario doesn't care about, such as analytics, error reporting and captcha
|
|
70
|
+
* checks (URL substring or RegExp). They are never reported as unhandled, and in strict mode
|
|
71
|
+
* they get an empty 204 instead of a 501, so SDKs don't log errors or retry.
|
|
72
|
+
*/
|
|
73
|
+
ignore?: (string | RegExp)[];
|
|
74
|
+
/**
|
|
75
|
+
* MSW handlers a scenario can use by name with `"handlers": ["name"]`, e.g. the ones your
|
|
76
|
+
* unit tests already use. They answer after the scenario's `network` entries.
|
|
77
|
+
*/
|
|
78
|
+
handlers?: Record<string, RequestHandler | RequestHandler[]>;
|
|
79
|
+
/**
|
|
80
|
+
* Called after an in-place switch changes network fixtures, so the app refetches,
|
|
81
|
+
* e.g. `() => queryClient.resetQueries()`. Without it, such switches reload the page.
|
|
82
|
+
*/
|
|
83
|
+
refresh?: () => void | Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* The app's own MSW worker (or a `setupServer()` in tests), instead of starting one.
|
|
86
|
+
* Its handlers stay active beneath the scenario's, and are restored by `destroy()`.
|
|
87
|
+
* The app owns its lifecycle: start it before `startScenarios`.
|
|
88
|
+
*/
|
|
89
|
+
worker?: MswWorker;
|
|
90
|
+
/** Passed to `worker.start()` when executable-stories-showcase starts its own worker. */
|
|
91
|
+
workerOptions?: Parameters<SetupWorker['start']>[0];
|
|
92
|
+
}
|
|
93
|
+
/** The parts of an MSW worker or server executable-stories-showcase uses. `events` is needed to record. */
|
|
94
|
+
export type MswWorker = Pick<SetupWorker, 'resetHandlers' | 'listHandlers'> & {
|
|
95
|
+
events?: Pick<RecordingEvents, 'on' | 'removeListener'>;
|
|
96
|
+
};
|
|
97
|
+
type StartListener = (e: {
|
|
98
|
+
requestId: string;
|
|
99
|
+
}) => void;
|
|
100
|
+
type ResponseListener = (e: {
|
|
101
|
+
requestId: string;
|
|
102
|
+
request: Request;
|
|
103
|
+
response: Response;
|
|
104
|
+
}) => void;
|
|
105
|
+
/** MSW's lifecycle events that recording listens to, as both MSW 2 and 3 type them. */
|
|
106
|
+
interface RecordingEvents {
|
|
107
|
+
on(event: 'request:start', listener: StartListener): void;
|
|
108
|
+
on(event: 'response:mocked' | 'response:bypass', listener: ResponseListener): void;
|
|
109
|
+
removeListener(event: 'request:start', listener: StartListener): void;
|
|
110
|
+
removeListener(event: 'response:mocked' | 'response:bypass', listener: ResponseListener): void;
|
|
111
|
+
}
|
|
112
|
+
export interface ScenarioSession {
|
|
113
|
+
catalog: Catalog;
|
|
114
|
+
/** The scenario as authored (with `extends`), or null when none is active. */
|
|
115
|
+
active: Scenario | null;
|
|
116
|
+
/** The active scenario with `extends` flattened. */
|
|
117
|
+
resolved: Scenario | null;
|
|
118
|
+
strict: boolean;
|
|
119
|
+
/** Non-asset requests no scenario entry matched (live list). */
|
|
120
|
+
unhandled: string[];
|
|
121
|
+
/** Whether real responses are being recorded (`record` or `?scenario-record`). */
|
|
122
|
+
recording: boolean;
|
|
123
|
+
/** Real responses recorded so far, as network entries. Await `settleRecording()` first for all of them. */
|
|
124
|
+
recorded(): NetworkEntry[];
|
|
125
|
+
/** Resolves once every recorded response body has been read. */
|
|
126
|
+
settleRecording(): Promise<void>;
|
|
127
|
+
/** Keys of connected adapters. */
|
|
128
|
+
keys(): string[];
|
|
129
|
+
/** The connected adapter's current state, as JSON. */
|
|
130
|
+
read(key: string): Json | undefined;
|
|
131
|
+
/** Validate and apply state to a connected adapter now, without changing scenario. */
|
|
132
|
+
applyState(key: string, state: Json): Promise<void>;
|
|
133
|
+
/** Connect an adapter; the active scenario's state for its key is applied at once. */
|
|
134
|
+
connect<T extends Serializable>(adapter: Adapter<T>): Connection<T>;
|
|
135
|
+
/**
|
|
136
|
+
* Switch to a catalog scenario: in place when only connected state (or network, with
|
|
137
|
+
* `refresh`) changes, otherwise by reloading. Names combine: "signed-in,server-error".
|
|
138
|
+
*/
|
|
139
|
+
open(names: string): Promise<void>;
|
|
140
|
+
/** Switch to a scenario that isn't in the catalog, e.g. one edited in the panel. */
|
|
141
|
+
openInline(scenario: Scenario): Promise<void>;
|
|
142
|
+
onChange(listener: () => void): () => void;
|
|
143
|
+
/**
|
|
144
|
+
* Tear down: unsubscribe adapters, drop listeners and queued switches, and stop the
|
|
145
|
+
* worker this session started (or restore the app's own worker's handlers).
|
|
146
|
+
* For HMR and unmounting. The page keeps whatever state it shows.
|
|
147
|
+
*/
|
|
148
|
+
destroy(): void;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* What the CLI's `shoot`, `doctor` and `mcp` call inside the page, as JSON in and out.
|
|
152
|
+
* The modules behind it load on first call, so apps that never use it don't pay for it.
|
|
153
|
+
*/
|
|
154
|
+
export interface PageHooks {
|
|
155
|
+
/** The active scenario's name, or null. */
|
|
156
|
+
scenario(): string | null;
|
|
157
|
+
/** Whether a ready selector matches now, plus selectors that would. */
|
|
158
|
+
checkReady(selector: string): Promise<ReadyReport>;
|
|
159
|
+
/** The app's current state as a catalog file, after recorded responses settle. */
|
|
160
|
+
capture(options: {
|
|
161
|
+
name: string;
|
|
162
|
+
description?: string;
|
|
163
|
+
ready?: string;
|
|
164
|
+
extends?: string;
|
|
165
|
+
}): Promise<ScenarioInput>;
|
|
166
|
+
}
|
|
167
|
+
export interface ReadyReport {
|
|
168
|
+
status: 'match' | 'none' | 'unchecked' | 'invalid';
|
|
169
|
+
count?: number;
|
|
170
|
+
visible?: boolean;
|
|
171
|
+
error?: string;
|
|
172
|
+
/** Distinctive selectors on the page now, best first. */
|
|
173
|
+
suggestions: string[];
|
|
174
|
+
}
|
|
175
|
+
declare global {
|
|
176
|
+
/** Set by `startScenarios` for the CLI and agents; see `PageHooks`. */
|
|
177
|
+
var executableStoriesShowcasePage: PageHooks | undefined;
|
|
178
|
+
}
|
|
179
|
+
/** The session started by `startScenarios`, if any (null in production builds that skip it). */
|
|
180
|
+
export declare const currentSession: () => ScenarioSession | null;
|
|
181
|
+
/**
|
|
182
|
+
* Read the scenario from the URL (or the tab's last one), mock the network if the
|
|
183
|
+
* scenario needs it, apply URL and adapter state. Await this before rendering the app.
|
|
184
|
+
*/
|
|
185
|
+
export declare function startScenarios(options: StartOptions): Promise<ScenarioSession>;
|
|
186
|
+
/**
|
|
187
|
+
* Go to a scenario link, reloading even when only the hash changes. Scenario links stay on the
|
|
188
|
+
* app, so another origin (or a `javascript:` URL, whose origin is `null`) is refused.
|
|
189
|
+
*/
|
|
190
|
+
export declare function navigate(href: string): void;
|
|
191
|
+
export {};
|
package/dist/save.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
+
export interface SaveTarget {
|
|
3
|
+
/** Absolute folder scenarios are written into. */
|
|
4
|
+
dir: string;
|
|
5
|
+
/** Absolute project root; replies show paths relative to it. */
|
|
6
|
+
root: string;
|
|
7
|
+
/** Page origins allowed to save cross-origin. Without it, only same-origin requests may. */
|
|
8
|
+
origins?: string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* GET reports the folder; POST `{ scenario, overwrite? }` writes `<dir>/<name>.json`.
|
|
12
|
+
* JSON from an allowed origin on a loopback host only, schema-checked, and never replaces a
|
|
13
|
+
* file unless `overwrite` is set.
|
|
14
|
+
*/
|
|
15
|
+
export declare function handleSaveRequest(request: Request, { dir, root, origins }: SaveTarget): Promise<Response>;
|
|
16
|
+
/**
|
|
17
|
+
* The save endpoint as Node middleware. Headers can be forged by any client and the socket
|
|
18
|
+
* can't, so only connections from this machine reach the handler.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createSaveHandler(target: SaveTarget): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Start a save server on 127.0.0.1, unreachable from other machines whatever the app server
|
|
23
|
+
* listens on. Resolves to its save URL.
|
|
24
|
+
*/
|
|
25
|
+
export declare function startSaveServer(target: SaveTarget): Promise<string>;
|
|
26
|
+
/** True for a loopback peer address, as Node reports it in `socket.remoteAddress`. */
|
|
27
|
+
export declare function isLoopbackAddress(address: string | undefined): boolean;
|