state-scenarios 0.2.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 (43) hide show
  1. package/dist/adapters/redux.js +2 -2
  2. package/dist/adapters/tanstack-query.js +1 -1
  3. package/dist/adapters/xstate.js +2 -2
  4. package/dist/adapters/zustand.js +1 -1
  5. package/dist/capture.d.ts +26 -0
  6. package/dist/catalog.d.ts +6 -0
  7. package/dist/{chunk-KRCYO64S.js → chunk-3M6SD4TA.js} +5 -1
  8. package/dist/chunk-3M6SD4TA.js.map +1 -0
  9. package/dist/{chunk-6L55WKFS.js → chunk-DCURPYW5.js} +4 -2
  10. package/dist/chunk-DCURPYW5.js.map +1 -0
  11. package/dist/chunk-FTWYRB6F.js +277 -0
  12. package/dist/chunk-FTWYRB6F.js.map +1 -0
  13. package/dist/chunk-HEVJQ47T.js +148 -0
  14. package/dist/chunk-HEVJQ47T.js.map +1 -0
  15. package/dist/chunk-KYU7RUWI.js +179 -0
  16. package/dist/chunk-KYU7RUWI.js.map +1 -0
  17. package/dist/index.d.ts +4 -2
  18. package/dist/index.js +133 -11
  19. package/dist/index.js.map +1 -1
  20. package/dist/link.d.ts +4 -0
  21. package/dist/next.d.ts +21 -0
  22. package/dist/next.js +30 -0
  23. package/dist/next.js.map +1 -0
  24. package/dist/no-msw-browser.d.ts +2 -0
  25. package/dist/no-msw-browser.js +8 -0
  26. package/dist/no-msw-browser.js.map +1 -0
  27. package/dist/panel.d.ts +10 -8
  28. package/dist/panel.js +284 -40
  29. package/dist/panel.js.map +1 -1
  30. package/dist/playwright.js +1 -1
  31. package/dist/ready.d.ts +39 -0
  32. package/dist/record.d.ts +49 -0
  33. package/dist/runtime.d.ts +31 -3
  34. package/dist/save.d.ts +27 -0
  35. package/dist/schema.d.ts +9 -0
  36. package/dist/vite.d.ts +11 -0
  37. package/dist/vite.js +26 -0
  38. package/dist/vite.js.map +1 -0
  39. package/package.json +27 -3
  40. package/dist/chunk-6L55WKFS.js.map +0 -1
  41. package/dist/chunk-KDH572NI.js +0 -88
  42. package/dist/chunk-KDH572NI.js.map +0 -1
  43. package/dist/chunk-KRCYO64S.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/save.ts"],"sourcesContent":["import { existsSync, mkdirSync, writeFileSync } from 'node:fs';\nimport { createServer, type IncomingMessage, type ServerResponse } from 'node:http';\nimport { join, relative } from 'node:path';\nimport { z } from 'zod';\nimport { SAVE_ENDPOINT } from './link.js';\nimport { JsonSchema, ScenarioSchema } from './schema.js';\n\n// The panel's \"Save to project\" endpoint, shared by the Vite plugin and the Next.js route.\n\nconst MAX_BODY = 20 * 1024 * 1024;\n\nconst SaveRequestSchema = z.object({ scenario: JsonSchema, overwrite: z.boolean().optional() });\n\ninterface SaveReply {\n error?: string;\n /** Saved file, relative to the project root. */\n path?: string;\n /** Saved file, absolute. */\n file?: string;\n /** Scenarios folder, relative to the project root. */\n dir?: string;\n}\n\nexport interface SaveTarget {\n /** Absolute folder scenarios are written into. */\n dir: string;\n /** Absolute project root; replies show paths relative to it. */\n root: string;\n /** Page origins allowed to save cross-origin. Without it, only same-origin requests may. */\n origins?: string[];\n}\n\n/**\n * GET reports the folder; POST `{ scenario, overwrite? }` writes `<dir>/<name>.json`.\n * JSON from an allowed origin on a loopback host only, schema-checked, and never replaces a\n * file unless `overwrite` is set.\n */\nexport async function handleSaveRequest(\n request: Request,\n { dir, root, origins }: SaveTarget,\n): Promise<Response> {\n const display = (file: string) => relative(root, file).replaceAll('\\\\', '/');\n const reply = (status: number, body: SaveReply) => Response.json(body, { status });\n\n if (!allowedOrigin(request.headers, origins)) {\n return reply(403, { error: 'Save refused: only the app on localhost may write files' });\n }\n\n if (request.method === 'GET') return reply(200, { dir: display(dir) });\n\n if (request.method !== 'POST') return reply(405, { error: 'Use GET or POST' });\n\n // A JSON content type also forces a CORS preflight, which only allowed origins pass.\n if (!request.headers.get('content-type')?.startsWith('application/json')) {\n return reply(415, { error: 'Send application/json' });\n }\n\n let body: z.output<typeof SaveRequestSchema>;\n\n try {\n body = SaveRequestSchema.parse(JSON.parse(await readCapped(request)));\n } catch (err) {\n return reply(400, { error: err instanceof z.ZodError ? z.prettifyError(err) : String(err) });\n }\n\n // The schema's kebab-case name rule also keeps the file inside `dir`.\n const parsed = ScenarioSchema.safeParse(body.scenario);\n\n if (!parsed.success) return reply(400, { error: z.prettifyError(parsed.error) });\n\n const { name } = parsed.data;\n\n if (name.includes(',')) return reply(400, { error: \"A saved scenario name can't contain a comma\" });\n\n const file = join(dir, `${name}.json`);\n\n if (existsSync(file) && !body.overwrite) {\n return reply(409, { error: `${display(file)} already exists`, path: display(file) });\n }\n\n mkdirSync(dir, { recursive: true });\n // Write what the panel sent, not the parsed form, so defaults don't bloat the file.\n writeFileSync(file, `${JSON.stringify(body.scenario, null, 2)}\\n`);\n\n return reply(200, { path: display(file), file });\n}\n\n/**\n * The save endpoint as Node middleware. Headers can be forged by any client and the socket\n * can't, so only connections from this machine reach the handler.\n */\nexport function createSaveHandler(target: SaveTarget) {\n return async (req: IncomingMessage, res: ServerResponse) => {\n const origin = req.headers.origin;\n\n // Cross-origin saves (the Next.js save server) need CORS for the origins the target allows.\n if (origin && target.origins?.includes(origin)) {\n res.setHeader('access-control-allow-origin', origin);\n res.setHeader('vary', 'origin');\n }\n\n if (req.method === 'OPTIONS') {\n res.setHeader('access-control-allow-methods', 'GET, POST');\n res.setHeader('access-control-allow-headers', 'content-type');\n res.statusCode = 204;\n res.end();\n\n return;\n }\n\n let response: Response;\n\n try {\n response = isLoopbackAddress(req.socket.remoteAddress)\n ? await handleSaveRequest(toRequest(req), target)\n : Response.json({ error: 'Save refused: only this machine may write files' }, { status: 403 });\n } catch (err) {\n // A bad Host header or a dropped connection: answer rather than leave the request hanging.\n response = Response.json({ error: String(err) }, { status: 500 });\n }\n\n res.statusCode = response.status;\n response.headers.forEach((value, key) => res.setHeader(key, value));\n res.end(await response.text());\n };\n}\n\n/**\n * Start a save server on 127.0.0.1, unreachable from other machines whatever the app server\n * listens on. Resolves to its save URL.\n */\nexport function startSaveServer(target: SaveTarget): Promise<string> {\n const handle = createSaveHandler(target);\n const server = createServer((req, res) => void handle(req, res));\n\n return new Promise((done, fail) => {\n server.once('error', fail);\n server.listen(0, '127.0.0.1', () => {\n const { port } = z.object({ port: z.number() }).parse(server.address());\n server.unref(); // never keeps the dev process alive on its own\n done(`http://127.0.0.1:${port}${SAVE_ENDPOINT}`);\n });\n });\n}\n\n/** True for a loopback peer address, as Node reports it in `socket.remoteAddress`. */\nexport function isLoopbackAddress(address: string | undefined): boolean {\n if (!address) return false;\n const ip = address.replace(/^::ffff:/, '');\n\n return ip === '::1' || /^127(\\.\\d{1,3}){3}$/.test(ip);\n}\n\nfunction allowedOrigin(headers: Headers, origins: string[] | undefined): boolean {\n const host = headers.get('host') ?? '';\n\n // DNS rebinding points an attacker's domain at 127.0.0.1, making its requests \"same-origin\";\n // the Host header still names that domain, so only loopback hosts may write.\n if (!isLoopbackHost(host)) return false;\n\n const origin = headers.get('origin');\n\n // No Origin: same-origin GET, curl or a test. Browsers always send it on cross-origin requests.\n if (!origin) return true;\n\n if (origins) return origins.includes(origin);\n\n try {\n return new URL(origin).host === host;\n } catch {\n return false; // \"null\" from sandboxed frames and file:// pages\n }\n}\n\nfunction isLoopbackHost(host: string): boolean {\n let hostname: string;\n\n try {\n hostname = new URL(`http://${host}`).hostname;\n } catch {\n return false;\n }\n\n return hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '[::1]'\n || /^127(\\.\\d{1,3}){3}$/.test(hostname);\n}\n\nfunction toRequest(req: IncomingMessage): Request {\n const headers = new Headers();\n\n for (let i = 0; i < req.rawHeaders.length; i += 2) headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);\n\n const method = req.method ?? 'GET';\n\n // Streamed, so the handler can refuse a request before reading (or capping) its body.\n const body = method === 'GET' || method === 'HEAD' ? undefined : new ReadableStream<Uint8Array>({\n start(controller) {\n req.on('data', (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));\n req.on('end', () => controller.close());\n req.on('error', (err) => controller.error(err));\n },\n cancel() {\n req.destroy();\n },\n });\n\n const init: RequestInit & { duplex: 'half'; } = { method, headers, body, duplex: 'half' };\n\n return new Request(new URL(req.url ?? '/', `http://${headers.get('host') ?? 'localhost'}`), init);\n}\n\nasync function readCapped(request: Request): Promise<string> {\n const reader = request.body?.getReader();\n\n if (!reader) return '';\n\n const chunks: Uint8Array[] = [];\n let size = 0;\n\n for (;;) {\n const { done, value } = await reader.read();\n\n if (done) break;\n size += value.length;\n\n if (size > MAX_BODY) {\n await reader.cancel();\n throw new Error('Scenario too large');\n }\n\n chunks.push(value);\n }\n\n return Buffer.concat(chunks).toString('utf8');\n}\n"],"mappings":"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","names":["file","body"]}
