state-scenarios 0.2.0 → 0.4.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-BKVROWKV.js +10 -0
- package/dist/capture-BKVROWKV.js.map +1 -0
- 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-3VHH36HY.js +137 -0
- package/dist/chunk-3VHH36HY.js.map +1 -0
- package/dist/{chunk-6L55WKFS.js → chunk-BK4EEQXV.js} +12 -3
- package/dist/chunk-BK4EEQXV.js.map +1 -0
- package/dist/chunk-MS3OSAWQ.js +198 -0
- package/dist/chunk-MS3OSAWQ.js.map +1 -0
- package/dist/chunk-XAH3YMWR.js +148 -0
- package/dist/chunk-XAH3YMWR.js.map +1 -0
- package/dist/chunk-XLNYICOI.js +234 -0
- package/dist/chunk-XLNYICOI.js.map +1 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +188 -18
- package/dist/index.js.map +1 -1
- package/dist/jwt.d.ts +7 -0
- 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 +286 -40
- package/dist/panel.js.map +1 -1
- package/dist/playwright.d.ts +5 -0
- package/dist/playwright.js +31 -1
- package/dist/playwright.js.map +1 -1
- 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 +22 -0
- package/dist/runtime.d.ts +76 -4
- package/dist/save.d.ts +27 -0
- package/dist/schema.d.ts +19 -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
package/dist/adapters/redux.js
CHANGED
package/dist/adapters/xstate.js
CHANGED
package/dist/adapters/zustand.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ScenarioSession } from './runtime.js';
|
|
2
|
+
import { type Scenario, type ScenarioInput } from './schema.js';
|
|
3
|
+
export interface CaptureOptions {
|
|
4
|
+
name: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
/** Playwright selector that marks this UI state ready. */
|
|
7
|
+
ready?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Catalog scenario to `extends`. Diff uses its resolved (flattened) form.
|
|
10
|
+
* Omit for a full self-contained snapshot.
|
|
11
|
+
*/
|
|
12
|
+
base?: Scenario;
|
|
13
|
+
/** Relative `$schema` path stamped on the catalog file only, e.g. `../scenario.schema.json`. */
|
|
14
|
+
schemaPath?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface CapturedScenario {
|
|
17
|
+
/** Flattened snapshot for inline share links. */
|
|
18
|
+
flat: Scenario;
|
|
19
|
+
/** File to commit: diffs against `base` when set, otherwise the flat snapshot as input. */
|
|
20
|
+
forCatalog: ScenarioInput;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Snapshot what the app shows now: current route and URL params, the active network
|
|
24
|
+
* fixtures (plus recorded responses), and every connected adapter's live state.
|
|
25
|
+
*/
|
|
26
|
+
export declare function captureScenario(session: ScenarioSession, options: CaptureOptions): CapturedScenario;
|
package/dist/catalog.d.ts
CHANGED
|
@@ -19,3 +19,9 @@ export declare function createCatalog(raw: ScenarioInput[], { rules }?: {
|
|
|
19
19
|
}): Catalog;
|
|
20
20
|
/** Objects merge recursively, `null` deletes a key, anything else replaces. */
|
|
21
21
|
export declare function deepMerge(base: Json | undefined, over: Json): Json;
|
|
22
|
+
/**
|
|
23
|
+
* Inverse of `extends` merge: overrides that turn a resolved `base` into `captured`.
|
|
24
|
+
* Pass the flattened base (from `catalog.resolve`). Copy-link keeps `captured` as-is;
|
|
25
|
+
* Download JSON uses this so the file can `extends` the base instead of repeating it.
|
|
26
|
+
*/
|
|
27
|
+
export declare function diffScenario(captured: Scenario, base?: Scenario): ScenarioInput;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// src/link.ts
|
|
2
2
|
var PARAM = "scenario";
|
|
3
3
|
var STRICT_PARAM = "scenario-strict";
|
|
4
|
+
var RECORD_PARAM = "scenario-record";
|
|
5
|
+
var SAVE_ENDPOINT = "/__state-scenarios/save";
|
|
4
6
|
var DATA_KEY = "scenario-data";
|
|
5
7
|
function scenarioHref(resolved, { base = location.href, inline = false, strict = false } = {}) {
|
|
6
8
|
const url = new URL(base);
|
|
@@ -24,8 +26,10 @@ var readInline = (hash) => new URLSearchParams(hash.slice(1)).get(DATA_KEY);
|
|
|
24
26
|
export {
|
|
25
27
|
PARAM,
|
|
26
28
|
STRICT_PARAM,
|
|
29
|
+
RECORD_PARAM,
|
|
30
|
+
SAVE_ENDPOINT,
|
|
27
31
|
DATA_KEY,
|
|
28
32
|
scenarioHref,
|
|
29
33
|
readInline
|
|
30
34
|
};
|
|
31
|
-
//# sourceMappingURL=chunk-
|
|
35
|
+
//# sourceMappingURL=chunk-3M6SD4TA.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/link.ts"],"sourcesContent":["import type { Scenario } from './schema.js';\n\ndeclare global {\n /** Requests the active scenario didn't cover, for tests and agents (see `unhandledRequests`). */\n var stateScenariosUnhandled: string[] | undefined;\n}\n\nexport const PARAM = 'scenario';\n\n/** Fail unmatched requests instead of letting them reach the network. */\nexport const STRICT_PARAM = 'scenario-strict';\n\n/** Record real responses so they can be saved as a scenario. */\nexport const RECORD_PARAM = 'scenario-record';\n\n/** Dev-server route the `state-scenarios/vite` plugin serves for the panel's \"Save to project\". */\nexport const SAVE_ENDPOINT = '/__state-scenarios/save';\n\n/** Inline scenario JSON, kept in the URL hash so it never reaches a server. */\nexport const DATA_KEY = 'scenario-data';\n\n/**\n * A shareable URL that opens the app in a resolved scenario.\n * `inline` embeds the flattened scenario, so the link works without the catalog.\n */\nexport function scenarioHref(\n resolved: Scenario,\n { base = location.href, inline = false, strict = false } = {},\n): string {\n const url = new URL(base);\n url.search = '';\n url.hash = '';\n\n if (resolved.path) url.pathname = resolved.path;\n\n for (const [k, v] of Object.entries(resolved.url ?? {})) {\n if (v !== null) url.searchParams.set(k, v);\n }\n\n if (inline) {\n const { $schema: _schema, extends: _extends, ...snapshot } = resolved;\n url.hash = new URLSearchParams({ [DATA_KEY]: JSON.stringify(snapshot) }).toString();\n } else {\n url.searchParams.set(PARAM, resolved.name);\n }\n\n if (strict) url.searchParams.set(STRICT_PARAM, '');\n\n return url.toString();\n}\n\n/** Inline scenario JSON from a URL hash, if present. */\nexport const readInline = (hash: string) => new URLSearchParams(hash.slice(1)).get(DATA_KEY);\n"],"mappings":";AAOO,IAAM,QAAQ;AAGd,IAAM,eAAe;AAGrB,IAAM,eAAe;AAGrB,IAAM,gBAAgB;AAGtB,IAAM,WAAW;AAMjB,SAAS,aACd,UACA,EAAE,OAAO,SAAS,MAAM,SAAS,OAAO,SAAS,MAAM,IAAI,CAAC,GACpD;AACR,QAAM,MAAM,IAAI,IAAI,IAAI;AACxB,MAAI,SAAS;AACb,MAAI,OAAO;AAEX,MAAI,SAAS,KAAM,KAAI,WAAW,SAAS;AAE3C,aAAW,CAAC,GAAG,CAAC,KAAK,OAAO,QAAQ,SAAS,OAAO,CAAC,CAAC,GAAG;AACvD,QAAI,MAAM,KAAM,KAAI,aAAa,IAAI,GAAG,CAAC;AAAA,EAC3C;AAEA,MAAI,QAAQ;AACV,UAAM,EAAE,SAAS,SAAS,SAAS,UAAU,GAAG,SAAS,IAAI;AAC7D,QAAI,OAAO,IAAI,gBAAgB,EAAE,CAAC,QAAQ,GAAG,KAAK,UAAU,QAAQ,EAAE,CAAC,EAAE,SAAS;AAAA,EACpF,OAAO;AACL,QAAI,aAAa,IAAI,OAAO,SAAS,IAAI;AAAA,EAC3C;AAEA,MAAI,OAAQ,KAAI,aAAa,IAAI,cAAc,EAAE;AAEjD,SAAO,IAAI,SAAS;AACtB;AAGO,IAAM,aAAa,CAAC,SAAiB,IAAI,gBAAgB,KAAK,MAAM,CAAC,CAAC,EAAE,IAAI,QAAQ;","names":[]}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PARAM,
|
|
3
|
+
RECORD_PARAM,
|
|
4
|
+
STRICT_PARAM
|
|
5
|
+
} from "./chunk-3M6SD4TA.js";
|
|
6
|
+
import {
|
|
7
|
+
diffScenario
|
|
8
|
+
} from "./chunk-MS3OSAWQ.js";
|
|
9
|
+
import {
|
|
10
|
+
parseScenario
|
|
11
|
+
} from "./chunk-BK4EEQXV.js";
|
|
12
|
+
|
|
13
|
+
// src/redact.ts
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
|
|
16
|
+
// src/jwt.ts
|
|
17
|
+
var encode = (value) => btoa(Array.from(new TextEncoder().encode(JSON.stringify(value)), (b) => String.fromCharCode(b)).join("")).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
18
|
+
var unsignedJwt = (claims) => `${encode({ alg: "none", typ: "JWT" })}.${encode(claims)}.`;
|
|
19
|
+
|
|
20
|
+
// src/redact.ts
|
|
21
|
+
var SECRET_KEY = /pass(word|phrase)?$|secret|api[-_]?key|private[-_]?key|access[-_]?key|credential|^otp$|^pin$|card[-_]?number|^cvc$|^cvv$|ssn|signature|^salt$|nonce/i;
|
|
22
|
+
var TOKEN_KEY = /token|session[-_]?id|^sid$|^auth$|authorization|bearer|jwt|cookie/i;
|
|
23
|
+
var secretParam = (k) => k.toLowerCase() === "key" || /^sig$/i.test(k) || SECRET_KEY.test(k) || TOKEN_KEY.test(k);
|
|
24
|
+
var NUMERIC_ID_KEY = /session[-_]?id$|^sid$/i;
|
|
25
|
+
var JWT = /[\w-]+\.[\w-]+\.[\w-]*/g;
|
|
26
|
+
var PLACEHOLDER = "[redacted]";
|
|
27
|
+
var decode = (part) => {
|
|
28
|
+
try {
|
|
29
|
+
const bytes = Uint8Array.from(atob(part.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
|
|
30
|
+
const value = JSON.parse(new TextDecoder().decode(bytes));
|
|
31
|
+
return value instanceof Object && !Array.isArray(value) ? value : void 0;
|
|
32
|
+
} catch {
|
|
33
|
+
return void 0;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var unsign = (text) => text.replace(JWT, (token) => {
|
|
37
|
+
const [header = "", payload = "", signature = ""] = token.split(".");
|
|
38
|
+
if (!decode(header)) return token;
|
|
39
|
+
const claims = decode(payload);
|
|
40
|
+
if (claims) return unsignedJwt(claims);
|
|
41
|
+
return signature ? PLACEHOLDER : token;
|
|
42
|
+
});
|
|
43
|
+
var isUnsignedJwt = (token) => /^[\w-]+\.[\w-]+\.$/.test(token) && !!decode(token.split(".")[0] ?? "");
|
|
44
|
+
var isBasicCredential = (value) => {
|
|
45
|
+
try {
|
|
46
|
+
return atob(value).includes(":");
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var scrub = (text) => text.replace(/\bBearer\s+([\w.~+/=-]{8,})/g, (m, token) => isUnsignedJwt(token) ? m : `Bearer ${PLACEHOLDER}`).replace(/\bBasic\s+([\w+/=]{8,})/g, (m, value) => isBasicCredential(value) ? `Basic ${PLACEHOLDER}` : m).replace(/(\w+:\/\/)[^\s/@:]+:[^\s/@]+@/g, `$1${PLACEHOLDER}@`).replace(/([?&])([^=&\s#]+)=([^&\s#]+)/g, (m, sep, k) => secretParam(k) ? `${sep}${k}=${PLACEHOLDER}` : m);
|
|
52
|
+
function redactSecrets(value) {
|
|
53
|
+
const redacted = [];
|
|
54
|
+
const walk = (node, path, key, above = "") => {
|
|
55
|
+
const text = z.string().safeParse(node);
|
|
56
|
+
const jwt = text.success ? unsign(text.data) : void 0;
|
|
57
|
+
const secret = SECRET_KEY.test(key) || TOKEN_KEY.test(key) || !!above;
|
|
58
|
+
const whole = jwt !== void 0 && isUnsignedJwt(jwt);
|
|
59
|
+
if (jwt !== void 0 && secret && whole) {
|
|
60
|
+
if (jwt !== text.data) redacted.push(path);
|
|
61
|
+
return jwt;
|
|
62
|
+
}
|
|
63
|
+
const clean = jwt === void 0 || secret ? void 0 : scrub(jwt);
|
|
64
|
+
if (clean !== void 0 && clean !== text.data) {
|
|
65
|
+
redacted.push(path);
|
|
66
|
+
return clean;
|
|
67
|
+
}
|
|
68
|
+
const number = z.number().safeParse(node);
|
|
69
|
+
if ((SECRET_KEY.test(key) || SECRET_KEY.test(above) || NUMERIC_ID_KEY.test(key)) && number.success) {
|
|
70
|
+
redacted.push(path);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
if (secret && text.success && text.data) {
|
|
74
|
+
redacted.push(path);
|
|
75
|
+
return PLACEHOLDER;
|
|
76
|
+
}
|
|
77
|
+
if (text.success || number.success) return node;
|
|
78
|
+
const holder = SECRET_KEY.test(key) || TOKEN_KEY.test(key) ? key : above;
|
|
79
|
+
if (Array.isArray(node)) return node.map((item, i) => walk(item, `${path}[${i}]`, key, holder));
|
|
80
|
+
if (node instanceof Object) {
|
|
81
|
+
const name = z.string().catch("").parse(node.name ?? node.key);
|
|
82
|
+
const pair = SECRET_KEY.test(name) || TOKEN_KEY.test(name) ? name : "";
|
|
83
|
+
return Object.fromEntries(
|
|
84
|
+
Object.entries(node).map(([k, v]) => [k, walk(v, path ? `${path}.${k}` : k, k, k === "value" && pair ? pair : holder)])
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return node;
|
|
88
|
+
};
|
|
89
|
+
return { value: walk(value, "", ""), redacted };
|
|
90
|
+
}
|
|
91
|
+
function redactQuery(query) {
|
|
92
|
+
return {
|
|
93
|
+
query: Object.fromEntries(Object.entries(query).filter(([k]) => !secretParam(k))),
|
|
94
|
+
dropped: Object.keys(query).filter(secretParam)
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/capture.ts
|
|
99
|
+
var SCENARIO_PARAMS = /* @__PURE__ */ new Set([PARAM, STRICT_PARAM, RECORD_PARAM]);
|
|
100
|
+
function captureScenario(session, options) {
|
|
101
|
+
const params = {};
|
|
102
|
+
new URLSearchParams(location.search).forEach((v, k) => {
|
|
103
|
+
if (!SCENARIO_PARAMS.has(k)) params[k] = v;
|
|
104
|
+
});
|
|
105
|
+
const { query: url } = redactQuery(params);
|
|
106
|
+
const state = Object.fromEntries(
|
|
107
|
+
session.keys().flatMap((key) => {
|
|
108
|
+
const value = session.read(key);
|
|
109
|
+
return value === void 0 ? [] : [[key, redactSecrets(value).value]];
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
const network = [...session.resolved?.network ?? [], ...session.recorded()];
|
|
113
|
+
const draft = parseScenario({
|
|
114
|
+
name: options.name,
|
|
115
|
+
description: options.description || void 0,
|
|
116
|
+
path: location.pathname,
|
|
117
|
+
url,
|
|
118
|
+
network: network.length ? network : void 0,
|
|
119
|
+
handlers: session.resolved?.handlers,
|
|
120
|
+
viewport: session.resolved?.viewport,
|
|
121
|
+
state,
|
|
122
|
+
ready: options.ready || void 0
|
|
123
|
+
});
|
|
124
|
+
const flat = session.catalog.resolve(draft);
|
|
125
|
+
const flatBase = options.base ? session.catalog.resolve(options.base) : void 0;
|
|
126
|
+
const forCatalog = diffScenario(flat, flatBase);
|
|
127
|
+
if (options.schemaPath) forCatalog.$schema = options.schemaPath;
|
|
128
|
+
return { flat, forCatalog };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export {
|
|
132
|
+
unsignedJwt,
|
|
133
|
+
redactSecrets,
|
|
134
|
+
redactQuery,
|
|
135
|
+
captureScenario
|
|
136
|
+
};
|
|
137
|
+
//# sourceMappingURL=chunk-3VHH36HY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/redact.ts","../src/jwt.ts","../src/capture.ts"],"sourcesContent":["import { z } from 'zod';\nimport { unsignedJwt } from './jwt.js';\nimport type { Json, JsonObject } from './schema.js';\n\n/** Keys whose values are secrets whatever they hold. */\nconst SECRET_KEY =\n /pass(word|phrase)?$|secret|api[-_]?key|private[-_]?key|access[-_]?key|credential|^otp$|^pin$|card[-_]?number|^cvc$|^cvv$|ssn|signature|^salt$|nonce/i;\n\n/** Keys holding bearer values: a JWT keeps its claims, anything else is replaced. */\nconst TOKEN_KEY = /token|session[-_]?id|^sid$|^auth$|authorization|bearer|jwt|cookie/i;\n\n/** URL params that carry credentials, e.g. `?key=` for maps APIs or `X-Amz-Signature` in signed URLs. */\nconst secretParam = (k: string) =>\n k.toLowerCase() === 'key' || /^sig$/i.test(k) || SECRET_KEY.test(k) || TOKEN_KEY.test(k);\n\n/** Numeric bearer values: session ids. */\nconst NUMERIC_ID_KEY = /session[-_]?id$|^sid$/i;\n\n/** Three base64url parts, anywhere in a string (e.g. after `Bearer `): a JWT when the first decodes to JSON. */\nconst JWT = /[\\w-]+\\.[\\w-]+\\.[\\w-]*/g;\n\nconst PLACEHOLDER = '[redacted]';\n\nconst decode = (part: string): JsonObject | undefined => {\n try {\n const bytes = Uint8Array.from(atob(part.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));\n const value: Json = JSON.parse(new TextDecoder().decode(bytes));\n\n return value instanceof Object && !Array.isArray(value) ? value : undefined;\n } catch {\n return undefined;\n }\n};\n\n/**\n * Each JWT in `text` re-issued unsigned with the same claims, so the app still decodes it but no\n * server accepts it. A signed token whose claims can't be read is replaced whole.\n */\nconst unsign = (text: string) =>\n text.replace(JWT, (token) => {\n const [header = '', payload = '', signature = ''] = token.split('.');\n\n // `v1.2.3`, `www.example.com`: not a token.\n if (!decode(header)) return token;\n const claims = decode(payload);\n\n if (claims) return unsignedJwt(claims);\n\n return signature ? PLACEHOLDER : token;\n });\n\nconst isUnsignedJwt = (token: string) =>\n /^[\\w-]+\\.[\\w-]+\\.$/.test(token) && !!decode(token.split('.')[0] ?? '');\n\nconst isBasicCredential = (value: string) => {\n try {\n return atob(value).includes(':');\n } catch {\n return false;\n }\n};\n\n/** Credentials inside any string: `Bearer`/`Basic` values, `user:pass@` in URLs, and secret URL params. */\nconst scrub = (text: string) =>\n text\n .replace(/\\bBearer\\s+([\\w.~+/=-]{8,})/g, (m, token: string) =>\n isUnsignedJwt(token) ? m : `Bearer ${PLACEHOLDER}`)\n .replace(/\\bBasic\\s+([\\w+/=]{8,})/g, (m, value: string) =>\n isBasicCredential(value) ? `Basic ${PLACEHOLDER}` : m)\n .replace(/(\\w+:\\/\\/)[^\\s/@:]+:[^\\s/@]+@/g, `$1${PLACEHOLDER}@`)\n .replace(/([?&])([^=&\\s#]+)=([^&\\s#]+)/g, (m, sep: string, k: string) =>\n secretParam(k) ? `${sep}${k}=${PLACEHOLDER}` : m);\n\nexport interface Redacted {\n value: Json;\n /** Paths that changed, e.g. `user.accessToken`. */\n redacted: string[];\n}\n\n/**\n * Remove secrets from a recorded response body: strings under secret-like keys become\n * \"[redacted]\" and numbers become 0, and signed JWTs anywhere lose their signature but keep\n * their claims.\n */\nexport function redactSecrets(value: Json): Redacted {\n const redacted: string[] = [];\n\n /** `above`: the nearest secret-like key holding this node, e.g. `apiKey` for `apiKey.primary`. */\n const walk = (node: Json, path: string, key: string, above = ''): Json => {\n const text = z.string().safeParse(node);\n // A JWT keeps its claims, whatever key holds it: the app may decode it.\n const jwt = text.success ? unsign(text.data) : undefined;\n // Checked before the value's type: a card number or one-time code is often a number.\n // Token-like keys only for strings: `tokens: 3` is usually a count, not a credential.\n const secret = SECRET_KEY.test(key) || TOKEN_KEY.test(key) || !!above;\n // A secret key keeps its value only when the whole value is one token, now unsigned.\n const whole = jwt !== undefined && isUnsignedJwt(jwt);\n\n if (jwt !== undefined && secret && whole) {\n if (jwt !== text.data) redacted.push(path);\n\n return jwt;\n }\n\n const clean = jwt === undefined || secret ? undefined : scrub(jwt);\n\n if (clean !== undefined && clean !== text.data) {\n redacted.push(path);\n\n return clean;\n }\n\n const number = z.number().safeParse(node);\n\n if ((SECRET_KEY.test(key) || SECRET_KEY.test(above) || NUMERIC_ID_KEY.test(key)) && number.success) {\n redacted.push(path);\n\n return 0; // still a number, so the app's parsing and formatting keep working\n }\n\n if (secret && text.success && text.data) {\n redacted.push(path);\n\n return PLACEHOLDER;\n }\n\n if (text.success || number.success) return node;\n\n const holder = SECRET_KEY.test(key) || TOKEN_KEY.test(key) ? key : above;\n\n if (Array.isArray(node)) return node.map((item, i) => walk(item, `${path}[${i}]`, key, holder));\n\n if (node instanceof Object) {\n // Header-style pairs: `{ name: \"Authorization\", value: \"...\" }`.\n const name = z.string().catch('').parse(node.name ?? node.key);\n const pair = SECRET_KEY.test(name) || TOKEN_KEY.test(name) ? name : '';\n\n return Object.fromEntries(\n Object.entries(node).map((\n [k, v],\n ) => [k, walk(v, path ? `${path}.${k}` : k, k, k === 'value' && pair ? pair : holder)]),\n );\n }\n\n return node;\n };\n\n return { value: walk(value, '', ''), redacted };\n}\n\n/**\n * Drop secret query params, such as `key` or `access_token`. Replay matches only the params an\n * entry lists, so the recorded entry still answers the request.\n */\nexport function redactQuery(query: Record<string, string>) {\n return {\n query: Object.fromEntries(Object.entries(query).filter(([k]) => !secretParam(k))),\n dropped: Object.keys(query).filter(secretParam),\n };\n}\n","import type { JsonObject } from './schema.js';\n\n/** Base64url of UTF-8, so claims in any script (\"José\", \"李雷\", \"Zoë\") survive decoding. */\nconst encode = (value: JsonObject) =>\n btoa(Array.from(new TextEncoder().encode(JSON.stringify(value)), (b) => String.fromCharCode(b)).join(''))\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n\n/**\n * An unsigned JWT (`alg: \"none\"`) carrying `claims`, for mocked responses in \"signed-in\" scenarios\n * when the app only decodes the token it gets back. Anything that verifies signatures rejects it,\n * which keeps it out of real backends. Set `exp` in the future if the app checks expiry.\n */\nexport const unsignedJwt = (claims: JsonObject): string =>\n `${encode({ alg: 'none', typ: 'JWT' })}.${encode(claims)}.`;\n","import { diffScenario } from './catalog.js';\nimport { PARAM, RECORD_PARAM, STRICT_PARAM } from './link.js';\nimport { redactQuery, redactSecrets } from './redact.js';\nimport type { ScenarioSession } from './runtime.js';\nimport { parseScenario, type Scenario, type ScenarioInput } from './schema.js';\n\nconst SCENARIO_PARAMS = new Set([PARAM, STRICT_PARAM, RECORD_PARAM]);\n\nexport interface CaptureOptions {\n name: string;\n description?: string;\n /** Playwright selector that marks this UI state ready. */\n ready?: string;\n /**\n * Catalog scenario to `extends`. Diff uses its resolved (flattened) form.\n * Omit for a full self-contained snapshot.\n */\n base?: Scenario;\n /** Relative `$schema` path stamped on the catalog file only, e.g. `../scenario.schema.json`. */\n schemaPath?: string;\n}\n\nexport interface CapturedScenario {\n /** Flattened snapshot for inline share links. */\n flat: Scenario;\n /** File to commit: diffs against `base` when set, otherwise the flat snapshot as input. */\n forCatalog: ScenarioInput;\n}\n\n/**\n * Snapshot what the app shows now: current route and URL params, the active network\n * fixtures (plus recorded responses), and every connected adapter's live state.\n */\nexport function captureScenario(session: ScenarioSession, options: CaptureOptions): CapturedScenario {\n const params: Record<string, string> = {};\n new URLSearchParams(location.search).forEach((v, k) => {\n if (!SCENARIO_PARAMS.has(k)) params[k] = v;\n });\n\n // Saved scenarios get committed and shared: keep sessions and tokens out of them.\n const { query: url } = redactQuery(params);\n\n const state = Object.fromEntries(\n session.keys().flatMap((key) => {\n const value = session.read(key);\n\n return value === undefined ? [] : [[key, redactSecrets(value).value] as const];\n }),\n );\n\n // Recorded responses go after the scenario's own: they answered requests it didn't mock.\n const network = [...(session.resolved?.network ?? []), ...session.recorded()];\n\n const draft = parseScenario({\n name: options.name,\n description: options.description || undefined,\n path: location.pathname,\n url,\n network: network.length ? network : undefined,\n handlers: session.resolved?.handlers,\n viewport: session.resolved?.viewport,\n state,\n ready: options.ready || undefined,\n });\n\n const flat = session.catalog.resolve(draft);\n const flatBase = options.base ? session.catalog.resolve(options.base) : undefined;\n const forCatalog = diffScenario(flat, flatBase);\n\n if (options.schemaPath) forCatalog.$schema = options.schemaPath;\n\n return { flat, forCatalog };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
var JsonSchema = z.json();
|
|
4
4
|
var parseJson = (text) => JsonSchema.parse(JSON.parse(text));
|
|
5
|
+
var MethodSchema = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
5
6
|
var ResponseSchema = z.object({
|
|
6
7
|
status: z.number().int().default(200),
|
|
7
8
|
/** Sent as JSON. */
|
|
@@ -15,7 +16,7 @@ var ResponseSchema = z.object({
|
|
|
15
16
|
message: 'Provide "body" (JSON) or "text", not both'
|
|
16
17
|
});
|
|
17
18
|
var NetworkEntrySchema = z.object({
|
|
18
|
-
method:
|
|
19
|
+
method: MethodSchema.default("GET"),
|
|
19
20
|
/** MSW path syntax: "/api/users/:id", absolute URLs and wildcards work. */
|
|
20
21
|
path: z.string().min(1),
|
|
21
22
|
/** Only match requests whose query string contains these values. */
|
|
@@ -47,13 +48,20 @@ var ScenarioSchema = z.object({
|
|
|
47
48
|
* Otherwise earlier entries win, and a scenario's own entries come before its parents'.
|
|
48
49
|
*/
|
|
49
50
|
network: z.array(NetworkEntrySchema).optional(),
|
|
51
|
+
/**
|
|
52
|
+
* Names of MSW handlers passed to `startScenarios({ handlers })`, e.g. ones your unit tests
|
|
53
|
+
* already use. They answer after this scenario's `network` entries. Inherited and combined.
|
|
54
|
+
*/
|
|
55
|
+
handlers: z.array(z.string().min(1)).optional(),
|
|
50
56
|
/** Per-adapter state, keyed by adapter key. Objects deep-merge; null removes a key. */
|
|
51
57
|
state: z.record(z.string(), JsonSchema).optional(),
|
|
52
58
|
/**
|
|
53
59
|
* Playwright selector that is visible once the UI shows this state,
|
|
54
60
|
* e.g. "text=Loading countries…". Not inherited: each state looks different.
|
|
55
61
|
*/
|
|
56
|
-
ready: z.string().optional()
|
|
62
|
+
ready: z.string().optional(),
|
|
63
|
+
/** Browser size `shoot` and `record` use for this scenario, e.g. a phone layout. Inherited. */
|
|
64
|
+
viewport: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }).optional()
|
|
57
65
|
});
|
|
58
66
|
function scenarioJsonSchema({ state = {} } = {}) {
|
|
59
67
|
return z.toJSONSchema(
|
|
@@ -82,8 +90,9 @@ ${z.prettifyError(result.error)}`);
|
|
|
82
90
|
export {
|
|
83
91
|
JsonSchema,
|
|
84
92
|
parseJson,
|
|
93
|
+
MethodSchema,
|
|
85
94
|
ScenarioSchema,
|
|
86
95
|
scenarioJsonSchema,
|
|
87
96
|
parseScenario
|
|
88
97
|
};
|
|
89
|
-
//# sourceMappingURL=chunk-
|
|
98
|
+
//# sourceMappingURL=chunk-BK4EEQXV.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/schema.ts"],"sourcesContent":["import { z } from 'zod';\n\n/** Scenarios live in JSON files and URLs, so everything they carry is JSON. */\nexport const JsonSchema = z.json();\n\nexport type Json = z.output<typeof JsonSchema>;\n\nexport type JsonObject = { [key: string]: Json; };\n\n/** Anything an app can hand over to be stored as JSON. */\nexport type Serializable = object | string | number | boolean | null;\n\n/**\n * Decode JSON text. Pair with `JSON.stringify` to copy app state into the format scenarios\n * are stored in: functions and undefined drop out, and the result is checked to be JSON.\n */\nexport const parseJson = (text: string): Json => JsonSchema.parse(JSON.parse(text));\n\nexport const MethodSchema = z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);\n\nconst ResponseSchema = z\n .object({\n status: z.number().int().default(200),\n /** Sent as JSON. */\n body: JsonSchema.optional(),\n /** Sent as text, e.g. an HTML error page. */\n text: z.string().optional(),\n headers: z.record(z.string(), z.string()).optional(),\n /** Milliseconds, or \"infinite\" to hold the request open (loading states). */\n delay: z.union([z.number().nonnegative(), z.literal('infinite')]).optional(),\n })\n .refine((r) => r.body === undefined || r.text === undefined, {\n message: 'Provide \"body\" (JSON) or \"text\", not both',\n });\n\nconst NetworkEntrySchema = z\n .object({\n method: MethodSchema.default('GET'),\n /** MSW path syntax: \"/api/users/:id\", absolute URLs and wildcards work. */\n path: z.string().min(1),\n /** Only match requests whose query string contains these values. */\n query: z.record(z.string(), z.string()).optional(),\n response: ResponseSchema.optional(),\n /** Successive responses for successive calls; the last one repeats. */\n sequence: z.array(ResponseSchema).min(1).optional(),\n /** Drop the inherited entry with the same method, path and query. */\n remove: z.literal(true).optional(),\n })\n .refine((e) => [e.response, e.sequence, e.remove].filter(Boolean).length === 1, {\n message: 'Provide exactly one of \"response\", \"sequence\" or \"remove\"',\n });\n\nexport const ScenarioSchema = z.object({\n $schema: z.string().optional(),\n // Kebab-case; a combined scenario is named by its parts, e.g. \"signed-in,server-error\".\n name: z.string().regex(/^[a-z0-9][a-z0-9-]*(,[a-z0-9][a-z0-9-]*)*$/, 'Use kebab-case, e.g. \"empty-search\"'),\n /** Not inherited. */\n description: z.string().optional(),\n /** Not inherited. */\n tags: z.array(z.string()).optional(),\n /** Scenario name(s) to build on. Later parents override earlier ones. */\n extends: z.union([z.string(), z.array(z.string())]).optional(),\n /** App route the scenario opens at, e.g. \"/orders/42\". Used by links, the panel, `shoot` and `openInlineScenario`. */\n path: z.string().startsWith('/').optional(),\n /** Query params the app should start with. null removes an inherited param. */\n url: z.record(z.string(), z.string().nullable()).optional(),\n /**\n * A child entry replaces the inherited entry with the same method, path and query.\n * Otherwise earlier entries win, and a scenario's own entries come before its parents'.\n */\n network: z.array(NetworkEntrySchema).optional(),\n /**\n * Names of MSW handlers passed to `startScenarios({ handlers })`, e.g. ones your unit tests\n * already use. They answer after this scenario's `network` entries. Inherited and combined.\n */\n handlers: z.array(z.string().min(1)).optional(),\n /** Per-adapter state, keyed by adapter key. Objects deep-merge; null removes a key. */\n state: z.record(z.string(), JsonSchema).optional(),\n /**\n * Playwright selector that is visible once the UI shows this state,\n * e.g. \"text=Loading countries…\". Not inherited: each state looks different.\n */\n ready: z.string().optional(),\n /** Browser size `shoot` and `record` use for this scenario, e.g. a phone layout. Inherited. */\n viewport: z.object({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(),\n});\n\nexport type Scenario = z.output<typeof ScenarioSchema>;\n\nexport type ScenarioInput = z.input<typeof ScenarioSchema>;\n\nexport type NetworkEntry = z.output<typeof NetworkEntrySchema>;\n\nexport type ScenarioResponse = z.output<typeof ResponseSchema>;\n\n/**\n * JSON Schema for editors, validators and AI generators.\n * Pass adapter state schemas so `state` is described too.\n */\nexport function scenarioJsonSchema({ state = {} }: { state?: Record<string, z.ZodType>; } = {}) {\n return z.toJSONSchema(\n ScenarioSchema.extend({ state: z.object(state).partial().catchall(JsonSchema).optional() }),\n {\n io: 'input',\n // Refinements aren't representable in JSON Schema; spell the exclusivity rules out.\n override: ({ zodSchema, jsonSchema }) => {\n if (zodSchema === NetworkEntrySchema) {\n jsonSchema.oneOf = ['response', 'sequence', 'remove'].map((key) => ({ required: [key] }));\n }\n\n if (zodSchema === ResponseSchema) {\n jsonSchema.not = { required: ['body', 'text'] };\n }\n },\n },\n );\n}\n\n/** Validate scenario input (from a file, URL or editor) into a Scenario, with readable errors. */\nexport function parseScenario(data: ScenarioInput, source = 'scenario'): Scenario {\n const result = ScenarioSchema.safeParse(data);\n\n if (result.success) return result.data;\n throw new Error(`Invalid scenario \"${data.name || source}\":\\n${z.prettifyError(result.error)}`);\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseScenario
|
|
3
|
+
} from "./chunk-BK4EEQXV.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
|
+
handlers: union(over.handlers, base.handlers),
|
|
68
|
+
state: mergeObjects(base.state ?? {}, over.state ?? {})
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
var union = (a = [], b = []) => {
|
|
72
|
+
const all = [.../* @__PURE__ */ new Set([...a, ...b])];
|
|
73
|
+
return all.length ? all : void 0;
|
|
74
|
+
};
|
|
75
|
+
var isJsonObject = (value) => value instanceof Object && !Array.isArray(value);
|
|
76
|
+
function mergeObjects(base, over) {
|
|
77
|
+
const out = { ...base };
|
|
78
|
+
for (const [k, v] of Object.entries(over)) {
|
|
79
|
+
if (v === null) delete out[k];
|
|
80
|
+
else out[k] = deepMerge(base[k], v);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
function deepMerge(base, over) {
|
|
85
|
+
return isJsonObject(base) && isJsonObject(over) ? mergeObjects(base, over) : over;
|
|
86
|
+
}
|
|
87
|
+
function diffScenario(captured, base) {
|
|
88
|
+
if (!base) {
|
|
89
|
+
return compactScenario({
|
|
90
|
+
name: captured.name,
|
|
91
|
+
description: captured.description,
|
|
92
|
+
tags: captured.tags,
|
|
93
|
+
path: captured.path,
|
|
94
|
+
url: captured.url,
|
|
95
|
+
network: captured.network,
|
|
96
|
+
handlers: captured.handlers,
|
|
97
|
+
state: captured.state,
|
|
98
|
+
ready: captured.ready,
|
|
99
|
+
viewport: captured.viewport
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const url = diffUrl(base.url, captured.url);
|
|
103
|
+
const network = diffNetwork(base.network ?? [], captured.network ?? []);
|
|
104
|
+
const state = diffObjects(base.state ?? {}, captured.state ?? {});
|
|
105
|
+
const handlers = diffHandlers(base.handlers ?? [], captured.handlers ?? []);
|
|
106
|
+
return compactScenario({
|
|
107
|
+
name: captured.name,
|
|
108
|
+
extends: base.name,
|
|
109
|
+
description: captured.description,
|
|
110
|
+
tags: captured.tags,
|
|
111
|
+
path: captured.path !== base.path ? captured.path : void 0,
|
|
112
|
+
url,
|
|
113
|
+
network: network.length ? network : void 0,
|
|
114
|
+
handlers,
|
|
115
|
+
state,
|
|
116
|
+
ready: captured.ready,
|
|
117
|
+
viewport: jsonEqual(captured.viewport ?? null, base.viewport ?? null) ? void 0 : captured.viewport
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function diffHandlers(base, captured) {
|
|
121
|
+
for (let k = 0; k <= captured.length; k++) {
|
|
122
|
+
const own = captured.slice(0, k);
|
|
123
|
+
if (JSON.stringify(union(own, base)) === JSON.stringify(captured)) return own.length ? own : void 0;
|
|
124
|
+
}
|
|
125
|
+
return captured.length ? captured : void 0;
|
|
126
|
+
}
|
|
127
|
+
function diffUrl(base, captured) {
|
|
128
|
+
const baseUrl = base ?? {};
|
|
129
|
+
const capturedUrl = captured ?? {};
|
|
130
|
+
const out = {};
|
|
131
|
+
for (const [k, v] of Object.entries(capturedUrl)) {
|
|
132
|
+
if (v === null) continue;
|
|
133
|
+
if (baseUrl[k] !== v) out[k] = v;
|
|
134
|
+
}
|
|
135
|
+
for (const [k, v] of Object.entries(baseUrl)) {
|
|
136
|
+
if (v === null) continue;
|
|
137
|
+
if (!(k in capturedUrl) || capturedUrl[k] === null) out[k] = null;
|
|
138
|
+
}
|
|
139
|
+
return Object.keys(out).length ? out : void 0;
|
|
140
|
+
}
|
|
141
|
+
function diffNetwork(base, captured) {
|
|
142
|
+
const baseByKey = new Map(base.map((e) => [routeKey(e), e]));
|
|
143
|
+
const capByKey = /* @__PURE__ */ new Map();
|
|
144
|
+
for (const e of captured) if (!capByKey.has(routeKey(e))) capByKey.set(routeKey(e), e);
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const [key, e] of capByKey) {
|
|
147
|
+
const prior = baseByKey.get(key);
|
|
148
|
+
if (!prior || !jsonEqual(prior, e)) out.push(e);
|
|
149
|
+
}
|
|
150
|
+
for (const [key, e] of baseByKey) {
|
|
151
|
+
if (!capByKey.has(key)) {
|
|
152
|
+
const removed = { method: e.method, path: e.path, remove: true };
|
|
153
|
+
if (e.query) removed.query = e.query;
|
|
154
|
+
out.push(removed);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
function diffObjects(base, captured) {
|
|
160
|
+
const out = {};
|
|
161
|
+
for (const [k, v] of Object.entries(captured)) {
|
|
162
|
+
if (!(k in base)) out[k] = v;
|
|
163
|
+
else if (isJsonObject(base[k]) && isJsonObject(v)) {
|
|
164
|
+
const nested = diffObjects(base[k], v);
|
|
165
|
+
if (nested) out[k] = nested;
|
|
166
|
+
} else if (!jsonEqual(base[k], v)) out[k] = v;
|
|
167
|
+
}
|
|
168
|
+
for (const k of Object.keys(base)) {
|
|
169
|
+
if (!(k in captured)) out[k] = null;
|
|
170
|
+
}
|
|
171
|
+
return Object.keys(out).length ? out : void 0;
|
|
172
|
+
}
|
|
173
|
+
function jsonEqual(a, b) {
|
|
174
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
175
|
+
}
|
|
176
|
+
function compactScenario(s) {
|
|
177
|
+
const out = { name: s.name };
|
|
178
|
+
if (s.$schema !== void 0) out.$schema = s.$schema;
|
|
179
|
+
if (s.description !== void 0) out.description = s.description;
|
|
180
|
+
if (s.tags !== void 0) out.tags = s.tags;
|
|
181
|
+
if (s.extends !== void 0) out.extends = s.extends;
|
|
182
|
+
if (s.path !== void 0) out.path = s.path;
|
|
183
|
+
if (s.url !== void 0 && Object.keys(s.url).length) out.url = s.url;
|
|
184
|
+
if (s.network !== void 0 && s.network.length) out.network = s.network;
|
|
185
|
+
if (s.handlers !== void 0 && s.handlers.length) out.handlers = s.handlers;
|
|
186
|
+
if (s.state !== void 0 && Object.keys(s.state).length) out.state = s.state;
|
|
187
|
+
if (s.ready !== void 0) out.ready = s.ready;
|
|
188
|
+
if (s.viewport !== void 0) out.viewport = s.viewport;
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export {
|
|
193
|
+
defineScenarioRules,
|
|
194
|
+
createCatalog,
|
|
195
|
+
deepMerge,
|
|
196
|
+
diffScenario
|
|
197
|
+
};
|
|
198
|
+
//# sourceMappingURL=chunk-MS3OSAWQ.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 handlers: union(over.handlers, base.handlers),\n state: mergeObjects(base.state ?? {}, over.state ?? {}),\n };\n}\n\nconst union = (a: string[] = [], b: string[] = []) => {\n const all = [...new Set([...a, ...b])];\n\n return all.length ? all : undefined;\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 handlers: captured.handlers,\n state: captured.state,\n ready: captured.ready,\n viewport: captured.viewport,\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 const handlers = diffHandlers(base.handlers ?? [], captured.handlers ?? []);\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 handlers,\n state,\n ready: captured.ready,\n viewport: jsonEqual(captured.viewport ?? null, base.viewport ?? null) ? undefined : captured.viewport,\n });\n}\n\n/**\n * The shortest own list that `extends` merges back into `captured`, order included: merge puts\n * the child's handlers first, then the base's it didn't name. Handler order is precedence.\n */\nfunction diffHandlers(base: string[], captured: string[]): string[] | undefined {\n for (let k = 0; k <= captured.length; k++) {\n const own = captured.slice(0, k);\n\n if (JSON.stringify(union(own, base)) === JSON.stringify(captured)) return own.length ? own : undefined;\n }\n\n // An inherited handler can't be removed: keep the captured order and let the base's extras follow.\n return captured.length ? captured : undefined;\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.handlers !== undefined && s.handlers.length) out.handlers = s.handlers;\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 if (s.viewport !== undefined) out.viewport = s.viewport;\n\n return out;\n}\n"],"mappings":"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","names":[]}
|