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
package/dist/adapters/redux.js
CHANGED
package/dist/adapters/xstate.js
CHANGED
package/dist/adapters/zustand.js
CHANGED
|
@@ -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":[]}
|
|
@@ -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. */
|
|
@@ -82,8 +83,9 @@ ${z.prettifyError(result.error)}`);
|
|
|
82
83
|
export {
|
|
83
84
|
JsonSchema,
|
|
84
85
|
parseJson,
|
|
86
|
+
MethodSchema,
|
|
85
87
|
ScenarioSchema,
|
|
86
88
|
scenarioJsonSchema,
|
|
87
89
|
parseScenario
|
|
88
90
|
};
|
|
89
|
-
//# sourceMappingURL=chunk-
|
|
91
|
+
//# sourceMappingURL=chunk-DCURPYW5.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 /** 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});\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,277 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PARAM,
|
|
3
|
+
RECORD_PARAM,
|
|
4
|
+
STRICT_PARAM
|
|
5
|
+
} from "./chunk-3M6SD4TA.js";
|
|
6
|
+
import {
|
|
7
|
+
diffScenario
|
|
8
|
+
} from "./chunk-KYU7RUWI.js";
|
|
9
|
+
import {
|
|
10
|
+
parseScenario
|
|
11
|
+
} from "./chunk-DCURPYW5.js";
|
|
12
|
+
|
|
13
|
+
// src/capture.ts
|
|
14
|
+
var SCENARIO_PARAMS = /* @__PURE__ */ new Set([PARAM, STRICT_PARAM, RECORD_PARAM]);
|
|
15
|
+
function captureScenario(session, options) {
|
|
16
|
+
const url = {};
|
|
17
|
+
new URLSearchParams(location.search).forEach((v, k) => {
|
|
18
|
+
if (!SCENARIO_PARAMS.has(k)) url[k] = v;
|
|
19
|
+
});
|
|
20
|
+
const state = Object.fromEntries(
|
|
21
|
+
session.keys().flatMap((key) => {
|
|
22
|
+
const value = session.read(key);
|
|
23
|
+
return value === void 0 ? [] : [[key, value]];
|
|
24
|
+
})
|
|
25
|
+
);
|
|
26
|
+
const network = [...session.resolved?.network ?? [], ...session.recorded()];
|
|
27
|
+
const draft = parseScenario({
|
|
28
|
+
name: options.name,
|
|
29
|
+
description: options.description || void 0,
|
|
30
|
+
path: location.pathname,
|
|
31
|
+
url,
|
|
32
|
+
network: network.length ? network : void 0,
|
|
33
|
+
state,
|
|
34
|
+
ready: options.ready || void 0
|
|
35
|
+
});
|
|
36
|
+
const flat = session.catalog.resolve(draft);
|
|
37
|
+
const flatBase = options.base ? session.catalog.resolve(options.base) : void 0;
|
|
38
|
+
const forCatalog = diffScenario(flat, flatBase);
|
|
39
|
+
if (options.schemaPath) forCatalog.$schema = options.schemaPath;
|
|
40
|
+
return { flat, forCatalog };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/ready.ts
|
|
44
|
+
var SKIP = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "NOSCRIPT", "META", "LINK", "HEAD", "HTML"]);
|
|
45
|
+
var MAX_TEXT = 60;
|
|
46
|
+
var CHROME = "header, nav, aside, footer, [role=banner], [role=navigation], [role=complementary], [role=contentinfo]";
|
|
47
|
+
var NAME_FROM_CONTENT = /* @__PURE__ */ new Set([
|
|
48
|
+
"button",
|
|
49
|
+
"cell",
|
|
50
|
+
"checkbox",
|
|
51
|
+
"columnheader",
|
|
52
|
+
"gridcell",
|
|
53
|
+
"heading",
|
|
54
|
+
"link",
|
|
55
|
+
"menuitem",
|
|
56
|
+
"menuitemcheckbox",
|
|
57
|
+
"menuitemradio",
|
|
58
|
+
"option",
|
|
59
|
+
"radio",
|
|
60
|
+
"row",
|
|
61
|
+
"rowheader",
|
|
62
|
+
"switch",
|
|
63
|
+
"tab",
|
|
64
|
+
"tooltip",
|
|
65
|
+
"treeitem"
|
|
66
|
+
]);
|
|
67
|
+
var IMPLICIT_ROLES = /* @__PURE__ */ new Set([
|
|
68
|
+
"button",
|
|
69
|
+
"link",
|
|
70
|
+
"heading",
|
|
71
|
+
"img",
|
|
72
|
+
"textbox",
|
|
73
|
+
"searchbox",
|
|
74
|
+
"checkbox",
|
|
75
|
+
"radio",
|
|
76
|
+
"combobox"
|
|
77
|
+
]);
|
|
78
|
+
function selectorForElement(el) {
|
|
79
|
+
if (!(el instanceof Element) || SKIP.has(el.tagName) || el.closest("scenario-panel")) {
|
|
80
|
+
return void 0;
|
|
81
|
+
}
|
|
82
|
+
for (const attr of ["data-testid", "data-test-id"]) {
|
|
83
|
+
const testId = el.getAttribute(attr);
|
|
84
|
+
if (testId) return `[${attr}=${JSON.stringify(testId)}]`;
|
|
85
|
+
}
|
|
86
|
+
const role = roleOf(el);
|
|
87
|
+
const name = role && accessibleName(el, role);
|
|
88
|
+
if (role && name && name.length <= MAX_TEXT) {
|
|
89
|
+
return `role=${role}[name=${JSON.stringify(name)}]`;
|
|
90
|
+
}
|
|
91
|
+
const text = ownText(el);
|
|
92
|
+
if (text && text.length <= MAX_TEXT) {
|
|
93
|
+
const selector = `text=${JSON.stringify(text)}`;
|
|
94
|
+
const check = checkReadySelector(selector, el.ownerDocument);
|
|
95
|
+
if (check.status === "match" && check.count === 1) return selector;
|
|
96
|
+
}
|
|
97
|
+
if (role) return `role=${role}`;
|
|
98
|
+
return void 0;
|
|
99
|
+
}
|
|
100
|
+
function closestReadySelector(el) {
|
|
101
|
+
for (let node = el; node && node !== node.ownerDocument.body; node = node.parentElement) {
|
|
102
|
+
const selector = selectorForElement(node);
|
|
103
|
+
if (selector) return { element: node, selector };
|
|
104
|
+
}
|
|
105
|
+
return void 0;
|
|
106
|
+
}
|
|
107
|
+
function suggestReadySelectors(root, { limit = 3 } = {}) {
|
|
108
|
+
const target = root ?? globalThis.document?.body;
|
|
109
|
+
if (!target) return [];
|
|
110
|
+
const ranked = [];
|
|
111
|
+
const seen = /* @__PURE__ */ new Set();
|
|
112
|
+
const walk = (node) => {
|
|
113
|
+
if (SKIP.has(node.tagName) || node.localName === "scenario-panel" || !isVisible(node)) return;
|
|
114
|
+
const selector = selectorForElement(node);
|
|
115
|
+
const tooShort = selector?.startsWith("text=") && selector.length < 'text=""'.length + 3;
|
|
116
|
+
if (selector && !tooShort && !seen.has(selector)) {
|
|
117
|
+
seen.add(selector);
|
|
118
|
+
ranked.push({ score: scoreSelector(selector, node), selector });
|
|
119
|
+
}
|
|
120
|
+
for (const child of node.children) walk(child);
|
|
121
|
+
};
|
|
122
|
+
if (target instanceof Element) walk(target);
|
|
123
|
+
else for (const child of target.children) walk(child);
|
|
124
|
+
return ranked.toSorted((a, b) => b.score - a.score).slice(0, limit).map((r) => r.selector);
|
|
125
|
+
}
|
|
126
|
+
function checkReadySelector(selector, doc = document) {
|
|
127
|
+
const s = selector.trim();
|
|
128
|
+
if (!s || s.includes(">>")) return { status: "unchecked" };
|
|
129
|
+
const found = s.startsWith("text=") ? matchText(s.slice(5), doc) : s.startsWith("role=") ? matchRole(s.slice(5), doc) : s.startsWith("css=") || !/^[a-z-]+=/i.test(s) ? matchCss(s.replace(/^css=/, ""), doc) : void 0;
|
|
130
|
+
if (!found) return { status: "unchecked" };
|
|
131
|
+
if ("error" in found) return { status: "invalid", error: found.error };
|
|
132
|
+
const [first] = found;
|
|
133
|
+
return first ? { status: "match", count: found.length, first, visible: isShown(first) } : { status: "none" };
|
|
134
|
+
}
|
|
135
|
+
function matchText(body, doc) {
|
|
136
|
+
const quoted = unquote(body);
|
|
137
|
+
if (quoted !== void 0) {
|
|
138
|
+
return appElements(doc).filter(
|
|
139
|
+
(el) => [...el.childNodes].some(
|
|
140
|
+
(n) => n.nodeType === Node.TEXT_NODE && collapse(n.textContent ?? "") === quoted
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (body.startsWith("/")) return void 0;
|
|
145
|
+
const needle = collapse(body).toLowerCase();
|
|
146
|
+
if (!needle) return void 0;
|
|
147
|
+
const has = (el) => collapse(el.textContent ?? "").toLowerCase().includes(needle);
|
|
148
|
+
return appElements(doc).filter((el) => has(el) && ![...el.children].some(has));
|
|
149
|
+
}
|
|
150
|
+
function matchRole(body, doc) {
|
|
151
|
+
const m = /^([a-z]+)(?:\[name=(".*")\])?$/.exec(body);
|
|
152
|
+
if (!m) return void 0;
|
|
153
|
+
const [, role, rawName] = m;
|
|
154
|
+
const name = rawName === void 0 ? void 0 : unquote(rawName);
|
|
155
|
+
if (rawName !== void 0 && name === void 0) return void 0;
|
|
156
|
+
const found = appElements(doc).filter(
|
|
157
|
+
(el) => roleOf(el) === role && isShown(el) && !el.closest("[aria-hidden=true]") && (name === void 0 || accessibleName(el, role) === name)
|
|
158
|
+
);
|
|
159
|
+
if (!found.length && !IMPLICIT_ROLES.has(role)) return void 0;
|
|
160
|
+
return found;
|
|
161
|
+
}
|
|
162
|
+
function matchCss(css, doc) {
|
|
163
|
+
try {
|
|
164
|
+
return [...doc.querySelectorAll(css)].filter((el) => !el.closest("scenario-panel"));
|
|
165
|
+
} catch {
|
|
166
|
+
return { error: "Not a valid CSS selector" };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function unquote(s) {
|
|
170
|
+
if (!s.startsWith('"') || !s.endsWith('"') || s.length < 2) return void 0;
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(s);
|
|
173
|
+
} catch {
|
|
174
|
+
return void 0;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function appElements(doc) {
|
|
178
|
+
const out = [];
|
|
179
|
+
const walk = (el) => {
|
|
180
|
+
if (SKIP.has(el.tagName) || el.localName === "scenario-panel") return;
|
|
181
|
+
out.push(el);
|
|
182
|
+
for (const child of el.children) walk(child);
|
|
183
|
+
};
|
|
184
|
+
if (doc.body) walk(doc.body);
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
function scoreSelector(selector, el) {
|
|
188
|
+
let score = 0;
|
|
189
|
+
if (selector.startsWith("[data-test")) score += 100;
|
|
190
|
+
else if (selector.startsWith("role=") && selector.includes("[name=")) score += 80;
|
|
191
|
+
else if (selector.startsWith("text=")) score += 60;
|
|
192
|
+
else if (selector.startsWith("role=")) score += 40;
|
|
193
|
+
if (el.children.length === 0) score += 10;
|
|
194
|
+
if (el.tagName === "H1" || el.tagName === "H2" || el.tagName === "H3") score += 15;
|
|
195
|
+
const role = el.getAttribute("role");
|
|
196
|
+
if (role === "alert" || role === "status") score += 20;
|
|
197
|
+
if (el.closest(CHROME)) score -= 50;
|
|
198
|
+
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT") score -= 50;
|
|
199
|
+
return score;
|
|
200
|
+
}
|
|
201
|
+
function roleOf(el) {
|
|
202
|
+
return el.getAttribute("role")?.trim().split(/\s+/)[0] || implicitRole(el);
|
|
203
|
+
}
|
|
204
|
+
function implicitRole(el) {
|
|
205
|
+
const tag = el.tagName;
|
|
206
|
+
if (tag === "BUTTON") return "button";
|
|
207
|
+
if (tag === "A" && el.hasAttribute("href")) return "link";
|
|
208
|
+
if (tag === "H1" || tag === "H2" || tag === "H3" || tag === "H4" || tag === "H5" || tag === "H6") {
|
|
209
|
+
return "heading";
|
|
210
|
+
}
|
|
211
|
+
if (tag === "IMG") return "img";
|
|
212
|
+
if (tag === "INPUT") {
|
|
213
|
+
const type = (el.getAttribute("type") ?? "text").toLowerCase();
|
|
214
|
+
if (type === "submit" || type === "button") return "button";
|
|
215
|
+
if (type === "checkbox") return "checkbox";
|
|
216
|
+
if (type === "radio") return "radio";
|
|
217
|
+
if (type === "search") return "searchbox";
|
|
218
|
+
return "textbox";
|
|
219
|
+
}
|
|
220
|
+
if (tag === "TEXTAREA") return "textbox";
|
|
221
|
+
if (tag === "SELECT") return "combobox";
|
|
222
|
+
return void 0;
|
|
223
|
+
}
|
|
224
|
+
function accessibleName(el, role) {
|
|
225
|
+
const labelledBy = el.getAttribute("aria-labelledby");
|
|
226
|
+
if (labelledBy) {
|
|
227
|
+
const text = collapse(
|
|
228
|
+
labelledBy.split(/\s+/).map((id) => el.ownerDocument.getElementById(id)?.textContent ?? "").join(" ")
|
|
229
|
+
);
|
|
230
|
+
if (text) return text;
|
|
231
|
+
}
|
|
232
|
+
const label = collapse(el.getAttribute("aria-label") ?? "");
|
|
233
|
+
if (label) return label;
|
|
234
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement) {
|
|
235
|
+
const fromLabel = collapse([...el.labels ?? []].map((l) => l.textContent ?? "").join(" "));
|
|
236
|
+
if (fromLabel) return fromLabel;
|
|
237
|
+
if (!(el instanceof HTMLSelectElement) && el.placeholder) return collapse(el.placeholder);
|
|
238
|
+
}
|
|
239
|
+
if (el instanceof HTMLImageElement && el.alt) return collapse(el.alt);
|
|
240
|
+
if (NAME_FROM_CONTENT.has(role)) return collapse(el.textContent ?? "") || void 0;
|
|
241
|
+
return collapse(el.getAttribute("title") ?? "") || void 0;
|
|
242
|
+
}
|
|
243
|
+
function ownText(el) {
|
|
244
|
+
for (const n of el.childNodes) {
|
|
245
|
+
const text = n.nodeType === Node.TEXT_NODE ? collapse(n.textContent ?? "") : "";
|
|
246
|
+
if (text) return text;
|
|
247
|
+
}
|
|
248
|
+
return el.children.length === 1 ? ownText(el.children[0]) : "";
|
|
249
|
+
}
|
|
250
|
+
function collapse(s) {
|
|
251
|
+
return s.replace(/\s+/g, " ").trim();
|
|
252
|
+
}
|
|
253
|
+
function isVisible(el) {
|
|
254
|
+
if (!(el instanceof HTMLElement)) return true;
|
|
255
|
+
const style = el.ownerDocument.defaultView?.getComputedStyle(el);
|
|
256
|
+
if (style && (style.display === "none" || style.visibility === "hidden")) return false;
|
|
257
|
+
if (el.hidden || el.getAttribute("aria-hidden") === "true") return false;
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
function isShown(el) {
|
|
261
|
+
for (let node = el; node; node = node.parentElement) {
|
|
262
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
263
|
+
if (node.hidden) return false;
|
|
264
|
+
const style = node.ownerDocument.defaultView?.getComputedStyle(node);
|
|
265
|
+
if (style && (style.display === "none" || style.visibility === "hidden")) return false;
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export {
|
|
271
|
+
captureScenario,
|
|
272
|
+
selectorForElement,
|
|
273
|
+
closestReadySelector,
|
|
274
|
+
suggestReadySelectors,
|
|
275
|
+
checkReadySelector
|
|
276
|
+
};
|
|
277
|
+
//# sourceMappingURL=chunk-FTWYRB6F.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/capture.ts","../src/ready.ts"],"sourcesContent":["import { diffScenario } from './catalog.js';\nimport { PARAM, RECORD_PARAM, STRICT_PARAM } from './link.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 url: Record<string, string> = {};\n new URLSearchParams(location.search).forEach((v, k) => {\n if (!SCENARIO_PARAMS.has(k)) url[k] = v;\n });\n\n const state = Object.fromEntries(\n session.keys().flatMap((key) => {\n const value = session.read(key);\n\n return value === undefined ? [] : [[key, 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 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","const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'META', 'LINK', 'HEAD', 'HTML']);\n\nconst MAX_TEXT = 60;\n\nconst CHROME =\n 'header, nav, aside, footer, [role=banner], [role=navigation], [role=complementary], [role=contentinfo]';\n\n// Roles whose accessible name comes from their text. Others (alert, status, region…) are\n// named only by aria-label/labelledby, so Playwright's [name=…] never matches their text.\nconst NAME_FROM_CONTENT = new Set([\n 'button',\n 'cell',\n 'checkbox',\n 'columnheader',\n 'gridcell',\n 'heading',\n 'link',\n 'menuitem',\n 'menuitemcheckbox',\n 'menuitemradio',\n 'option',\n 'radio',\n 'row',\n 'rowheader',\n 'switch',\n 'tab',\n 'tooltip',\n 'treeitem',\n]);\n\n// Implicit roles implicitRole knows; for any other role only an explicit role=\"…\" is checkable.\nconst IMPLICIT_ROLES = new Set([\n 'button',\n 'link',\n 'heading',\n 'img',\n 'textbox',\n 'searchbox',\n 'checkbox',\n 'radio',\n 'combobox',\n]);\n\n/** Playwright selector for an element the user marked as the scenario's ready signal. */\nexport function selectorForElement(el: Element): string | undefined {\n if (!(el instanceof Element) || SKIP.has(el.tagName) || el.closest('scenario-panel')) {\n return undefined;\n }\n\n for (const attr of ['data-testid', 'data-test-id']) {\n const testId = el.getAttribute(attr);\n\n if (testId) return `[${attr}=${JSON.stringify(testId)}]`;\n }\n\n const role = roleOf(el);\n const name = role && accessibleName(el, role);\n\n if (role && name && name.length <= MAX_TEXT) {\n return `role=${role}[name=${JSON.stringify(name)}]`;\n }\n\n const text = ownText(el);\n\n if (text && text.length <= MAX_TEXT) {\n const selector = `text=${JSON.stringify(text)}`;\n const check = checkReadySelector(selector, el.ownerDocument);\n\n if (check.status === 'match' && check.count === 1) return selector;\n }\n\n if (role) return `role=${role}`;\n\n return undefined;\n}\n\n/**\n * The nearest element at or above `el` that has a ready selector, so clicking an icon or a\n * wrapper still marks something.\n */\nexport function closestReadySelector(el: Element): { element: Element; selector: string; } | undefined {\n for (let node: Element | null = el; node && node !== node.ownerDocument.body; node = node.parentElement) {\n const selector = selectorForElement(node);\n\n if (selector) return { element: node, selector };\n }\n\n return undefined;\n}\n\n/**\n * Ranked Playwright selectors for visible, distinctive UI, offered when the user\n * hasn't marked ready. Prefers test ids, then named roles, then unique short text.\n */\nexport function suggestReadySelectors(\n root?: ParentNode | null,\n { limit = 3 }: { limit?: number; } = {},\n): string[] {\n const target = root ?? globalThis.document?.body;\n\n if (!target) return [];\n\n const ranked: { score: number; selector: string; }[] = [];\n const seen = new Set<string>();\n\n const walk = (node: Element) => {\n if (SKIP.has(node.tagName) || node.localName === 'scenario-panel' || !isVisible(node)) return;\n\n const selector = selectorForElement(node);\n // A count or a single character (\"0\" in \"0 countries\") is too generic to suggest.\n const tooShort = selector?.startsWith('text=') && selector.length < 'text=\"\"'.length + 3;\n\n if (selector && !tooShort && !seen.has(selector)) {\n seen.add(selector);\n ranked.push({ score: scoreSelector(selector, node), selector });\n }\n\n for (const child of node.children) walk(child);\n };\n\n if (target instanceof Element) walk(target);\n else for (const child of target.children) walk(child);\n\n return ranked.toSorted((a, b) => b.score - a.score).slice(0, limit).map((r) => r.selector);\n}\n\nexport type ReadyCheck =\n /** `first` is what `openScenario` waits on; `visible` says whether it's showing now. */\n | { status: 'match'; count: number; first: Element; visible: boolean; }\n | { status: 'none'; }\n /** A selector form this page can't evaluate (xpath, chains, regex…); Playwright still can. */\n | { status: 'unchecked'; }\n | { status: 'invalid'; error: string; };\n\n/**\n * Evaluate a ready selector against the live page the way Playwright would, for the forms\n * the panel generates and plain CSS. Anything else comes back `unchecked`, never a guess.\n */\nexport function checkReadySelector(selector: string, doc: Document = document): ReadyCheck {\n const s = selector.trim();\n\n if (!s || s.includes('>>')) return { status: 'unchecked' };\n\n const found = s.startsWith('text=')\n ? matchText(s.slice(5), doc)\n : s.startsWith('role=')\n ? matchRole(s.slice(5), doc)\n : s.startsWith('css=') || !/^[a-z-]+=/i.test(s)\n ? matchCss(s.replace(/^css=/, ''), doc)\n : undefined;\n\n if (!found) return { status: 'unchecked' };\n\n if ('error' in found) return { status: 'invalid', error: found.error };\n\n const [first] = found;\n\n return first\n ? { status: 'match', count: found.length, first, visible: isShown(first) }\n : { status: 'none' };\n}\n\ntype Found = Element[] | { error: string; } | undefined;\n\nfunction matchText(body: string, doc: Document): Found {\n const quoted = unquote(body);\n\n // Quoted: an element with a text node equal to it, case-sensitive.\n if (quoted !== undefined) {\n return appElements(doc).filter((el) =>\n [...el.childNodes].some((n) =>\n n.nodeType === Node.TEXT_NODE && collapse(n.textContent ?? '') === quoted\n )\n );\n }\n\n if (body.startsWith('/')) return undefined;\n\n // Unquoted: the smallest elements whose text contains it, ignoring case.\n const needle = collapse(body).toLowerCase();\n\n if (!needle) return undefined;\n\n const has = (el: Element) => collapse(el.textContent ?? '').toLowerCase().includes(needle);\n\n return appElements(doc).filter((el) => has(el) && ![...el.children].some(has));\n}\n\nfunction matchRole(body: string, doc: Document): Found {\n const m = /^([a-z]+)(?:\\[name=(\".*\")\\])?$/.exec(body);\n\n if (!m) return undefined;\n\n const [, role, rawName] = m;\n const name = rawName === undefined ? undefined : unquote(rawName);\n\n if (rawName !== undefined && name === undefined) return undefined;\n\n // Like Playwright's role engine, hidden elements don't count.\n const found = appElements(doc).filter((el) =>\n roleOf(el) === role && isShown(el) && !el.closest('[aria-hidden=true]')\n && (name === undefined || accessibleName(el, role) === name)\n );\n\n // We only know some implicit roles (<main> is \"main\", <p> is \"paragraph\"…), so a miss\n // on any other role isn't proof of absence.\n if (!found.length && !IMPLICIT_ROLES.has(role)) return undefined;\n\n return found;\n}\n\nfunction matchCss(css: string, doc: Document): Found {\n try {\n return [...doc.querySelectorAll(css)].filter((el) => !el.closest('scenario-panel'));\n } catch {\n return { error: 'Not a valid CSS selector' };\n }\n}\n\n/** A JSON-style \"quoted\" string, or undefined when `s` isn't one. */\nfunction unquote(s: string): string | undefined {\n if (!s.startsWith('\"') || !s.endsWith('\"') || s.length < 2) return undefined;\n\n try {\n return JSON.parse(s);\n } catch {\n return undefined;\n }\n}\n\nfunction appElements(doc: Document): Element[] {\n const out: Element[] = [];\n\n const walk = (el: Element) => {\n if (SKIP.has(el.tagName) || el.localName === 'scenario-panel') return;\n out.push(el);\n\n for (const child of el.children) walk(child);\n };\n\n if (doc.body) walk(doc.body);\n\n return out;\n}\n\nfunction scoreSelector(selector: string, el: Element): number {\n let score = 0;\n\n if (selector.startsWith('[data-test')) score += 100;\n else if (selector.startsWith('role=') && selector.includes('[name=')) score += 80;\n else if (selector.startsWith('text=')) score += 60;\n else if (selector.startsWith('role=')) score += 40;\n\n // Prefer leaf-ish content over giant containers.\n if (el.children.length === 0) score += 10;\n\n if (el.tagName === 'H1' || el.tagName === 'H2' || el.tagName === 'H3') score += 15;\n\n const role = el.getAttribute('role');\n\n if (role === 'alert' || role === 'status') score += 20;\n\n // Headers, navs, sidebars and inputs render in every state, so they make weak ready signals.\n if (el.closest(CHROME)) score -= 50;\n\n if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') score -= 50;\n\n return score;\n}\n\nfunction roleOf(el: Element): string | undefined {\n return el.getAttribute('role')?.trim().split(/\\s+/)[0] || implicitRole(el);\n}\n\nfunction implicitRole(el: Element): string | undefined {\n const tag = el.tagName;\n\n if (tag === 'BUTTON') return 'button';\n\n if (tag === 'A' && el.hasAttribute('href')) return 'link';\n\n if (tag === 'H1' || tag === 'H2' || tag === 'H3' || tag === 'H4' || tag === 'H5' || tag === 'H6') {\n return 'heading';\n }\n\n if (tag === 'IMG') return 'img';\n\n if (tag === 'INPUT') {\n const type = (el.getAttribute('type') ?? 'text').toLowerCase();\n\n if (type === 'submit' || type === 'button') return 'button';\n\n if (type === 'checkbox') return 'checkbox';\n\n if (type === 'radio') return 'radio';\n\n if (type === 'search') return 'searchbox';\n\n return 'textbox';\n }\n\n if (tag === 'TEXTAREA') return 'textbox';\n\n if (tag === 'SELECT') return 'combobox';\n\n return undefined;\n}\n\n/** The accessible name Playwright's `[name=…]` compares against, for the common cases. */\nfunction accessibleName(el: Element, role: string): string | undefined {\n const labelledBy = el.getAttribute('aria-labelledby');\n\n if (labelledBy) {\n const text = collapse(\n labelledBy.split(/\\s+/).map((id) => el.ownerDocument.getElementById(id)?.textContent ?? '').join(' '),\n );\n\n if (text) return text;\n }\n\n const label = collapse(el.getAttribute('aria-label') ?? '');\n\n if (label) return label;\n\n if (\n el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement\n ) {\n const fromLabel = collapse([...(el.labels ?? [])].map((l) => l.textContent ?? '').join(' '));\n\n if (fromLabel) return fromLabel;\n\n if (!(el instanceof HTMLSelectElement) && el.placeholder) return collapse(el.placeholder);\n }\n\n if (el instanceof HTMLImageElement && el.alt) return collapse(el.alt);\n\n if (NAME_FROM_CONTENT.has(role)) return collapse(el.textContent ?? '') || undefined;\n\n return collapse(el.getAttribute('title') ?? '') || undefined;\n}\n\n/** The element's first own text node, which is what a quoted `text=` selector matches. */\nfunction ownText(el: Element): string {\n for (const n of el.childNodes) {\n const text = n.nodeType === Node.TEXT_NODE ? collapse(n.textContent ?? '') : '';\n\n if (text) return text;\n }\n\n // No own text: a lone descendant text still identifies this element's content.\n return el.children.length === 1 ? ownText(el.children[0]) : '';\n}\n\nfunction collapse(s: string): string {\n return s.replace(/\\s+/g, ' ').trim();\n}\n\nfunction isVisible(el: Element): boolean {\n if (!(el instanceof HTMLElement)) return true;\n const style = el.ownerDocument.defaultView?.getComputedStyle(el);\n\n if (style && (style.display === 'none' || style.visibility === 'hidden')) return false;\n\n if (el.hidden || el.getAttribute('aria-hidden') === 'true') return false;\n\n return true;\n}\n\n/** Rendered including ancestors: what Playwright's waitFor needs. */\nfunction isShown(el: Element): boolean {\n for (let node: Element | null = el; node; node = node.parentElement) {\n if (!(node instanceof HTMLElement)) continue;\n\n if (node.hidden) return false;\n const style = node.ownerDocument.defaultView?.getComputedStyle(node);\n\n if (style && (style.display === 'none' || style.visibility === 'hidden')) return false;\n }\n\n return true;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SAVE_ENDPOINT
|
|
3
|
+
} from "./chunk-3M6SD4TA.js";
|
|
4
|
+
import {
|
|
5
|
+
JsonSchema,
|
|
6
|
+
ScenarioSchema
|
|
7
|
+
} from "./chunk-DCURPYW5.js";
|
|
8
|
+
|
|
9
|
+
// src/save.ts
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
11
|
+
import { createServer } from "http";
|
|
12
|
+
import { join, relative } from "path";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
var MAX_BODY = 20 * 1024 * 1024;
|
|
15
|
+
var SaveRequestSchema = z.object({ scenario: JsonSchema, overwrite: z.boolean().optional() });
|
|
16
|
+
async function handleSaveRequest(request, { dir, root, origins }) {
|
|
17
|
+
const display = (file2) => relative(root, file2).replaceAll("\\", "/");
|
|
18
|
+
const reply = (status, body2) => Response.json(body2, { status });
|
|
19
|
+
if (!allowedOrigin(request.headers, origins)) {
|
|
20
|
+
return reply(403, { error: "Save refused: only the app on localhost may write files" });
|
|
21
|
+
}
|
|
22
|
+
if (request.method === "GET") return reply(200, { dir: display(dir) });
|
|
23
|
+
if (request.method !== "POST") return reply(405, { error: "Use GET or POST" });
|
|
24
|
+
if (!request.headers.get("content-type")?.startsWith("application/json")) {
|
|
25
|
+
return reply(415, { error: "Send application/json" });
|
|
26
|
+
}
|
|
27
|
+
let body;
|
|
28
|
+
try {
|
|
29
|
+
body = SaveRequestSchema.parse(JSON.parse(await readCapped(request)));
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return reply(400, { error: err instanceof z.ZodError ? z.prettifyError(err) : String(err) });
|
|
32
|
+
}
|
|
33
|
+
const parsed = ScenarioSchema.safeParse(body.scenario);
|
|
34
|
+
if (!parsed.success) return reply(400, { error: z.prettifyError(parsed.error) });
|
|
35
|
+
const { name } = parsed.data;
|
|
36
|
+
if (name.includes(",")) return reply(400, { error: "A saved scenario name can't contain a comma" });
|
|
37
|
+
const file = join(dir, `${name}.json`);
|
|
38
|
+
if (existsSync(file) && !body.overwrite) {
|
|
39
|
+
return reply(409, { error: `${display(file)} already exists`, path: display(file) });
|
|
40
|
+
}
|
|
41
|
+
mkdirSync(dir, { recursive: true });
|
|
42
|
+
writeFileSync(file, `${JSON.stringify(body.scenario, null, 2)}
|
|
43
|
+
`);
|
|
44
|
+
return reply(200, { path: display(file), file });
|
|
45
|
+
}
|
|
46
|
+
function createSaveHandler(target) {
|
|
47
|
+
return async (req, res) => {
|
|
48
|
+
const origin = req.headers.origin;
|
|
49
|
+
if (origin && target.origins?.includes(origin)) {
|
|
50
|
+
res.setHeader("access-control-allow-origin", origin);
|
|
51
|
+
res.setHeader("vary", "origin");
|
|
52
|
+
}
|
|
53
|
+
if (req.method === "OPTIONS") {
|
|
54
|
+
res.setHeader("access-control-allow-methods", "GET, POST");
|
|
55
|
+
res.setHeader("access-control-allow-headers", "content-type");
|
|
56
|
+
res.statusCode = 204;
|
|
57
|
+
res.end();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
let response;
|
|
61
|
+
try {
|
|
62
|
+
response = isLoopbackAddress(req.socket.remoteAddress) ? await handleSaveRequest(toRequest(req), target) : Response.json({ error: "Save refused: only this machine may write files" }, { status: 403 });
|
|
63
|
+
} catch (err) {
|
|
64
|
+
response = Response.json({ error: String(err) }, { status: 500 });
|
|
65
|
+
}
|
|
66
|
+
res.statusCode = response.status;
|
|
67
|
+
response.headers.forEach((value, key) => res.setHeader(key, value));
|
|
68
|
+
res.end(await response.text());
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function startSaveServer(target) {
|
|
72
|
+
const handle = createSaveHandler(target);
|
|
73
|
+
const server = createServer((req, res) => void handle(req, res));
|
|
74
|
+
return new Promise((done, fail) => {
|
|
75
|
+
server.once("error", fail);
|
|
76
|
+
server.listen(0, "127.0.0.1", () => {
|
|
77
|
+
const { port } = z.object({ port: z.number() }).parse(server.address());
|
|
78
|
+
server.unref();
|
|
79
|
+
done(`http://127.0.0.1:${port}${SAVE_ENDPOINT}`);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function isLoopbackAddress(address) {
|
|
84
|
+
if (!address) return false;
|
|
85
|
+
const ip = address.replace(/^::ffff:/, "");
|
|
86
|
+
return ip === "::1" || /^127(\.\d{1,3}){3}$/.test(ip);
|
|
87
|
+
}
|
|
88
|
+
function allowedOrigin(headers, origins) {
|
|
89
|
+
const host = headers.get("host") ?? "";
|
|
90
|
+
if (!isLoopbackHost(host)) return false;
|
|
91
|
+
const origin = headers.get("origin");
|
|
92
|
+
if (!origin) return true;
|
|
93
|
+
if (origins) return origins.includes(origin);
|
|
94
|
+
try {
|
|
95
|
+
return new URL(origin).host === host;
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function isLoopbackHost(host) {
|
|
101
|
+
let hostname;
|
|
102
|
+
try {
|
|
103
|
+
hostname = new URL(`http://${host}`).hostname;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return hostname === "localhost" || hostname.endsWith(".localhost") || hostname === "[::1]" || /^127(\.\d{1,3}){3}$/.test(hostname);
|
|
108
|
+
}
|
|
109
|
+
function toRequest(req) {
|
|
110
|
+
const headers = new Headers();
|
|
111
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);
|
|
112
|
+
const method = req.method ?? "GET";
|
|
113
|
+
const body = method === "GET" || method === "HEAD" ? void 0 : new ReadableStream({
|
|
114
|
+
start(controller) {
|
|
115
|
+
req.on("data", (chunk) => controller.enqueue(new Uint8Array(chunk)));
|
|
116
|
+
req.on("end", () => controller.close());
|
|
117
|
+
req.on("error", (err) => controller.error(err));
|
|
118
|
+
},
|
|
119
|
+
cancel() {
|
|
120
|
+
req.destroy();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
const init = { method, headers, body, duplex: "half" };
|
|
124
|
+
return new Request(new URL(req.url ?? "/", `http://${headers.get("host") ?? "localhost"}`), init);
|
|
125
|
+
}
|
|
126
|
+
async function readCapped(request) {
|
|
127
|
+
const reader = request.body?.getReader();
|
|
128
|
+
if (!reader) return "";
|
|
129
|
+
const chunks = [];
|
|
130
|
+
let size = 0;
|
|
131
|
+
for (; ; ) {
|
|
132
|
+
const { done, value } = await reader.read();
|
|
133
|
+
if (done) break;
|
|
134
|
+
size += value.length;
|
|
135
|
+
if (size > MAX_BODY) {
|
|
136
|
+
await reader.cancel();
|
|
137
|
+
throw new Error("Scenario too large");
|
|
138
|
+
}
|
|
139
|
+
chunks.push(value);
|
|
140
|
+
}
|
|
141
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export {
|
|
145
|
+
createSaveHandler,
|
|
146
|
+
startSaveServer
|
|
147
|
+
};
|
|
148
|
+
//# sourceMappingURL=chunk-HEVJQ47T.js.map
|