@@ -0,0 +1,179 @@
1
+ import {
2
+ parseScenario
3
+ } from "./chunk-DCURPYW5.js";
4
+
5
+ // src/catalog.ts
6
+ var defineScenarioRules = (rules) => rules;
7
+ function createCatalog(raw, { rules = [] } = {}) {
8
+ const byName = /* @__PURE__ */ new Map();
9
+ for (const [i, data] of raw.entries()) {
10
+ const s = parseScenario(data, `#${i}`);
11
+ if (s.name.includes(",")) throw new Error(`Scenario name "${s.name}" can't contain a comma`);
12
+ if (byName.has(s.name)) throw new Error(`Duplicate scenario name "${s.name}"`);
13
+ byName.set(s.name, s);
14
+ }
15
+ const flatten = (s, seen) => {
16
+ if (seen.includes(s.name)) {
17
+ throw new Error(`Circular extends: ${[...seen, s.name].join(" -> ")}`);
18
+ }
19
+ const parents = [s.extends ?? []].flat().map((name) => {
20
+ const parent = byName.get(name);
21
+ if (!parent) throw new Error(`Scenario "${s.name}" extends unknown "${name}"`);
22
+ return flatten(parent, [...seen, s.name]);
23
+ });
24
+ const { extends: _extends, $schema: _schema, ...own } = s;
25
+ return [...parents, own].reduce(merge, { name: own.name });
26
+ };
27
+ const resolve = (s) => {
28
+ const flat = flatten(s, []);
29
+ const violations = rules.flatMap((rule) => rule(flat) ?? []);
30
+ if (violations.length) {
31
+ throw new Error(`Scenario "${s.name}" breaks rules:
32
+ ${violations.map((m) => `- ${m}`).join("\n")}`);
33
+ }
34
+ return flat;
35
+ };
36
+ return {
37
+ list: [...byName.values()],
38
+ get: (name) => byName.get(name),
39
+ resolve,
40
+ check: () => [...byName.values()].flatMap((s) => {
41
+ try {
42
+ resolve(s);
43
+ return [];
44
+ } catch (err) {
45
+ return [err instanceof Error ? err.message : String(err)];
46
+ }
47
+ })
48
+ };
49
+ }
50
+ var byKey = ([a], [b]) => a.localeCompare(b);
51
+ var routeKey = (e) => `${e.method} ${e.path} ${JSON.stringify(Object.entries(e.query ?? {}).toSorted(byKey))}`;
52
+ function merge(base, over) {
53
+ const overKeys = new Set((over.network ?? []).map(routeKey));
54
+ return {
55
+ ...base,
56
+ ...over,
57
+ // Own-only fields: a child describes and finishes rendering differently.
58
+ description: over.description,
59
+ tags: over.tags,
60
+ ready: over.ready,
61
+ // A null param removes the inherited one.
62
+ url: Object.fromEntries(Object.entries({ ...base.url, ...over.url }).filter(([, v]) => v !== null)),
63
+ network: [
64
+ ...(over.network ?? []).filter((e) => !e.remove),
65
+ ...(base.network ?? []).filter((e) => !overKeys.has(routeKey(e)))
66
+ ],
67
+ state: mergeObjects(base.state ?? {}, over.state ?? {})
68
+ };
69
+ }
70
+ var isJsonObject = (value) => value instanceof Object && !Array.isArray(value);
71
+ function mergeObjects(base, over) {
72
+ const out = { ...base };
73
+ for (const [k, v] of Object.entries(over)) {
74
+ if (v === null) delete out[k];
75
+ else out[k] = deepMerge(base[k], v);
76
+ }
77
+ return out;
78
+ }
79
+ function deepMerge(base, over) {
80
+ return isJsonObject(base) && isJsonObject(over) ? mergeObjects(base, over) : over;
81
+ }
82
+ function diffScenario(captured, base) {
83
+ if (!base) {
84
+ return compactScenario({
85
+ name: captured.name,
86
+ description: captured.description,
87
+ tags: captured.tags,
88
+ path: captured.path,
89
+ url: captured.url,
90
+ network: captured.network,
91
+ state: captured.state,
92
+ ready: captured.ready
93
+ });
94
+ }
95
+ const url = diffUrl(base.url, captured.url);
96
+ const network = diffNetwork(base.network ?? [], captured.network ?? []);
97
+ const state = diffObjects(base.state ?? {}, captured.state ?? {});
98
+ return compactScenario({
99
+ name: captured.name,
100
+ extends: base.name,
101
+ description: captured.description,
102
+ tags: captured.tags,
103
+ path: captured.path !== base.path ? captured.path : void 0,
104
+ url,
105
+ network: network.length ? network : void 0,
106
+ state,
107
+ ready: captured.ready
108
+ });
109
+ }
110
+ function diffUrl(base, captured) {
111
+ const baseUrl = base ?? {};
112
+ const capturedUrl = captured ?? {};
113
+ const out = {};
114
+ for (const [k, v] of Object.entries(capturedUrl)) {
115
+ if (v === null) continue;
116
+ if (baseUrl[k] !== v) out[k] = v;
117
+ }
118
+ for (const [k, v] of Object.entries(baseUrl)) {
119
+ if (v === null) continue;
120
+ if (!(k in capturedUrl) || capturedUrl[k] === null) out[k] = null;
121
+ }
122
+ return Object.keys(out).length ? out : void 0;
123
+ }
124
+ function diffNetwork(base, captured) {
125
+ const baseByKey = new Map(base.map((e) => [routeKey(e), e]));
126
+ const capByKey = /* @__PURE__ */ new Map();
127
+ for (const e of captured) if (!capByKey.has(routeKey(e))) capByKey.set(routeKey(e), e);
128
+ const out = [];
129
+ for (const [key, e] of capByKey) {
130
+ const prior = baseByKey.get(key);
131
+ if (!prior || !jsonEqual(prior, e)) out.push(e);
132
+ }
133
+ for (const [key, e] of baseByKey) {
134
+ if (!capByKey.has(key)) {
135
+ const removed = { method: e.method, path: e.path, remove: true };
136
+ if (e.query) removed.query = e.query;
137
+ out.push(removed);
138
+ }
139
+ }
140
+ return out;
141
+ }
142
+ function diffObjects(base, captured) {
143
+ const out = {};
144
+ for (const [k, v] of Object.entries(captured)) {
145
+ if (!(k in base)) out[k] = v;
146
+ else if (isJsonObject(base[k]) && isJsonObject(v)) {
147
+ const nested = diffObjects(base[k], v);
148
+ if (nested) out[k] = nested;
149
+ } else if (!jsonEqual(base[k], v)) out[k] = v;
150
+ }
151
+ for (const k of Object.keys(base)) {
152
+ if (!(k in captured)) out[k] = null;
153
+ }
154
+ return Object.keys(out).length ? out : void 0;
155
+ }
156
+ function jsonEqual(a, b) {
157
+ return JSON.stringify(a) === JSON.stringify(b);
158
+ }
159
+ function compactScenario(s) {
160
+ const out = { name: s.name };
161
+ if (s.$schema !== void 0) out.$schema = s.$schema;
162
+ if (s.description !== void 0) out.description = s.description;
163
+ if (s.tags !== void 0) out.tags = s.tags;
164
+ if (s.extends !== void 0) out.extends = s.extends;
165
+ if (s.path !== void 0) out.path = s.path;
166
+ if (s.url !== void 0 && Object.keys(s.url).length) out.url = s.url;
167
+ if (s.network !== void 0 && s.network.length) out.network = s.network;
168
+ if (s.state !== void 0 && Object.keys(s.state).length) out.state = s.state;
169
+ if (s.ready !== void 0) out.ready = s.ready;
170
+ return out;
171
+ }
172
+
173
+ export {
174
+ defineScenarioRules,
175
+ createCatalog,
176
+ deepMerge,
177
+ diffScenario
178
+ };
179
+ //# sourceMappingURL=chunk-KYU7RUWI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/catalog.ts"],"sourcesContent":["import {\n type Json,\n type JsonObject,\n type NetworkEntry,\n parseScenario,\n type Scenario,\n type ScenarioInput,\n} from './schema.js';\n\n/**\n * A business rule a resolved scenario must satisfy, beyond schema shape.\n * Return a message (or several) for each violation.\n */\nexport type ScenarioRule = (scenario: Scenario) => string | string[] | void | undefined;\n\n/** Identity helper for typing rule lists. */\nexport const defineScenarioRules = (rules: ScenarioRule[]) => rules;\n\nexport interface Catalog {\n list: Scenario[];\n get(name: string): Scenario | undefined;\n /** Flatten `extends` into one self-contained scenario, then check the rules. */\n resolve(scenario: Scenario): Scenario;\n /** Resolve every scenario; returns one message per problem. */\n check(): string[];\n}\n\nexport function createCatalog(\n raw: ScenarioInput[],\n { rules = [] }: { rules?: ScenarioRule[]; } = {},\n): Catalog {\n const byName = new Map<string, Scenario>();\n\n for (const [i, data] of raw.entries()) {\n const s = parseScenario(data, `#${i}`);\n\n // Commas join names in ?scenario=a,b, so a catalog name can't contain one.\n if (s.name.includes(',')) throw new Error(`Scenario name \"${s.name}\" can't contain a comma`);\n\n if (byName.has(s.name)) throw new Error(`Duplicate scenario name \"${s.name}\"`);\n byName.set(s.name, s);\n }\n\n const flatten = (s: Scenario, seen: string[]): Scenario => {\n if (seen.includes(s.name)) {\n throw new Error(`Circular extends: ${[...seen, s.name].join(' -> ')}`);\n }\n\n const parents = [s.extends ?? []].flat().map((name) => {\n const parent = byName.get(name);\n\n if (!parent) throw new Error(`Scenario \"${s.name}\" extends unknown \"${name}\"`);\n\n return flatten(parent, [...seen, s.name]);\n });\n\n const { extends: _extends, $schema: _schema, ...own } = s;\n\n // Start from an empty base so standalone scenarios are normalised too (remove entries, nulls).\n return [...parents, own].reduce(merge, { name: own.name });\n };\n\n const resolve = (s: Scenario) => {\n const flat = flatten(s, []);\n const violations = rules.flatMap((rule) => rule(flat) ?? []);\n\n if (violations.length) {\n throw new Error(`Scenario \"${s.name}\" breaks rules:\\n${violations.map((m) => `- ${m}`).join('\\n')}`);\n }\n\n return flat;\n };\n\n return {\n list: [...byName.values()],\n get: (name) => byName.get(name),\n resolve,\n check: () =>\n [...byName.values()].flatMap((s) => {\n try {\n resolve(s);\n\n return [];\n } catch (err) {\n return [err instanceof Error ? err.message : String(err)];\n }\n }),\n };\n}\n\nconst byKey = ([a]: [string, unknown], [b]: [string, unknown]) => a.localeCompare(b);\n\nconst routeKey = (e: NetworkEntry) =>\n `${e.method} ${e.path} ${JSON.stringify(Object.entries(e.query ?? {}).toSorted(byKey))}`;\n\nfunction merge(base: Scenario, over: Scenario): Scenario {\n const overKeys = new Set((over.network ?? []).map(routeKey));\n\n return {\n ...base,\n ...over,\n // Own-only fields: a child describes and finishes rendering differently.\n description: over.description,\n tags: over.tags,\n ready: over.ready,\n // A null param removes the inherited one.\n url: Object.fromEntries(Object.entries({ ...base.url, ...over.url }).filter(([, v]) => v !== null)),\n network: [\n ...(over.network ?? []).filter((e) => !e.remove),\n ...(base.network ?? []).filter((e) => !overKeys.has(routeKey(e))),\n ],\n state: mergeObjects(base.state ?? {}, over.state ?? {}),\n };\n}\n\nconst isJsonObject = (value: Json | undefined): value is JsonObject =>\n value instanceof Object && !Array.isArray(value);\n\nfunction mergeObjects(base: JsonObject, over: JsonObject): JsonObject {\n const out = { ...base };\n\n for (const [k, v] of Object.entries(over)) {\n if (v === null) delete out[k];\n else out[k] = deepMerge(base[k], v);\n }\n\n return out;\n}\n\n/** Objects merge recursively, `null` deletes a key, anything else replaces. */\nexport function deepMerge(base: Json | undefined, over: Json): Json {\n return isJsonObject(base) && isJsonObject(over) ? mergeObjects(base, over) : over;\n}\n\n/**\n * Inverse of `extends` merge: overrides that turn a resolved `base` into `captured`.\n * Pass the flattened base (from `catalog.resolve`). Copy-link keeps `captured` as-is;\n * Download JSON uses this so the file can `extends` the base instead of repeating it.\n */\nexport function diffScenario(captured: Scenario, base?: Scenario): ScenarioInput {\n if (!base) {\n return compactScenario({\n name: captured.name,\n description: captured.description,\n tags: captured.tags,\n path: captured.path,\n url: captured.url,\n network: captured.network,\n state: captured.state,\n ready: captured.ready,\n });\n }\n\n const url = diffUrl(base.url, captured.url);\n const network = diffNetwork(base.network ?? [], captured.network ?? []);\n const state = diffObjects(base.state ?? {}, captured.state ?? {});\n\n return compactScenario({\n name: captured.name,\n extends: base.name,\n description: captured.description,\n tags: captured.tags,\n path: captured.path !== base.path ? captured.path : undefined,\n url,\n network: network.length ? network : undefined,\n state,\n ready: captured.ready,\n });\n}\n\nfunction diffUrl(\n base: Scenario['url'],\n captured: Scenario['url'],\n): ScenarioInput['url'] {\n const baseUrl = base ?? {};\n const capturedUrl = captured ?? {};\n const out: Record<string, string | null> = {};\n\n for (const [k, v] of Object.entries(capturedUrl)) {\n if (v === null) continue;\n\n if (baseUrl[k] !== v) out[k] = v;\n }\n\n for (const [k, v] of Object.entries(baseUrl)) {\n if (v === null) continue;\n\n if (!(k in capturedUrl) || capturedUrl[k] === null) out[k] = null;\n }\n\n return Object.keys(out).length ? out : undefined;\n}\n\nfunction diffNetwork(base: NetworkEntry[], captured: NetworkEntry[]): NetworkEntry[] {\n const baseByKey = new Map(base.map((e) => [routeKey(e), e]));\n const capByKey = new Map<string, NetworkEntry>();\n\n // First match wins in MSW, so a later duplicate route is unreachable; keep the first.\n for (const e of captured) if (!capByKey.has(routeKey(e))) capByKey.set(routeKey(e), e);\n\n const out: NetworkEntry[] = [];\n\n for (const [key, e] of capByKey) {\n const prior = baseByKey.get(key);\n\n if (!prior || !jsonEqual(prior, e)) out.push(e);\n }\n\n for (const [key, e] of baseByKey) {\n if (!capByKey.has(key)) {\n const removed: NetworkEntry = { method: e.method, path: e.path, remove: true };\n\n if (e.query) removed.query = e.query;\n\n out.push(removed);\n }\n }\n\n return out;\n}\n\nfunction diffObjects(base: JsonObject, captured: JsonObject): JsonObject | undefined {\n const out: JsonObject = {};\n\n for (const [k, v] of Object.entries(captured)) {\n if (!(k in base)) out[k] = v;\n else if (isJsonObject(base[k]) && isJsonObject(v)) {\n const nested = diffObjects(base[k], v);\n\n if (nested) out[k] = nested;\n } else if (!jsonEqual(base[k], v)) out[k] = v;\n }\n\n for (const k of Object.keys(base)) {\n if (!(k in captured)) out[k] = null;\n }\n\n return Object.keys(out).length ? out : undefined;\n}\n\nfunction jsonEqual(a: Json | NetworkEntry, b: Json | NetworkEntry): boolean {\n return JSON.stringify(a) === JSON.stringify(b);\n}\n\nfunction compactScenario(s: ScenarioInput): ScenarioInput {\n const out: ScenarioInput = { name: s.name };\n\n if (s.$schema !== undefined) out.$schema = s.$schema;\n\n if (s.description !== undefined) out.description = s.description;\n\n if (s.tags !== undefined) out.tags = s.tags;\n\n if (s.extends !== undefined) out.extends = s.extends;\n\n if (s.path !== undefined) out.path = s.path;\n\n if (s.url !== undefined && Object.keys(s.url).length) out.url = s.url;\n\n if (s.network !== undefined && s.network.length) out.network = s.network;\n\n if (s.state !== undefined && Object.keys(s.state).length) out.state = s.state;\n\n if (s.ready !== undefined) out.ready = s.ready;\n\n return out;\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
- export { type Catalog, createCatalog, deepMerge, defineScenarioRules, type ScenarioRule } from './catalog.js';
2
- export { DATA_KEY, PARAM, readInline, scenarioHref, STRICT_PARAM } from './link.js';
1
+ export { type CapturedScenario, type CaptureOptions, captureScenario } from './capture.js';
2
+ export { type Catalog, createCatalog, deepMerge, defineScenarioRules, diffScenario, type ScenarioRule, } from './catalog.js';
3
+ export { DATA_KEY, PARAM, readInline, RECORD_PARAM, scenarioHref, STRICT_PARAM } from './link.js';
3
4
  export { toHandlers } from './network.js';
