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.
- package/dist/adapters/redux.js +2 -2
- package/dist/adapters/tanstack-query.js +1 -1
- package/dist/adapters/xstate.js +2 -2
- package/dist/adapters/zustand.js +1 -1
- package/dist/capture.d.ts +26 -0
- package/dist/catalog.d.ts +6 -0
- package/dist/{chunk-KRCYO64S.js → chunk-3M6SD4TA.js} +5 -1
- package/dist/chunk-3M6SD4TA.js.map +1 -0
- package/dist/{chunk-6L55WKFS.js → chunk-DCURPYW5.js} +4 -2
- package/dist/chunk-DCURPYW5.js.map +1 -0
- package/dist/chunk-FTWYRB6F.js +277 -0
- package/dist/chunk-FTWYRB6F.js.map +1 -0
- package/dist/chunk-HEVJQ47T.js +148 -0
- package/dist/chunk-HEVJQ47T.js.map +1 -0
- package/dist/chunk-KYU7RUWI.js +179 -0
- package/dist/chunk-KYU7RUWI.js.map +1 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +133 -11
- package/dist/index.js.map +1 -1
- package/dist/link.d.ts +4 -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 +10 -8
- package/dist/panel.js +284 -40
- package/dist/panel.js.map +1 -1
- package/dist/playwright.js +1 -1
- package/dist/ready.d.ts +39 -0
- package/dist/record.d.ts +49 -0
- package/dist/runtime.d.ts +31 -3
- package/dist/save.d.ts +27 -0
- package/dist/schema.d.ts +9 -0
- package/dist/vite.d.ts +11 -0
- package/dist/vite.js +26 -0
- package/dist/vite.js.map +1 -0
- package/package.json +27 -3
- package/dist/chunk-6L55WKFS.js.map +0 -1
- package/dist/chunk-KDH572NI.js +0 -88
- package/dist/chunk-KDH572NI.js.map +0 -1
- 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
|
|
2
|
-
export {
|
|
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-
|
|
18
|
+
} from "./chunk-3M6SD4TA.js";
|
|
11
19
|
import {
|
|
12
20
|
createCatalog,
|
|
13
21
|
deepMerge,
|
|
14
|
-
defineScenarioRules
|
|
15
|
-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|