state-scenarios-cli 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +6 -1
  2. package/dist/cli.js +194 -10
  3. package/dist/cli.js.map +1 -1
  4. package/dist/compare-TBRNNO4A.js +69 -0
  5. package/dist/compare-TBRNNO4A.js.map +1 -0
  6. package/dist/compare.d.ts +14 -0
  7. package/dist/map-NAV75ZEE.js +159 -0
  8. package/dist/map-NAV75ZEE.js.map +1 -0
  9. package/dist/map.d.ts +73 -0
  10. package/dist/publish-K6YWPOCO.js +80 -0
  11. package/dist/publish-K6YWPOCO.js.map +1 -0
  12. package/dist/publish.d.ts +18 -0
  13. package/dist/{record-SNQF63Y7.js → record-QUE3LDEG.js} +24 -1
  14. package/dist/record-QUE3LDEG.js.map +1 -0
  15. package/dist/record.d.ts +8 -1
  16. package/dist/{skills-NQRJHKHG.js → skills-P5SE3XSV.js} +7 -2
  17. package/dist/skills-P5SE3XSV.js.map +1 -0
  18. package/package.json +3 -3
  19. package/skills/create-scenario-map/SKILL.md +24 -4
  20. package/skills/scenario-coverage-loop/SKILL.md +122 -0
  21. package/skills/scenario-coverage-loop/references/controller.mjs +38 -0
  22. package/skills/scenario-coverage-loop/references/memory.md +13 -0
  23. package/skills/scenario-coverage-loop/references/pr-check.yml +50 -0
  24. package/skills/scenario-coverage-loop/references/prompt.md +58 -0
  25. package/skills/scenario-coverage-loop/references/workflow.yml +232 -0
  26. package/skills/scenario-pr/SKILL.md +114 -0
  27. package/skills/scenario-pr/references/pr-template.md +61 -0
  28. package/skills/state-scenarios/SKILL.md +12 -4
  29. package/skills/state-scenarios/references/media.md +7 -4
  30. package/skills/state-scenarios/references/verify.md +55 -3
  31. package/dist/record-SNQF63Y7.js.map +0 -1
  32. package/dist/skills-NQRJHKHG.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/compare.ts"],"sourcesContent":["import type { Browser } from '@playwright/test';\n\nexport interface ImageComparison {\n /** Pixels that differ, counting area only one image covers. */\n changed: number;\n total: number;\n /** before | after | diff, side by side, when anything changed. */\n image?: Buffer;\n}\n\n/**\n * Compare two PNG screenshots in the browser `shoot` already has open, so there's no image\n * library to install. A pixel counts as changed when its RGBA channels differ by more than\n * `tolerance` in total, which ignores the odd anti-aliasing step.\n */\nexport async function compareImages(\n browser: Browser,\n before: Buffer,\n after: Buffer,\n tolerance = 48,\n): Promise<ImageComparison> {\n const page = await browser.newPage();\n\n try {\n const result = await page.evaluate(async ({ before, after, tolerance }) => {\n const load = async (base64: string) => {\n const image = new Image();\n image.src = `data:image/png;base64,${base64}`;\n await image.decode();\n\n return image;\n };\n\n const [a, b] = await Promise.all([load(before), load(after)]);\n const width = Math.max(a.width, b.width);\n const height = Math.max(a.height, b.height);\n\n const pixels = (image: HTMLImageElement) => {\n const canvas = new OffscreenCanvas(width, height);\n const context = canvas.getContext('2d');\n\n if (!context) throw new Error('No 2D canvas');\n context.drawImage(image, 0, 0);\n\n return context.getImageData(0, 0, width, height).data;\n };\n\n const pa = pixels(a);\n const pb = pixels(b);\n const diff = new ImageData(width, height);\n let changed = 0;\n\n for (let i = 0; i < pa.length; i += 4) {\n const x = (i / 4) % width;\n const y = Math.floor(i / 4 / width);\n const outside = x >= a.width || y >= a.height || x >= b.width || y >= b.height;\n\n const distance = Math.abs(pa[i] - pb[i]) + Math.abs(pa[i + 1] - pb[i + 1])\n + Math.abs(pa[i + 2] - pb[i + 2]) + Math.abs(pa[i + 3] - pb[i + 3]);\n\n if (outside || distance > tolerance) {\n changed++;\n diff.data.set([255, 0, 64, 255], i);\n } else {\n // Unchanged pixels show faintly, so the red reads against the page.\n const grey = 255 - (255 - (pb[i] + pb[i + 1] + pb[i + 2]) / 3) * 0.2;\n diff.data.set([grey, grey, grey, 255], i);\n }\n }\n\n if (!changed) return { changed, total: width * height };\n\n const gap = 16;\n const label = 28;\n const sheet = new OffscreenCanvas(width * 3 + gap * 2, height + label);\n const context = sheet.getContext('2d');\n\n if (!context) throw new Error('No 2D canvas');\n context.fillStyle = '#fff';\n context.fillRect(0, 0, sheet.width, sheet.height);\n context.fillStyle = '#111';\n context.font = '600 16px system-ui, sans-serif';\n\n for (const [i, text] of ['Before', 'After', 'Changed'].entries()) {\n context.fillText(text, i * (width + gap), 19);\n }\n\n context.drawImage(a, 0, label);\n context.drawImage(b, width + gap, label);\n context.putImageData(diff, (width + gap) * 2, label);\n\n const blob = await sheet.convertToBlob({ type: 'image/png' });\n const bytes = new Uint8Array(await blob.arrayBuffer());\n let binary = '';\n\n for (const byte of bytes) binary += String.fromCharCode(byte);\n\n return { changed, total: width * height, image: btoa(binary) };\n }, { before: before.toString('base64'), after: after.toString('base64'), tolerance });\n\n return { ...result, image: result.image ? Buffer.from(result.image, 'base64') : undefined };\n } finally {\n await page.close();\n }\n}\n"],"mappings":"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","names":["before","after","tolerance","context"]}
@@ -0,0 +1,14 @@
1
+ import type { Browser } from '@playwright/test';
2
+ export interface ImageComparison {
3
+ /** Pixels that differ, counting area only one image covers. */
4
+ changed: number;
5
+ total: number;
6
+ /** before | after | diff, side by side, when anything changed. */
7
+ image?: Buffer;
8
+ }
9
+ /**
10
+ * Compare two PNG screenshots in the browser `shoot` already has open, so there's no image
11
+ * library to install. A pixel counts as changed when its RGBA channels differ by more than
12
+ * `tolerance` in total, which ignores the odd anti-aliasing step.
13
+ */
14
+ export declare function compareImages(browser: Browser, before: Buffer, after: Buffer, tolerance?: number): Promise<ImageComparison>;
@@ -0,0 +1,159 @@
1
+ // src/map.ts
2
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
3
+ import { join } from "path";
4
+ import { z } from "zod";
5
+ var cells = (line) => line.trim().replace(/^\||\|$/g, "").split(/(?<!\\)\|/).map((c) => c.trim().replaceAll("`", ""));
6
+ var statusOf = (text) => {
7
+ const s = text.toLowerCase().replace(/[^a-z]/g, "");
8
+ if (s === "covered") return "covered";
9
+ if (s === "missing") return "missing";
10
+ if (s === "cantmock") return "cant-mock";
11
+ return "other";
12
+ };
13
+ function parseMap(text, file) {
14
+ const rows = [];
15
+ const lines = text.split("\n");
16
+ let columns;
17
+ for (const [i, line] of lines.entries()) {
18
+ if (!line.trim().startsWith("|")) {
19
+ columns = void 0;
20
+ continue;
21
+ }
22
+ const row = cells(line);
23
+ if (row.every((c) => /^:?-+:?$/.test(c))) continue;
24
+ if (!columns) {
25
+ const names = row.map((c) => c.toLowerCase());
26
+ columns = names.includes("scenario") && names.includes("status") ? names : [];
27
+ continue;
28
+ }
29
+ if (!columns.length) continue;
30
+ const get = (name) => row[columns?.indexOf(name) ?? -1] ?? "";
31
+ const scenario = get("scenario");
32
+ rows.push({
33
+ feature: get("feature"),
34
+ route: get("route"),
35
+ state: get("state"),
36
+ scenario: /^[a-z0-9][a-z0-9,-]*$/.test(scenario) ? scenario : "",
37
+ status: statusOf(get("status")),
38
+ source: get("source").split(/[\s,]+/).filter(Boolean),
39
+ at: `${file}:${i + 1}`
40
+ });
41
+ }
42
+ return rows;
43
+ }
44
+ function findMapFiles(paths, cwd = ".") {
45
+ const skills = join(cwd, ".agents/skills");
46
+ const roots = paths ?? (existsSync(skills) ? readdirSync(skills).filter((d) => d.startsWith("scenarios-")).map((d) => join(skills, d)) : []);
47
+ const walk = (path) => statSync(path).isDirectory() ? readdirSync(path).flatMap((f) => walk(join(path, f))) : path.endsWith(".md") ? [path] : [];
48
+ return roots.flatMap(walk);
49
+ }
50
+ var readMap = (files) => files.flatMap((f) => parseMap(readFileSync(f, "utf8"), f));
51
+ var bases = (catalog) => new Set(catalog.list.flatMap((s) => [s.extends ?? []].flat()));
52
+ function coverage(rows, catalog) {
53
+ const stale = rows.filter((r) => r.status === "covered" && !catalog.get(r.scenario));
54
+ const covered = rows.filter((r) => r.status === "covered").length - stale.length;
55
+ const missing = rows.filter((r) => r.status === "missing").length + stale.length;
56
+ const mapped = new Set(rows.flatMap((r) => r.scenario.split(",")));
57
+ const skip = bases(catalog);
58
+ return {
59
+ states: rows.length,
60
+ covered,
61
+ missing,
62
+ cantMock: rows.filter((r) => r.status === "cant-mock").length,
63
+ /** Covered over states that can be mocked; 1 when there is nothing to cover. */
64
+ ratio: covered + missing ? covered / (covered + missing) : 1,
65
+ missingStates: rows.filter((r) => r.status === "missing"),
66
+ stale,
67
+ unmapped: catalog.list.map((s) => s.name).filter((n) => !mapped.has(n) && !skip.has(n))
68
+ };
69
+ }
70
+ var isSource = (path) => /\.(tsx?|jsx?|mjs|cjs|vue|svelte|astro|css|scss|less|html)$/.test(path);
71
+ function endpointStem(path) {
72
+ const local = path.replace(/^[a-z]+:\/\/[^/]+/i, "").split("?")[0] ?? "";
73
+ const segments = [];
74
+ for (const s of local.split("/").filter(Boolean)) {
75
+ if (s.startsWith(":") || s.includes("*")) break;
76
+ segments.push(s);
77
+ }
78
+ const specific = segments.some((s) => !/^(api|v\d+|graphql)$/i.test(s));
79
+ return specific ? `/${segments.join("/")}` : void 0;
80
+ }
81
+ var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82
+ function affectedScenarios({ changed, dir, catalog, rows }) {
83
+ const found = /* @__PURE__ */ new Map();
84
+ const matched = /* @__PURE__ */ new Set();
85
+ const add = (name, reason, file) => {
86
+ if (!catalog.get(name)) return;
87
+ const reasons = found.get(name) ?? [];
88
+ if (!reasons.some((r) => r.kind === reason.kind && r.detail === reason.detail)) reasons.push(reason);
89
+ found.set(name, reasons);
90
+ if (file) matched.add(file);
91
+ };
92
+ const folder = `${dir.replace(/^\.\//, "").replace(/\/$/, "")}/`;
93
+ for (const file of changed) {
94
+ if (!file.path.startsWith(folder) || !file.path.endsWith(".json") || file.content === void 0) continue;
95
+ try {
96
+ const named = z.object({ name: z.string() }).safeParse(JSON.parse(file.content));
97
+ if (named.success) add(named.data.name, { kind: "scenario", detail: file.path }, file.path);
98
+ } catch {
99
+ }
100
+ }
101
+ const children = (parent) => catalog.list.filter((s) => [s.extends ?? []].flat().includes(parent)).map((s) => s.name);
102
+ const queue = [...found.keys()];
103
+ for (let parent = queue.shift(); parent !== void 0; parent = queue.shift()) {
104
+ for (const child of children(parent)) {
105
+ if (!found.has(child)) queue.push(child);
106
+ add(child, { kind: "extends", detail: parent });
107
+ }
108
+ }
109
+ const sources = changed.filter((f) => isSource(f.path));
110
+ for (const row of rows) {
111
+ for (const source of row.source) {
112
+ const prefix = source.replace(/^\.\//, "");
113
+ for (const file of sources) {
114
+ if (file.path === prefix || file.path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`)) {
115
+ for (const name of row.scenario.split(",").filter(Boolean)) {
116
+ add(name, { kind: "map", detail: `${row.feature || row.state} (${source})` }, file.path);
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ for (const s of catalog.list) {
123
+ const resolved = catalog.resolve(s);
124
+ for (const entry of resolved.network ?? []) {
125
+ const stem = endpointStem(entry.path);
126
+ if (!stem) continue;
127
+ const pattern = new RegExp(`${escape(stem)}(?![\\w-])`);
128
+ for (const file of sources) {
129
+ if (file.content && pattern.test(file.content)) {
130
+ add(s.name, { kind: "endpoint", detail: `${file.path} calls ${stem}` }, file.path);
131
+ }
132
+ }
133
+ }
134
+ const route = resolved.path?.split(/[/?#]/).find((p) => p.length >= 3 && !p.startsWith(":"))?.toLowerCase();
135
+ if (!route) continue;
136
+ for (const file of sources) {
137
+ const parts = file.path.toLowerCase().split("/").map((p) => p.replace(/\.[^.]+$/, ""));
138
+ if (parts.includes(route)) {
139
+ add(s.name, { kind: "route", detail: `${file.path} is under /${route}` }, file.path);
140
+ }
141
+ }
142
+ }
143
+ const strength = ["scenario", "extends", "map", "endpoint", "route"];
144
+ const rank = (reasons) => Math.min(...reasons.map((r) => strength.indexOf(r.kind)));
145
+ return {
146
+ scenarios: [...found].map(([name, reasons]) => ({ name, reasons })).toSorted((a, b) => rank(a.reasons) - rank(b.reasons) || a.name.localeCompare(b.name)),
147
+ // Deleted files included: removing a shared stylesheet or component can change every state.
148
+ unmatched: sources.filter((f) => !matched.has(f.path)).map((f) => f.path)
149
+ };
150
+ }
151
+ export {
152
+ affectedScenarios,
153
+ coverage,
154
+ endpointStem,
155
+ findMapFiles,
156
+ parseMap,
157
+ readMap
158
+ };
159
+ //# sourceMappingURL=map-NAV75ZEE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/map.ts"],"sourcesContent":["import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';\nimport { join } from 'node:path';\nimport type { Catalog } from 'state-scenarios';\nimport { z } from 'zod';\n\n/**\n * One row of a scenario map: the tables `/create-scenario-map` writes into\n * `.agents/skills/scenarios-<app>/`. Any Markdown table with Scenario and Status columns counts;\n * Feature, Route, State and Source are optional.\n */\nexport interface MapRow {\n feature: string;\n route: string;\n state: string;\n /** Empty when the state has no scenario yet. */\n scenario: string;\n status: 'covered' | 'missing' | 'cant-mock' | 'other';\n /** Files or folders that render this state, as paths from the project root. */\n source: string[];\n /** The map file and line, so a reader can fix the row. */\n at: string;\n}\n\nconst cells = (line: string) =>\n line.trim().replace(/^\\||\\|$/g, '').split(/(?<!\\\\)\\|/).map((c) => c.trim().replaceAll('`', ''));\n\nconst statusOf = (text: string): MapRow['status'] => {\n const s = text.toLowerCase().replace(/[^a-z]/g, '');\n\n if (s === 'covered') return 'covered';\n\n if (s === 'missing') return 'missing';\n\n if (s === 'cantmock') return 'cant-mock';\n\n return 'other';\n};\n\n/** Rows of every map table in one Markdown file. */\nexport function parseMap(text: string, file: string): MapRow[] {\n const rows: MapRow[] = [];\n const lines = text.split('\\n');\n let columns: string[] | undefined;\n\n for (const [i, line] of lines.entries()) {\n if (!line.trim().startsWith('|')) {\n columns = undefined;\n continue;\n }\n\n const row = cells(line);\n\n if (row.every((c) => /^:?-+:?$/.test(c))) continue;\n\n if (!columns) {\n const names = row.map((c) => c.toLowerCase());\n // Only a table with both columns is a map; other tables in the file are prose.\n columns = names.includes('scenario') && names.includes('status') ? names : [];\n continue;\n }\n\n if (!columns.length) continue;\n\n const get = (name: string) => row[columns?.indexOf(name) ?? -1] ?? '';\n const scenario = get('scenario');\n\n rows.push({\n feature: get('feature'),\n route: get('route'),\n state: get('state'),\n scenario: /^[a-z0-9][a-z0-9,-]*$/.test(scenario) ? scenario : '',\n status: statusOf(get('status')),\n source: get('source').split(/[\\s,]+/).filter(Boolean),\n at: `${file}:${i + 1}`,\n });\n }\n\n return rows;\n}\n\n/** Markdown files under the given paths, or under `.agents/skills/scenarios-*` when none are given. */\nexport function findMapFiles(paths: string[] | undefined, cwd = '.'): string[] {\n const skills = join(cwd, '.agents/skills');\n\n const roots = paths\n ?? (existsSync(skills)\n ? readdirSync(skills).filter((d) => d.startsWith('scenarios-')).map((d) => join(skills, d))\n : []);\n\n const walk = (path: string): string[] =>\n statSync(path).isDirectory()\n ? readdirSync(path).flatMap((f) => walk(join(path, f)))\n : path.endsWith('.md')\n ? [path]\n : [];\n\n return roots.flatMap(walk);\n}\n\nexport const readMap = (files: string[]) => files.flatMap((f) => parseMap(readFileSync(f, 'utf8'), f));\n\n/** Scenarios another scenario extends: bases such as `default`, not states of their own. */\nconst bases = (catalog: Catalog) => new Set(catalog.list.flatMap((s) => [s.extends ?? []].flat()));\n\n/**\n * How much of the map has scenarios. `stale` rows name a scenario the folder doesn't have;\n * `unmapped` scenarios are states no row mentions (bases excluded).\n */\nexport function coverage(rows: MapRow[], catalog: Catalog) {\n const stale = rows.filter((r) => r.status === 'covered' && !catalog.get(r.scenario));\n const covered = rows.filter((r) => r.status === 'covered').length - stale.length;\n // A stale row's state has no scenario, so it counts as missing.\n const missing = rows.filter((r) => r.status === 'missing').length + stale.length;\n const mapped = new Set(rows.flatMap((r) => r.scenario.split(',')));\n const skip = bases(catalog);\n\n return {\n states: rows.length,\n covered,\n missing,\n cantMock: rows.filter((r) => r.status === 'cant-mock').length,\n /** Covered over states that can be mocked; 1 when there is nothing to cover. */\n ratio: covered + missing ? covered / (covered + missing) : 1,\n missingStates: rows.filter((r) => r.status === 'missing'),\n stale,\n unmapped: catalog.list.map((s) => s.name).filter((n) => !mapped.has(n) && !skip.has(n)),\n };\n}\n\nexport interface ChangedFile {\n /** From the project root, `/`-separated. */\n path: string;\n /** Undefined for a deleted file. */\n content?: string;\n}\n\nexport interface Reason {\n kind: 'scenario' | 'extends' | 'map' | 'endpoint' | 'route';\n detail: string;\n}\n\nconst isSource = (path: string) => /\\.(tsx?|jsx?|mjs|cjs|vue|svelte|astro|css|scss|less|html)$/.test(path);\n\n/**\n * The part of an MSW path a source file would contain: no origin, cut before the first\n * `:param` or wildcard. Undefined when what's left is too generic to mean one endpoint\n * (`/`, `/api`, `/v2`).\n */\nexport function endpointStem(path: string): string | undefined {\n const local = path.replace(/^[a-z]+:\\/\\/[^/]+/i, '').split('?')[0] ?? '';\n const segments: string[] = [];\n\n for (const s of local.split('/').filter(Boolean)) {\n if (s.startsWith(':') || s.includes('*')) break;\n segments.push(s);\n }\n\n const specific = segments.some((s) => !/^(api|v\\d+|graphql)$/i.test(s));\n\n return specific ? `/${segments.join('/')}` : undefined;\n}\n\nconst escape = (s: string) => s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n\n/**\n * Which scenarios a set of changed files can affect, each with why. Strongest first: a changed\n * scenario file (and the scenarios that extend it), a map row whose Source covers the file, a file\n * that calls an endpoint the scenario mocks, a file under a folder named like the scenario's route.\n * `unmatched` lists changed source files nothing pointed at, deleted ones included; shared ones\n * (layout, theme) can change every state.\n */\nexport function affectedScenarios(\n { changed, dir, catalog, rows }: { changed: ChangedFile[]; dir: string; catalog: Catalog; rows: MapRow[]; },\n) {\n const found = new Map<string, Reason[]>();\n const matched = new Set<string>();\n\n const add = (name: string, reason: Reason, file?: string) => {\n if (!catalog.get(name)) return;\n\n const reasons = found.get(name) ?? [];\n\n if (!reasons.some((r) => r.kind === reason.kind && r.detail === reason.detail)) reasons.push(reason);\n found.set(name, reasons);\n\n if (file) matched.add(file);\n };\n\n const folder = `${dir.replace(/^\\.\\//, '').replace(/\\/$/, '')}/`;\n\n // 1. Scenario files, then everything that extends them.\n for (const file of changed) {\n if (!file.path.startsWith(folder) || !file.path.endsWith('.json') || file.content === undefined) continue;\n\n try {\n // A file without a name isn't a scenario (e.g. scenario.schema.json).\n const named = z.object({ name: z.string() }).safeParse(JSON.parse(file.content));\n\n if (named.success) add(named.data.name, { kind: 'scenario', detail: file.path }, file.path);\n } catch {\n // Invalid JSON: validate reports it.\n }\n }\n\n const children = (parent: string) =>\n catalog.list.filter((s) => [s.extends ?? []].flat().includes(parent)).map((s) => s.name);\n\n const queue = [...found.keys()];\n\n for (let parent = queue.shift(); parent !== undefined; parent = queue.shift()) {\n for (const child of children(parent)) {\n if (!found.has(child)) queue.push(child);\n add(child, { kind: 'extends', detail: parent });\n }\n }\n\n const sources = changed.filter((f) => isSource(f.path));\n\n // 2. Map rows that name the file, or a folder holding it.\n for (const row of rows) {\n for (const source of row.source) {\n const prefix = source.replace(/^\\.\\//, '');\n\n for (const file of sources) {\n if (file.path === prefix || file.path.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`)) {\n for (const name of row.scenario.split(',').filter(Boolean)) {\n add(name, { kind: 'map', detail: `${row.feature || row.state} (${source})` }, file.path);\n }\n }\n }\n }\n }\n\n // 3 and 4. What each scenario mocks and where it opens, against what changed.\n for (const s of catalog.list) {\n const resolved = catalog.resolve(s);\n\n for (const entry of resolved.network ?? []) {\n const stem = endpointStem(entry.path);\n\n if (!stem) continue;\n const pattern = new RegExp(`${escape(stem)}(?![\\\\w-])`);\n\n for (const file of sources) {\n if (file.content && pattern.test(file.content)) {\n add(s.name, { kind: 'endpoint', detail: `${file.path} calls ${stem}` }, file.path);\n }\n }\n }\n\n const route = resolved.path?.split(/[/?#]/).find((p) => p.length >= 3 && !p.startsWith(':'))\n ?.toLowerCase();\n\n if (!route) continue;\n\n for (const file of sources) {\n const parts = file.path.toLowerCase().split('/').map((p) => p.replace(/\\.[^.]+$/, ''));\n\n if (parts.includes(route)) {\n add(s.name, { kind: 'route', detail: `${file.path} is under /${route}` }, file.path);\n }\n }\n }\n\n const strength: Reason['kind'][] = ['scenario', 'extends', 'map', 'endpoint', 'route'];\n const rank = (reasons: Reason[]) => Math.min(...reasons.map((r) => strength.indexOf(r.kind)));\n\n return {\n scenarios: [...found]\n .map(([name, reasons]) => ({ name, reasons }))\n .toSorted((a, b) => rank(a.reasons) - rank(b.reasons) || a.name.localeCompare(b.name)),\n // Deleted files included: removing a shared stylesheet or component can change every state.\n unmatched: sources.filter((f) => !matched.has(f.path)).map((f) => f.path),\n };\n}\n"],"mappings":"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","names":[]}
package/dist/map.d.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { Catalog } from 'state-scenarios';
2
+ /**
3
+ * One row of a scenario map: the tables `/create-scenario-map` writes into
4
+ * `.agents/skills/scenarios-<app>/`. Any Markdown table with Scenario and Status columns counts;
5
+ * Feature, Route, State and Source are optional.
6
+ */
7
+ export interface MapRow {
8
+ feature: string;
9
+ route: string;
10
+ state: string;
11
+ /** Empty when the state has no scenario yet. */
12
+ scenario: string;
13
+ status: 'covered' | 'missing' | 'cant-mock' | 'other';
14
+ /** Files or folders that render this state, as paths from the project root. */
15
+ source: string[];
16
+ /** The map file and line, so a reader can fix the row. */
17
+ at: string;
18
+ }
19
+ /** Rows of every map table in one Markdown file. */
20
+ export declare function parseMap(text: string, file: string): MapRow[];
21
+ /** Markdown files under the given paths, or under `.agents/skills/scenarios-*` when none are given. */
22
+ export declare function findMapFiles(paths: string[] | undefined, cwd?: string): string[];
23
+ export declare const readMap: (files: string[]) => MapRow[];
24
+ /**
25
+ * How much of the map has scenarios. `stale` rows name a scenario the folder doesn't have;
26
+ * `unmapped` scenarios are states no row mentions (bases excluded).
27
+ */
28
+ export declare function coverage(rows: MapRow[], catalog: Catalog): {
29
+ states: number;
30
+ covered: number;
31
+ missing: number;
32
+ cantMock: number;
33
+ /** Covered over states that can be mocked; 1 when there is nothing to cover. */
34
+ ratio: number;
35
+ missingStates: MapRow[];
36
+ stale: MapRow[];
37
+ unmapped: string[];
38
+ };
39
+ export interface ChangedFile {
40
+ /** From the project root, `/`-separated. */
41
+ path: string;
42
+ /** Undefined for a deleted file. */
43
+ content?: string;
44
+ }
45
+ export interface Reason {
46
+ kind: 'scenario' | 'extends' | 'map' | 'endpoint' | 'route';
47
+ detail: string;
48
+ }
49
+ /**
50
+ * The part of an MSW path a source file would contain: no origin, cut before the first
51
+ * `:param` or wildcard. Undefined when what's left is too generic to mean one endpoint
52
+ * (`/`, `/api`, `/v2`).
53
+ */
54
+ export declare function endpointStem(path: string): string | undefined;
55
+ /**
56
+ * Which scenarios a set of changed files can affect, each with why. Strongest first: a changed
57
+ * scenario file (and the scenarios that extend it), a map row whose Source covers the file, a file
58
+ * that calls an endpoint the scenario mocks, a file under a folder named like the scenario's route.
59
+ * `unmatched` lists changed source files nothing pointed at, deleted ones included; shared ones
60
+ * (layout, theme) can change every state.
61
+ */
62
+ export declare function affectedScenarios({ changed, dir, catalog, rows }: {
63
+ changed: ChangedFile[];
64
+ dir: string;
65
+ catalog: Catalog;
66
+ rows: MapRow[];
67
+ }): {
68
+ scenarios: {
69
+ name: string;
70
+ reasons: Reason[];
71
+ }[];
72
+ unmatched: string[];
73
+ };
@@ -0,0 +1,80 @@
1
+ import {
2
+ CliError
3
+ } from "./chunk-GCYOLAFU.js";
4
+
5
+ // src/publish.ts
6
+ import { execFileSync } from "child_process";
7
+ import { readFileSync } from "fs";
8
+ import { basename } from "path";
9
+ var git = (args, input, env) => execFileSync("git", args, { input, env, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
10
+ var configured = (key) => {
11
+ try {
12
+ return git(["config", key]) !== "";
13
+ } catch {
14
+ return false;
15
+ }
16
+ };
17
+ function githubRepo(url) {
18
+ return /github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?\/?$/.exec(url.trim())?.[1];
19
+ }
20
+ function publishFiles({ files, folder, branch = "scenario-shots", remote = "origin" }) {
21
+ if (!/^[\w.-]+(\/[\w.-]+)*$/.test(folder) || folder.split("/").includes("..")) {
22
+ throw new CliError(
23
+ "INVALID_ARGUMENT",
24
+ `--folder must be a plain relative path, e.g. pr-42, not "${folder}"`
25
+ );
26
+ }
27
+ const repo = githubRepo(git(["remote", "get-url", remote]));
28
+ let parent;
29
+ try {
30
+ git(["fetch", "--quiet", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`]);
31
+ parent = git(["rev-parse", `refs/remotes/${remote}/${branch}^{commit}`]);
32
+ } catch {
33
+ }
34
+ const names = files.map((f) => basename(f));
35
+ const duplicate = names.find((n, i) => names.indexOf(n) !== i);
36
+ if (duplicate) throw new CliError("INVALID_ARGUMENT", `two files are named ${duplicate}`);
37
+ const blobs = files.map((file, i) => {
38
+ const sha = git(["hash-object", "-w", "--stdin"], readFileSync(file));
39
+ return `100644 blob ${sha} ${names[i]}`;
40
+ });
41
+ let tree = git(["mktree"], `${blobs.join("\n")}
42
+ `);
43
+ const parts = folder.split("/");
44
+ for (let depth = parts.length - 1; depth >= 0; depth--) {
45
+ const path = parts.slice(0, depth).join("/");
46
+ const name = parts[depth];
47
+ const existing = parent ? listTree(parent, path).filter((line) => !line.endsWith(` ${name}`)) : [];
48
+ tree = git(["mktree"], `${[...existing, `040000 tree ${tree} ${name}`].join("\n")}
49
+ `);
50
+ }
51
+ const identity = configured("user.name") && configured("user.email") ? {} : {
52
+ GIT_AUTHOR_NAME: "state-scenarios",
53
+ GIT_AUTHOR_EMAIL: "state-scenarios@users.noreply.github.com",
54
+ GIT_COMMITTER_NAME: "state-scenarios",
55
+ GIT_COMMITTER_EMAIL: "state-scenarios@users.noreply.github.com"
56
+ };
57
+ const commit = git(
58
+ ["commit-tree", tree, ...parent ? ["-p", parent] : [], "-m", `Publish ${folder}`],
59
+ void 0,
60
+ { ...process.env, ...identity }
61
+ );
62
+ git(["push", "--quiet", remote, `${commit}:refs/heads/${branch}`]);
63
+ return names.map((name) => ({
64
+ file: name,
65
+ url: repo ? `https://github.com/${repo}/blob/${commit}/${folder}/${encodeURIComponent(name)}?raw=true` : `${branch}@${commit.slice(0, 12)}:${folder}/${name}`
66
+ }));
67
+ }
68
+ function listTree(commit, path) {
69
+ try {
70
+ const out = git(["ls-tree", path ? `${commit}:${path}` : commit]);
71
+ return out ? out.split("\n") : [];
72
+ } catch {
73
+ return [];
74
+ }
75
+ }
76
+ export {
77
+ githubRepo,
78
+ publishFiles
79
+ };
80
+ //# sourceMappingURL=publish-K6YWPOCO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/publish.ts"],"sourcesContent":["import { execFileSync } from 'node:child_process';\nimport { readFileSync } from 'node:fs';\nimport { basename } from 'node:path';\nimport { CliError } from './errors.js';\n\nconst git = (args: string[], input?: string | Buffer, env?: NodeJS.ProcessEnv) =>\n execFileSync('git', args, { input, env, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();\n\nconst configured = (key: string) => {\n try {\n return git(['config', key]) !== '';\n } catch {\n return false;\n }\n};\n\n/** `owner/repo` from a GitHub remote URL (https or ssh), else undefined. */\nexport function githubRepo(url: string): string | undefined {\n return /github\\.com[:/]([^/]+\\/[^/]+?)(?:\\.git)?\\/?$/.exec(url.trim())?.[1];\n}\n\n/**\n * Commit files to `<branch>/<folder>/` and push, without touching the working tree, the index or\n * the current branch: a PR body can show the images, and the code branch stays free of them.\n * Built from git plumbing, so it works in a dirty tree and in CI. Each run adds a commit on top of\n * the branch, replacing only `<folder>`. Returns each file's URL at that commit, so links don't\n * change when the folder is published again.\n */\nexport function publishFiles(\n { files, folder, branch = 'scenario-shots', remote = 'origin' }: {\n files: string[];\n folder: string;\n branch?: string;\n remote?: string;\n },\n) {\n if (!/^[\\w.-]+(\\/[\\w.-]+)*$/.test(folder) || folder.split('/').includes('..')) {\n throw new CliError(\n 'INVALID_ARGUMENT',\n `--folder must be a plain relative path, e.g. pr-42, not \"${folder}\"`,\n );\n }\n\n const repo = githubRepo(git(['remote', 'get-url', remote]));\n\n let parent: string | undefined;\n\n try {\n git(['fetch', '--quiet', remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`]);\n parent = git(['rev-parse', `refs/remotes/${remote}/${branch}^{commit}`]);\n } catch {\n // The branch doesn't exist yet: the first commit has no parent.\n }\n\n const names = files.map((f) => basename(f));\n const duplicate = names.find((n, i) => names.indexOf(n) !== i);\n\n if (duplicate) throw new CliError('INVALID_ARGUMENT', `two files are named ${duplicate}`);\n\n const blobs = files.map((file, i) => {\n const sha = git(['hash-object', '-w', '--stdin'], readFileSync(file));\n\n return `100644 blob ${sha}\\t${names[i]}`;\n });\n\n // Nest the files under each part of the folder, innermost first.\n let tree = git(['mktree'], `${blobs.join('\\n')}\\n`);\n const parts = folder.split('/');\n\n for (let depth = parts.length - 1; depth >= 0; depth--) {\n const path = parts.slice(0, depth).join('/');\n const name = parts[depth];\n // Keep what else the branch has at this level, replacing only our entry.\n const existing = parent ? listTree(parent, path).filter((line) => !line.endsWith(`\\t${name}`)) : [];\n tree = git(['mktree'], `${[...existing, `040000 tree ${tree}\\t${name}`].join('\\n')}\\n`);\n }\n\n // CI runners often have no git identity, which commit-tree needs.\n const identity = configured('user.name') && configured('user.email') ? {} : {\n GIT_AUTHOR_NAME: 'state-scenarios',\n GIT_AUTHOR_EMAIL: 'state-scenarios@users.noreply.github.com',\n GIT_COMMITTER_NAME: 'state-scenarios',\n GIT_COMMITTER_EMAIL: 'state-scenarios@users.noreply.github.com',\n };\n\n const commit = git(\n ['commit-tree', tree, ...(parent ? ['-p', parent] : []), '-m', `Publish ${folder}`],\n undefined,\n { ...process.env, ...identity },\n );\n\n git(['push', '--quiet', remote, `${commit}:refs/heads/${branch}`]);\n\n return names.map((name) => ({\n file: name,\n url: repo\n ? `https://github.com/${repo}/blob/${commit}/${folder}/${encodeURIComponent(name)}?raw=true`\n : `${branch}@${commit.slice(0, 12)}:${folder}/${name}`,\n }));\n}\n\nfunction listTree(commit: string, path: string): string[] {\n try {\n const out = git(['ls-tree', path ? `${commit}:${path}` : commit]);\n\n return out ? out.split('\\n') : [];\n } catch {\n return [];\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,18 @@
1
+ /** `owner/repo` from a GitHub remote URL (https or ssh), else undefined. */
2
+ export declare function githubRepo(url: string): string | undefined;
3
+ /**
4
+ * Commit files to `<branch>/<folder>/` and push, without touching the working tree, the index or
5
+ * the current branch: a PR body can show the images, and the code branch stays free of them.
6
+ * Built from git plumbing, so it works in a dirty tree and in CI. Each run adds a commit on top of
7
+ * the branch, replacing only `<folder>`. Returns each file's URL at that commit, so links don't
8
+ * change when the folder is published again.
9
+ */
10
+ export declare function publishFiles({ files, folder, branch, remote }: {
11
+ files: string[];
12
+ folder: string;
13
+ branch?: string;
14
+ remote?: string;
15
+ }): {
16
+ file: string;
17
+ url: string;
18
+ }[];
@@ -102,12 +102,35 @@ async function pagePoint(frame, x, y) {
102
102
  const scaleY = box.height / layout.height;
103
103
  return { x: box.x + (layout.left + x) * scaleX, y: box.y + (layout.top + y) * scaleY };
104
104
  }
105
+ var gliding = /* @__PURE__ */ new WeakMap();
106
+ function glideBeforeClick(page, ms = 400) {
107
+ gliding.set(page, ms);
108
+ const proto = Object.getPrototypeOf(page.locator("html"));
109
+ if (proto.glides) return;
110
+ proto.glides = true;
111
+ const click = Object.getOwnPropertyDescriptor(proto, "click")?.value;
112
+ proto.click = async function(options) {
113
+ const wait = gliding.get(this.page());
114
+ const hover = { position: options?.position, force: options?.force, timeout: options?.timeout };
115
+ if (wait !== void 0) {
116
+ for (let tries = 0; tries < 3; tries++) {
117
+ await this.hover(hover);
118
+ const before = await this.boundingBox();
119
+ await this.page().waitForTimeout(wait);
120
+ const after = await this.boundingBox();
121
+ if (JSON.stringify(before) === JSON.stringify(after)) break;
122
+ }
123
+ }
124
+ return click.call(this, options);
125
+ };
126
+ }
105
127
  export {
106
128
  encodeVideo,
107
129
  ffmpegArgs,
130
+ glideBeforeClick,
108
131
  outputSize,
109
132
  pagePoint,
110
133
  videoManifest,
111
134
  visibleCrop
112
135
  };
113
- //# sourceMappingURL=record-SNQF63Y7.js.map
136
+ //# sourceMappingURL=record-QUE3LDEG.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/record.ts"],"sourcesContent":["import type { Frame, Locator, Page } from '@playwright/test';\nimport { spawnSync } from 'node:child_process';\nimport { copyFileSync } from 'node:fs';\nimport { CliError } from './errors.js';\n\nexport type VideoFormat = 'mp4' | 'gif' | 'webm';\n\nexport interface Crop {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\n/**\n * The part of an element's box the video shows. Video holds only the viewport, so a crop must\n * stay inside it; `clipped` says the element was larger than the visible area.\n */\nexport function visibleCrop(box: Crop, viewport: { width: number; height: number; }) {\n const x = Math.max(0, box.x);\n const y = Math.max(0, box.y);\n const width = Math.min(viewport.width, box.x + box.width) - x;\n const height = Math.min(viewport.height, box.y + box.height) - y;\n\n if (width < 2 || height < 2) return undefined;\n\n return { crop: { x, y, width, height }, clipped: width < box.width || height < box.height };\n}\n\nconst even = (n: number) => Math.max(2, Math.floor(n / 2) * 2);\n\n/**\n * ffmpeg arguments that trim the page load off the start, crop to an element, and encode.\n * MP4 is H.264 in yuv420p so it plays everywhere, including Safari and phones; GIF uses a\n * generated palette, which keeps it sharp at a fraction of the naive size.\n */\nexport function ffmpegArgs(\n { input, output, format, start, crop, fps, width }: {\n input: string;\n output: string;\n format: VideoFormat;\n /** Seconds to skip, e.g. the page loading before the steps begin. */\n start: number;\n crop?: Crop;\n /** Frames per second; GIFs default to 12. */\n fps?: number;\n /** Maximum output width in pixels, never upscaled; height follows the aspect ratio. */\n width?: number;\n },\n): string[] {\n const rate = fps ?? (format === 'gif' ? 12 : undefined);\n\n // yuv420p needs even sizes.\n const filters = [\n crop && `crop=${even(crop.width)}:${even(crop.height)}:${Math.round(crop.x)}:${Math.round(crop.y)}`,\n rate && `fps=${rate}`,\n // Quoted so the comma in min() isn't read as a filter separator.\n width && `scale='trunc(min(${width},iw)/2)*2':-2:flags=lanczos`,\n ].filter(Boolean);\n\n const codec = {\n mp4: ['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '23', '-movflags', '+faststart'],\n webm: ['-c:v', 'libvpx-vp9', '-b:v', '0', '-crf', '32'],\n gif: [],\n }[format];\n\n const vf = format === 'gif'\n ? `${filters.join(',')},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer`\n : filters.join(',');\n\n return [\n '-y',\n '-loglevel',\n 'error',\n '-i',\n input,\n '-ss',\n start.toFixed(2),\n ...(vf ? ['-vf', vf] : []),\n '-an',\n ...codec,\n output,\n ];\n}\n\n/**\n * Convert Playwright's WebM into the requested format. WebM without a crop, --fps or --width is\n * copied as recorded (page load included), so it works without ffmpeg.\n */\nexport function encodeVideo(options: Parameters<typeof ffmpegArgs>[0]) {\n if (options.format === 'webm' && !options.crop && !options.fps && !options.width) {\n copyFileSync(options.input, options.output);\n\n return { start: 0 };\n }\n\n // FFMPEG_PATH is the usual override when ffmpeg isn't on PATH (common on Windows).\n const ffmpeg = process.env.FFMPEG_PATH || 'ffmpeg';\n const result = spawnSync(ffmpeg, ffmpegArgs(options), { encoding: 'utf8' });\n\n if (result.error) {\n throw new CliError(\n 'FFMPEG_MISSING',\n `record needs ffmpeg for ${options.format}${\n options.format === 'webm' ? ' with --element, --fps or --width' : ''\n }: install it (https://ffmpeg.org/download.html) or set FFMPEG_PATH`,\n );\n }\n\n if (result.status !== 0) throw new Error(`ffmpeg failed: ${result.stderr.trim()}`);\n\n return { start: options.start };\n}\n\n/** The video size ffmpeg writes: the crop or viewport, capped at `width` as the scale filter does. */\nexport function outputSize(source: { width: number; height: number; }, width?: number) {\n if (!width) return source;\n\n const w = Math.trunc(Math.min(width, source.width) / 2) * 2;\n\n return { width: w, height: Math.round((w * source.height) / (source.width * 2)) * 2 };\n}\n\n/**\n * What `<name>.video.json` holds next to each video, so video editors such as Remotion can size\n * a composition, title it and zoom to clicks. Times are ms into the saved video; click positions\n * are pixels in it.\n */\nexport function videoManifest(\n { name, description, video, viewport, crop, width, start, end, clicks }: {\n name: string;\n description?: string;\n video: string;\n viewport: { width: number; height: number; };\n crop?: Crop;\n width?: number;\n /** Epoch ms where the saved video begins and ends. */\n start: number;\n end: number;\n /** Epoch ms and page coordinates of each pointerdown. */\n clicks: { at: number; x: number; y: number; }[];\n },\n) {\n const source = crop\n ? { ...crop, width: even(crop.width), height: even(crop.height) }\n : { x: 0, y: 0, ...viewport };\n\n const size = outputSize({ width: source.width, height: source.height }, width);\n const ratio = size.width / source.width;\n\n return {\n name,\n description,\n video,\n width: size.width,\n height: size.height,\n durationMs: Math.round(end - start),\n clicks: clicks\n .map((c) => ({\n t: Math.round(c.at - start),\n x: Math.round((c.x - Math.round(source.x)) * ratio),\n y: Math.round((c.y - Math.round(source.y)) * ratio),\n }))\n .filter((c) => c.t >= 0 && c.x >= 0 && c.y >= 0 && c.x < size.width && c.y < size.height),\n };\n}\n\n/**\n * A point in a frame, in page coordinates, so a click inside an iframe maps to the video.\n * Playwright measures the iframe element on the page, CSS transforms included; comparing that with\n * its layout size gives the scale for the border, padding and the point itself. Scales and\n * translations map exactly; a rotated or skewed iframe maps to its bounding box.\n */\nexport async function pagePoint(frame: Frame, x: number, y: number) {\n if (!frame.parentFrame()) return { x, y };\n\n const element = await frame.frameElement();\n\n const [box, layout] = await Promise.all([\n element.boundingBox(),\n element.evaluate((node) => {\n // frameElement() returns the <iframe> or <frame> element, so this always passes.\n if (!(node instanceof HTMLElement)) return undefined;\n\n const style = getComputedStyle(node);\n\n return {\n left: node.clientLeft + parseFloat(style.paddingLeft),\n top: node.clientTop + parseFloat(style.paddingTop),\n width: node.offsetWidth,\n height: node.offsetHeight,\n };\n }),\n ]);\n\n if (!box || !layout?.width || !layout.height) throw new Error('The iframe is not visible');\n\n const scaleX = box.width / layout.width;\n const scaleY = box.height / layout.height;\n\n return { x: box.x + (layout.left + x) * scaleX, y: box.y + (layout.top + y) * scaleY };\n}\n\nconst gliding = new WeakMap<Page, number>();\n\n/**\n * Make `locator.click()` on this page hover first and wait for the drawn cursor's glide, so videos\n * show the pointer reaching its target before the click. Playwright's click moves and presses in\n * one go, so the page would react before the cursor arrives. The Locator prototype is shared, so\n * it's patched once and other pages click as usual.\n */\nexport function glideBeforeClick(page: Page, ms = 400) {\n gliding.set(page, ms);\n\n const proto: Locator & { glides?: true; } = Object.getPrototypeOf(page.locator('html'));\n\n if (proto.glides) return;\n proto.glides = true;\n\n const click: (this: Locator, options?: Parameters<Locator['click']>[0]) => Promise<void> = Object\n .getOwnPropertyDescriptor(proto, 'click')?.value;\n\n proto.click = async function(this: Locator, options) {\n const wait = gliding.get(this.page());\n const hover = { position: options?.position, force: options?.force, timeout: options?.timeout };\n\n // A target that moves or resizes during the glide (its click point moves either way) gets\n // another one, up to three, so the cursor is there when the click lands.\n if (wait !== undefined) {\n for (let tries = 0; tries < 3; tries++) {\n await this.hover(hover);\n const before = await this.boundingBox();\n await this.page().waitForTimeout(wait);\n const after = await this.boundingBox();\n\n if (JSON.stringify(before) === JSON.stringify(after)) break;\n }\n }\n\n return click.call(this, options);\n };\n}\n"],"mappings":"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","names":[]}
package/dist/record.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Frame } from '@playwright/test';
1
+ import type { Frame, Page } from '@playwright/test';
2
2
  export type VideoFormat = 'mp4' | 'gif' | 'webm';
3
3
  export interface Crop {
4
4
  x: number;
@@ -101,3 +101,10 @@ export declare function pagePoint(frame: Frame, x: number, y: number): Promise<{
101
101
  x: number;
102
102
  y: number;
103
103
  }>;
104
+ /**
105
+ * Make `locator.click()` on this page hover first and wait for the drawn cursor's glide, so videos
106
+ * show the pointer reaching its target before the click. Playwright's click moves and presses in
107
+ * one go, so the page would react before the cursor arrives. The Locator prototype is shared, so
108
+ * it's patched once and other pages click as usual.
109
+ */
110
+ export declare function glideBeforeClick(page: Page, ms?: number): void;
@@ -18,7 +18,12 @@ import { dirname, join, relative } from "path";
18
18
  import { fileURLToPath } from "url";
19
19
  import { z } from "zod";
20
20
  var SKILLS = fileURLToPath(new URL("../skills/", import.meta.url));
21
- var SKILL_NAMES = ["state-scenarios", "create-scenario-map"];
21
+ var SKILL_NAMES = [
22
+ "state-scenarios",
23
+ "create-scenario-map",
24
+ "scenario-pr",
25
+ "scenario-coverage-loop"
26
+ ];
22
27
  var topics = () => readdirSync(join(SKILLS, "state-scenarios/references")).map((f) => f.replace(/\.md$/, "")).toSorted();
23
28
  function guide(topic) {
24
29
  if (!topic) return readFileSync(join(SKILLS, "state-scenarios/SKILL.md"), "utf8");
@@ -77,4 +82,4 @@ export {
77
82
  installSkills,
78
83
  registerMcp
79
84
  };
80
- //# sourceMappingURL=skills-NQRJHKHG.js.map
85
+ //# sourceMappingURL=skills-P5SE3XSV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/skills.ts"],"sourcesContent":["import {\n cpSync,\n existsSync,\n lstatSync,\n mkdirSync,\n readdirSync,\n readFileSync,\n rmSync,\n symlinkSync,\n writeFileSync,\n} from 'node:fs';\nimport { dirname, join, relative } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { z } from 'zod';\nimport { CliError } from './errors.js';\n\n/** Shipped in the package, so the guidance always matches the installed version. */\nconst SKILLS = fileURLToPath(new URL('../skills/', import.meta.url));\n\nexport const SKILL_NAMES = [\n 'state-scenarios',\n 'create-scenario-map',\n 'scenario-pr',\n 'scenario-coverage-loop',\n];\n\nconst topics = () =>\n readdirSync(join(SKILLS, 'state-scenarios/references')).map((f) => f.replace(/\\.md$/, '')).toSorted();\n\n/** The skill's overview, or one topic of it. */\nexport function guide(topic?: string): string {\n if (!topic) return readFileSync(join(SKILLS, 'state-scenarios/SKILL.md'), 'utf8');\n\n if (!topics().includes(topic)) {\n throw new CliError('INVALID_ARGUMENT', `Unknown topic \"${topic}\". Topics: ${topics().join(', ')}`);\n }\n\n return readFileSync(join(SKILLS, `state-scenarios/references/${topic}.md`), 'utf8');\n}\n\n/**\n * Copy the skills into `.agents/skills/` (Codex, Cursor and others read it) and link them\n * from `.claude/skills/` for Claude Code. Rerun after an upgrade to update them.\n */\nexport function installSkills(cwd: string): string[] {\n return SKILL_NAMES.map((name) => {\n const target = join(cwd, '.agents/skills', name);\n rmSync(target, { recursive: true, force: true });\n mkdirSync(dirname(target), { recursive: true });\n cpSync(join(SKILLS, name), target, { recursive: true });\n\n const link = join(cwd, '.claude/skills', name);\n mkdirSync(dirname(link), { recursive: true });\n\n if (existsSync(link) || isLink(link)) rmSync(link, { recursive: true, force: true });\n\n try {\n symlinkSync(relative(dirname(link), target), link, 'dir');\n } catch {\n // Windows without Developer Mode can't create symlinks: copy instead.\n cpSync(target, link, { recursive: true });\n }\n\n return `Installed skill ${relative(cwd, target).replaceAll('\\\\', '/')} (and .claude/skills/${name})`;\n });\n}\n\nconst isLink = (path: string) => {\n try {\n return lstatSync(path).isSymbolicLink();\n } catch {\n return false;\n }\n};\n\nconst McpConfig = z.looseObject({ mcpServers: z.record(z.string(), z.unknown()).optional() });\n\n/**\n * Register `state-scenarios mcp` with Claude Code (`.mcp.json`) and, when the project uses it,\n * Cursor (`.cursor/mcp.json`). An existing entry is kept.\n */\nexport function registerMcp(cwd: string, args: string[]): string[] {\n // Windows can't spawn npx directly from an MCP client: go through cmd.\n const server = process.platform === 'win32'\n ? { command: 'cmd', args: ['/c', 'npx', 'state-scenarios', ...args] }\n : { command: 'npx', args: ['state-scenarios', ...args] };\n\n const files = ['.mcp.json', ...(existsSync(join(cwd, '.cursor')) ? ['.cursor/mcp.json'] : [])];\n\n return files.map((file) => {\n const path = join(cwd, file);\n const config = existsSync(path) ? McpConfig.parse(JSON.parse(readFileSync(path, 'utf8'))) : {};\n\n if (config.mcpServers?.['state-scenarios']) return `Kept the state-scenarios server in ${file}`;\n writeFileSync(\n path,\n `${\n JSON.stringify(\n { ...config, mcpServers: { ...config.mcpServers, 'state-scenarios': server } },\n null,\n 2,\n )\n }\\n`,\n );\n\n return `Registered the state-scenarios MCP server in ${file}`;\n });\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "state-scenarios-cli",
3
- "version": "0.6.0",
4
- "description": "state-scenarios CLI: init, guide, doctor, validate, list, shoot, record, mcp, suggest and promote scenarios.",
3
+ "version": "0.7.0",
4
+ "description": "state-scenarios CLI: init, guide, doctor, validate, list, shoot, affected, coverage, publish, record, mcp, suggest and promote scenarios.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -29,7 +29,7 @@
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sdk": "^0.131.0",
31
31
  "ajv": "^8.20.0",
32
- "state-scenarios": "^0.4.0",
32
+ "state-scenarios": "^0.5.0",
33
33
  "zod": "4.6.5"
34
34
  },
35
35
  "peerDependencies": {
@@ -47,7 +47,7 @@ naming the app and when to use it), then:
47
47
  | Doctor | `npx state-scenarios doctor <dir> --base-url <url>`; run it first whenever something looks off |
48
48
  | Open a state | The link pattern and the MCP server; that links beat clicking through |
49
49
  | Base scenarios | What `default` and `signed-in` (or the app's equivalents) contain, and which to extend |
50
- | Map | A table: feature, route, state, scenario name, status (`covered`, `missing`, `can't mock`) |
50
+ | Map | The map table below |
51
51
  | Gotchas | What reads as broken but isn't; third parties in `ignore`; states only the server can produce |
52
52
 
53
53
  For more than a handful of features, put one file per feature under `features/` with the same
@@ -55,6 +55,25 @@ columns and link them from the map.
55
55
 
56
56
  Name scenarios `<feature>-<state>`, e.g. `orders-empty`, `orders-error`, `checkout-card-declined`.
57
57
 
58
+ The CLI reads the map, so keep these column names (order doesn't matter):
59
+
60
+ ```markdown
61
+ | Feature | Route | State | Scenario | Status | Source |
62
+ | ------- | ------- | ----- | -------------- | ---------- | ---------------------- |
63
+ | Orders | /orders | Empty | `orders-empty` | covered | `src/features/orders/` |
64
+ | Orders | /orders | Error | `orders-error` | missing | `src/features/orders/` |
65
+ | Orders | /orders | SSR | | can't mock | `app/orders/page.tsx` |
66
+ ```
67
+
68
+ - **Status** is `covered`, `missing` or `can't mock`.
69
+ - **Scenario** is the name, or the planned name for a missing state.
70
+ - **Source** lists the files or folders, from the project root, that render the state. `npx state-scenarios affected`
71
+ maps a changed file to the states it touches through this column, so be specific: the feature's
72
+ folder, not `src/`.
73
+
74
+ `npx state-scenarios coverage <dir>` counts the rows by status, and fails when a `covered` row names a
75
+ scenario that doesn't exist. Other tables in the file are ignored.
76
+
58
77
  ## 3. Fill the most important gaps
59
78
 
60
79
  Capture the missing states users hit most (empty and error for each main feature first), using
@@ -68,7 +87,8 @@ A state the browser can't produce (server-rendered data, a native permission pro
68
87
  ## 4. Prove it before handing over
69
88
 
70
89
  1. `doctor` passes.
71
- 2. `npx state-scenarios validate <dir>` passes.
90
+ 2. `npx state-scenarios validate <dir>` passes, and `npx state-scenarios coverage <dir>` exits 0 (no
91
+ row names a scenario that doesn't exist). Give every scenario it lists as unmapped a row.
72
92
  3. `npx state-scenarios shoot <dir> --base-url <url> --only <the scenarios you added>` passes. Look
73
93
  at each screenshot: it must show the state the map says.
74
94
  4. Open one state through the link pattern the map gives.
@@ -77,8 +97,8 @@ Fix what fails. A map that was never run is a draft.
77
97
 
78
98
  ## 5. Hand over
79
99
 
80
- Tell the user: the skill path, features mapped, states covered and missing, the scenarios you added,
81
- and the screenshot folder. Don't commit for them. When a feature gains a state, its map row changes
100
+ Tell the user: the skill path, features mapped, the `coverage` line (states covered and missing), the
101
+ scenarios you added, and the screenshot folder. Don't commit for them. When a feature gains a state, its map row changes
82
102
  in the same commit as the scenario.
83
103
 
84
104
  ## Rules