5
+ export { checkReadySelector, type ReadyCheck, selectorForElement, suggestReadySelectors } from './ready.js';
4
6
  export { type Adapter, type Connection, currentSession, type JsonAdapter, type MswWorker, navigate, type ScenarioSession, type StartOptions, startScenarios, type TypedAdapter, } from './runtime.js';
5
7
  export { type Json, type JsonObject, JsonSchema, type NetworkEntry, parseJson, parseScenario, type Scenario, type ScenarioInput, scenarioJsonSchema, ScenarioSchema, type Serializable, } from './schema.js';
package/dist/index.js CHANGED
@@ -1,25 +1,102 @@
1
1
  import {
2
2
  toHandlers
3
3
  } from "./chunk-U3I7ORTB.js";
4
+ import {
5
+ captureScenario,
6
+ checkReadySelector,
7
+ selectorForElement,
8
+ suggestReadySelectors
9
+ } from "./chunk-FTWYRB6F.js";
4
10
  import {
5
11
  DATA_KEY,
6
12
  PARAM,
13
+ RECORD_PARAM,
14
+ SAVE_ENDPOINT,
7
15
  STRICT_PARAM,
8
16
  readInline,
9
17
  scenarioHref
10
- } from "./chunk-KRCYO64S.js";
18
+ } from "./chunk-3M6SD4TA.js";
11
19
  import {
12
20
  createCatalog,
13
21
  deepMerge,
14
- defineScenarioRules
15
- } from "./chunk-KDH572NI.js";
22
+ defineScenarioRules,
23
+ diffScenario
24
+ } from "./chunk-KYU7RUWI.js";
16
25
  import {
17
26
  JsonSchema,
27
+ MethodSchema,
18
28
  ScenarioSchema,
19
29
  parseJson,
20
30
  parseScenario,
21
31
  scenarioJsonSchema
22
- } from "./chunk-6L55WKFS.js";
32
+ } from "./chunk-DCURPYW5.js";
33
+
34
+ // src/record.ts
35
+ function createRecorder() {
36
+ const started = /* @__PURE__ */ new Map();
37
+ const calls = [];
38
+ const pending = /* @__PURE__ */ new Set();
39
+ let next = 0;
40
+ return {
41
+ start(requestId) {
42
+ started.set(requestId, next++);
43
+ },
44
+ /** A mocked response: nothing to record. */
45
+ drop(requestId) {
46
+ started.delete(requestId);
47
+ },
48
+ bypass(requestId, request, response) {
49
+ const order = started.get(requestId) ?? next++;
50
+ started.delete(requestId);
51
+ const method = MethodSchema.safeParse(request.method);
52
+ const own = new URL(request.url).pathname.startsWith(SAVE_ENDPOINT);
53
+ if (own || !method.success || !response.headers.get("content-type")?.includes("json")) {
54
+ return Promise.resolve();
55
+ }
56
+ const url = new URL(request.url);
57
+ if (new Set(url.searchParams.keys()).size < [...url.searchParams.keys()].length) {
58
+ console.warn(`[state-scenarios] not recorded: ${request.method} ${url} repeats a query key`);
59
+ return Promise.resolve();
60
+ }
61
+ const read = response.clone().text().then((text) => {
62
+ const call = {
63
+ order,
64
+ method: method.data,
65
+ path: url.origin === location.origin ? url.pathname : url.origin + url.pathname,
66
+ query: Object.fromEntries(url.searchParams),
67
+ status: response.status
68
+ };
69
+ if (text) call.body = parseJson(text);
70
+ calls.push(call);
71
+ }).catch((err) => console.warn("[state-scenarios] skipped a recorded response", err)).finally(() => pending.delete(read));
72
+ pending.add(read);
73
+ return read;
74
+ },
75
+ /** Responses whose bodies have been read, as network entries. */
76
+ entries: () => toNetwork(calls),
77
+ /** Wait for bodies still being read. */
78
+ settled: async () => {
79
+ await Promise.all(pending);
80
+ }
81
+ };
82
+ }
83
+ function toNetwork(calls) {
84
+ const groups = /* @__PURE__ */ new Map();
85
+ for (const call of calls.toSorted((a, b) => a.order - b.order)) {
86
+ const query = Object.fromEntries(Object.entries(call.query).toSorted(([a], [b]) => a.localeCompare(b)));
87
+ const key = JSON.stringify([call.method, call.path, query]);
88
+ groups.set(key, [...groups.get(key) ?? [], { ...call, query }]);
89
+ }
90
+ return [...groups.values()].map(([first, ...rest]) => {
91
+ const responses = [first, ...rest].map(({ status, body }) => ({ status, body }));
92
+ const same = responses.every((r) => JSON.stringify(r) === JSON.stringify(responses[0]));
93
+ const entry = { method: first.method, path: first.path };
94
+ if (Object.keys(first.query).length) entry.query = first.query;
95
+ if (same) entry.response = responses[0];
96
+ else entry.sequence = responses;
97
+ return entry;
98
+ }).toSorted((a, b) => Object.keys(b.query ?? {}).length - Object.keys(a.query ?? {}).length);
99
+ }
23
100
 
24
101
  // src/runtime.ts
25
102
  var STORAGE_KEY = "state-scenarios:active";
@@ -60,6 +137,30 @@ async function startScenarios(options) {
60
137
  }
61
138
  }
62
139
  };
140
+ const recorder = createRecorder();
141
+ let stopListening = null;
142
+ const listen = (target) => {
143
+ if (!session.recording || stopListening) return;
144
+ if (!target.events) {
145
+ console.warn("[state-scenarios] recording needs a worker with `events`");
146
+ return;
147
+ }
148
+ const { events } = target;
149
+ const start = ({ requestId }) => recorder.start(requestId);
150
+ const mocked = ({ requestId }) => recorder.drop(requestId);
151
+ const bypass = ({ requestId, request, response }) => {
152
+ void recorder.bypass(requestId, request, response).then(emit);
153
+ };
154
+ events.on("request:start", start);
155
+ events.on("response:mocked", mocked);
156
+ events.on("response:bypass", bypass);
157
+ stopListening = () => {
158
+ events.removeListener("request:start", start);
159
+ events.removeListener("response:mocked", mocked);
160
+ events.removeListener("response:bypass", bypass);
161
+ stopListening = null;
162
+ };
163
+ };
63
164
  let worker = options.worker ?? null;
64
165
  let ownWorker = null;
65
166
  const appHandlers = options.worker ? [...options.worker.listHandlers()] : [];
@@ -82,7 +183,7 @@ async function startScenarios(options) {
82
183
  };
83
184
  const boundAdapters = () => [...connected.values()].map((entry) => entry.adapter);
84
185
  const setNetwork = async (resolved2) => {
85
- if (!resolved2.network?.length && !session.strict && !worker) return;
186
+ if (!resolved2.network?.length && !session.strict && !session.recording && !worker) return;
86
187
  const [{ toHandlers: toHandlers2 }, msw] = await Promise.all([import("./network-MYTTJ2HL.js"), import("msw")]);
87
188
  if (destroyed) return;
88
189
  const allow = options.allow ?? [];
@@ -92,7 +193,7 @@ async function startScenarios(options) {
92
193
  const allowed = allow.some(
93
194
  (rule) => rule instanceof RegExp ? rule.test(request.url) : request.url.includes(rule)
94
195
  );
95
- if (isPage || isAsset(request.url) || pathname.startsWith("/@") || allowed) {
196
+ if (isPage || isAsset(request.url) || pathname.startsWith("/@") || pathname.startsWith(SAVE_ENDPOINT) || allowed) {
96
197
  return msw.passthrough();
97
198
  }
98
199
  const label = `${request.method} ${request.url}`;
@@ -105,9 +206,10 @@ async function startScenarios(options) {
105
206
  const handlers = [...toHandlers2(resolved2.network), ...appHandlers, fallback];
106
207
  if (worker) {
107
208
  worker.resetHandlers(...handlers);
209
+ listen(worker);
108
210
  return;
109
211
  }
110
- const { setupWorker } = await import("msw/browser");
212
+ const { setupWorker } = await import("#msw-browser");
111
213
  const started = setupWorker(...handlers);
112
214
  await started.start({ quiet: true, ...options.workerOptions });
113
215
  if (destroyed) {
@@ -115,6 +217,7 @@ async function startScenarios(options) {
115
217
  return;
116
218
  }
117
219
  worker = ownWorker = started;
220
+ listen(started);
118
221
  };
119
222
  const activate = (active2, resolved2, link) => {
120
223
  fromLink = link;
@@ -150,7 +253,7 @@ async function startScenarios(options) {
150
253
  unhandled.splice(0);
151
254
  activate(next, resolved2, inline);
152
255
  const at = new URL(href);
153
- history.replaceState(history.state, "", at);
256
+ replaceUrl(at);
154
257
  const data = readInline(at.hash);
155
258
  store(data ? { data, strict: session.strict } : { name: next.name, strict: session.strict });
156
259
  emit();
@@ -186,6 +289,10 @@ async function startScenarios(options) {
186
289
  resolved: null,
187
290
  strict: options.strict ?? !!selection?.strict,
188
291
  unhandled,
292
+ // Held in memory: a full page load starts a new recording.
293
+ recording: options.record ?? url.searchParams.has(RECORD_PARAM),
294
+ recorded: recorder.entries,
295
+ settleRecording: recorder.settled,
189
296
  keys: () => [...connected.keys()],
190
297
  read(key) {
191
298
  const entry = connected.get(key);
@@ -225,6 +332,7 @@ async function startScenarios(options) {
225
332
  for (const entry of connected.values()) entry.unsubscribe?.();
226
333
  connected.clear();
227
334
  listeners.clear();
335
+ stopListening?.();
228
336
  if (ownWorker) void ownWorker.stop();
229
337
  else worker?.resetHandlers(...appHandlers);
230
338
  worker = ownWorker = null;
@@ -240,7 +348,10 @@ async function startScenarios(options) {
240
348
  current = session;
241
349
  const initial = (options.adapters ?? []).map(bind);
242
350
  for (const adapter of initial) attach(adapter);
243
- if (!selection) return session;
351
+ if (!selection) {
352
+ if (session.recording) await setNetwork({ name: "live" });
353
+ return session;
354
+ }
244
355
  const loaded = (() => {
245
356
  try {
246
357
  const active2 = selection.data ? parseScenario(JSON.parse(selection.data), "inline scenario") : lookup(catalog, selection.name ?? "");
@@ -257,7 +368,7 @@ async function startScenarios(options) {
257
368
  for (const [k, v] of Object.entries(resolved.url ?? {})) {
258
369
  if (v !== null && !url.searchParams.has(k)) url.searchParams.set(k, v);
259
370
  }
260
- history.replaceState(history.state, "", url);
371
+ replaceUrl(url);
261
372
  await applyPrepared(prepared);
262
373
  if (destroyed) return session;
263
374
  activate(active, resolved, !!selection.data);
@@ -268,13 +379,18 @@ var isAsset = (href) => {
268
379
  const url = new URL(href);
269
380
  return url.protocol === "file:" || url.hostname === "fonts.googleapis.com" || url.pathname.includes("node_modules") || url.pathname.includes("@vite") || /\.(s?css|less|m?jsx?|m?tsx?|html|ttf|otf|woff2?|eot|gif|jpe?g|png|avif|webp|svg|mp4|webm|ogg|mov|mp3|wav|flac|aac|pdf|txt|csv|json|xml|md|zip|tar|gz|rar|7z)$/i.test(url.pathname);
270
381
  };
382
+ var replaceUrl = (url) => {
383
+ const state = history.state;
384
+ const { __NA, ...rest } = Object(state);
385
+ history.replaceState(__NA === void 0 ? state : rest, "", url);
386
+ };
271
387
  var byKey = ([a], [b]) => a.localeCompare(b);
272
388
  var appParams = (search) => [...new URLSearchParams(search)].filter(([k]) => k !== PARAM && k !== STRICT_PARAM).toSorted(byKey);
273
389
  var sortedParams = (url) => Object.entries(url ?? {}).filter(([, v]) => v !== null).toSorted(byKey);
274
390
  function navigate(href) {
275
391
  const next = new URL(href, location.href);
276
392
  if (next.pathname + next.search === location.pathname + location.search) {
277
- history.replaceState(history.state, "", next);
393
+ replaceUrl(next);
278
394
  location.reload();
279
395
  } else {
280
396
  location.href = next.toString();
@@ -329,19 +445,25 @@ export {
329
445
  DATA_KEY,
330
446
  JsonSchema,
331
447
  PARAM,
448
+ RECORD_PARAM,
332
449
  STRICT_PARAM,
333
450
  ScenarioSchema,
451
+ captureScenario,
452
+ checkReadySelector,
334
453
  createCatalog,
335
454
  currentSession,
336
455
  deepMerge,
337
456
  defineScenarioRules,
457
+ diffScenario,
338
458
  navigate,
339
459
  parseJson,
340
460
  parseScenario,
341
461
  readInline,
342
462
  scenarioHref,
343
463
  scenarioJsonSchema,
464
+ selectorForElement,
344
465
  startScenarios,
466
+ suggestReadySelectors,
345
467
  toHandlers
346
468
  };
347
469
  //# sourceMappingURL=index.js.map