state-scenarios-cli 0.5.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.
- package/README.md +6 -1
- package/dist/cli.d.ts +6 -0
- package/dist/cli.js +228 -11
- package/dist/cli.js.map +1 -1
- package/dist/compare-TBRNNO4A.js +69 -0
- package/dist/compare-TBRNNO4A.js.map +1 -0
- package/dist/compare.d.ts +14 -0
- package/dist/map-NAV75ZEE.js +159 -0
- package/dist/map-NAV75ZEE.js.map +1 -0
- package/dist/map.d.ts +73 -0
- package/dist/publish-K6YWPOCO.js +80 -0
- package/dist/publish-K6YWPOCO.js.map +1 -0
- package/dist/publish.d.ts +18 -0
- package/dist/record-QUE3LDEG.js +136 -0
- package/dist/record-QUE3LDEG.js.map +1 -0
- package/dist/record.d.ts +66 -1
- package/dist/{skills-NQRJHKHG.js → skills-P5SE3XSV.js} +7 -2
- package/dist/skills-P5SE3XSV.js.map +1 -0
- package/package.json +3 -3
- package/skills/create-scenario-map/SKILL.md +24 -4
- package/skills/scenario-coverage-loop/SKILL.md +122 -0
- package/skills/scenario-coverage-loop/references/controller.mjs +38 -0
- package/skills/scenario-coverage-loop/references/memory.md +13 -0
- package/skills/scenario-coverage-loop/references/pr-check.yml +50 -0
- package/skills/scenario-coverage-loop/references/prompt.md +58 -0
- package/skills/scenario-coverage-loop/references/workflow.yml +232 -0
- package/skills/scenario-pr/SKILL.md +114 -0
- package/skills/scenario-pr/references/pr-template.md +61 -0
- package/skills/state-scenarios/SKILL.md +13 -5
- package/skills/state-scenarios/references/media.md +38 -4
- package/skills/state-scenarios/references/verify.md +55 -3
- package/dist/record-HXIYGMWA.js +0 -65
- package/dist/record-HXIYGMWA.js.map +0 -1
- package/dist/skills-NQRJHKHG.js.map +0 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// src/compare.ts
|
|
2
|
+
async function compareImages(browser, before, after, tolerance = 48) {
|
|
3
|
+
const page = await browser.newPage();
|
|
4
|
+
try {
|
|
5
|
+
const result = await page.evaluate(async ({ before: before2, after: after2, tolerance: tolerance2 }) => {
|
|
6
|
+
const load = async (base64) => {
|
|
7
|
+
const image = new Image();
|
|
8
|
+
image.src = `data:image/png;base64,${base64}`;
|
|
9
|
+
await image.decode();
|
|
10
|
+
return image;
|
|
11
|
+
};
|
|
12
|
+
const [a, b] = await Promise.all([load(before2), load(after2)]);
|
|
13
|
+
const width = Math.max(a.width, b.width);
|
|
14
|
+
const height = Math.max(a.height, b.height);
|
|
15
|
+
const pixels = (image) => {
|
|
16
|
+
const canvas = new OffscreenCanvas(width, height);
|
|
17
|
+
const context2 = canvas.getContext("2d");
|
|
18
|
+
if (!context2) throw new Error("No 2D canvas");
|
|
19
|
+
context2.drawImage(image, 0, 0);
|
|
20
|
+
return context2.getImageData(0, 0, width, height).data;
|
|
21
|
+
};
|
|
22
|
+
const pa = pixels(a);
|
|
23
|
+
const pb = pixels(b);
|
|
24
|
+
const diff = new ImageData(width, height);
|
|
25
|
+
let changed = 0;
|
|
26
|
+
for (let i = 0; i < pa.length; i += 4) {
|
|
27
|
+
const x = i / 4 % width;
|
|
28
|
+
const y = Math.floor(i / 4 / width);
|
|
29
|
+
const outside = x >= a.width || y >= a.height || x >= b.width || y >= b.height;
|
|
30
|
+
const distance = Math.abs(pa[i] - pb[i]) + Math.abs(pa[i + 1] - pb[i + 1]) + Math.abs(pa[i + 2] - pb[i + 2]) + Math.abs(pa[i + 3] - pb[i + 3]);
|
|
31
|
+
if (outside || distance > tolerance2) {
|
|
32
|
+
changed++;
|
|
33
|
+
diff.data.set([255, 0, 64, 255], i);
|
|
34
|
+
} else {
|
|
35
|
+
const grey = 255 - (255 - (pb[i] + pb[i + 1] + pb[i + 2]) / 3) * 0.2;
|
|
36
|
+
diff.data.set([grey, grey, grey, 255], i);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!changed) return { changed, total: width * height };
|
|
40
|
+
const gap = 16;
|
|
41
|
+
const label = 28;
|
|
42
|
+
const sheet = new OffscreenCanvas(width * 3 + gap * 2, height + label);
|
|
43
|
+
const context = sheet.getContext("2d");
|
|
44
|
+
if (!context) throw new Error("No 2D canvas");
|
|
45
|
+
context.fillStyle = "#fff";
|
|
46
|
+
context.fillRect(0, 0, sheet.width, sheet.height);
|
|
47
|
+
context.fillStyle = "#111";
|
|
48
|
+
context.font = "600 16px system-ui, sans-serif";
|
|
49
|
+
for (const [i, text] of ["Before", "After", "Changed"].entries()) {
|
|
50
|
+
context.fillText(text, i * (width + gap), 19);
|
|
51
|
+
}
|
|
52
|
+
context.drawImage(a, 0, label);
|
|
53
|
+
context.drawImage(b, width + gap, label);
|
|
54
|
+
context.putImageData(diff, (width + gap) * 2, label);
|
|
55
|
+
const blob = await sheet.convertToBlob({ type: "image/png" });
|
|
56
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
57
|
+
let binary = "";
|
|
58
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
59
|
+
return { changed, total: width * height, image: btoa(binary) };
|
|
60
|
+
}, { before: before.toString("base64"), after: after.toString("base64"), tolerance });
|
|
61
|
+
return { ...result, image: result.image ? Buffer.from(result.image, "base64") : void 0 };
|
|
62
|
+
} finally {
|
|
63
|
+
await page.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export {
|
|
67
|
+
compareImages
|
|
68
|
+
};
|
|
69
|
+
//# sourceMappingURL=compare-TBRNNO4A.js.map
|
|
@@ -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":";AAAA,SAAS,YAAY,aAAa,cAAc,gBAAgB;AAChE,SAAS,YAAY;AAErB,SAAS,SAAS;AAoBlB,IAAM,QAAQ,CAAC,SACb,KAAK,KAAK,EAAE,QAAQ,YAAY,EAAE,EAAE,MAAM,WAAW,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,WAAW,KAAK,EAAE,CAAC;AAEhG,IAAM,WAAW,CAAC,SAAmC;AACnD,QAAM,IAAI,KAAK,YAAY,EAAE,QAAQ,WAAW,EAAE;AAElD,MAAI,MAAM,UAAW,QAAO;AAE5B,MAAI,MAAM,UAAW,QAAO;AAE5B,MAAI,MAAM,WAAY,QAAO;AAE7B,SAAO;AACT;AAGO,SAAS,SAAS,MAAc,MAAwB;AAC7D,QAAM,OAAiB,CAAC;AACxB,QAAM,QAAQ,KAAK,MAAM,IAAI;AAC7B,MAAI;AAEJ,aAAW,CAAC,GAAG,IAAI,KAAK,MAAM,QAAQ,GAAG;AACvC,QAAI,CAAC,KAAK,KAAK,EAAE,WAAW,GAAG,GAAG;AAChC,gBAAU;AACV;AAAA,IACF;AAEA,UAAM,MAAM,MAAM,IAAI;AAEtB,QAAI,IAAI,MAAM,CAAC,MAAM,WAAW,KAAK,CAAC,CAAC,EAAG;AAE1C,QAAI,CAAC,SAAS;AACZ,YAAM,QAAQ,IAAI,IAAI,CAAC,MAAM,EAAE,YAAY,CAAC;AAE5C,gBAAU,MAAM,SAAS,UAAU,KAAK,MAAM,SAAS,QAAQ,IAAI,QAAQ,CAAC;AAC5E;AAAA,IACF;AAEA,QAAI,CAAC,QAAQ,OAAQ;AAErB,UAAM,MAAM,CAAC,SAAiB,IAAI,SAAS,QAAQ,IAAI,KAAK,EAAE,KAAK;AACnE,UAAM,WAAW,IAAI,UAAU;AAE/B,SAAK,KAAK;AAAA,MACR,SAAS,IAAI,SAAS;AAAA,MACtB,OAAO,IAAI,OAAO;AAAA,MAClB,OAAO,IAAI,OAAO;AAAA,MAClB,UAAU,wBAAwB,KAAK,QAAQ,IAAI,WAAW;AAAA,MAC9D,QAAQ,SAAS,IAAI,QAAQ,CAAC;AAAA,MAC9B,QAAQ,IAAI,QAAQ,EAAE,MAAM,QAAQ,EAAE,OAAO,OAAO;AAAA,MACpD,IAAI,GAAG,IAAI,IAAI,IAAI,CAAC;AAAA,IACtB,CAAC;AAAA,EACH;AAEA,SAAO;AACT;AAGO,SAAS,aAAa,OAA6B,MAAM,KAAe;AAC7E,QAAM,SAAS,KAAK,KAAK,gBAAgB;AAEzC,QAAM,QAAQ,UACR,WAAW,MAAM,IACjB,YAAY,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,WAAW,YAAY,CAAC,EAAE,IAAI,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,IACxF,CAAC;AAEP,QAAM,OAAO,CAAC,SACZ,SAAS,IAAI,EAAE,YAAY,IACvB,YAAY,IAAI,EAAE,QAAQ,CAAC,MAAM,KAAK,KAAK,MAAM,CAAC,CAAC,CAAC,IACpD,KAAK,SAAS,KAAK,IACnB,CAAC,IAAI,IACL,CAAC;AAEP,SAAO,MAAM,QAAQ,IAAI;AAC3B;AAEO,IAAM,UAAU,CAAC,UAAoB,MAAM,QAAQ,CAAC,MAAM,SAAS,aAAa,GAAG,MAAM,GAAG,CAAC,CAAC;AAGrG,IAAM,QAAQ,CAAC,YAAqB,IAAI,IAAI,QAAQ,KAAK,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC,CAAC,EAAE,KAAK,CAAC,CAAC;AAM1F,SAAS,SAAS,MAAgB,SAAkB;AACzD,QAAM,QAAQ,KAAK,OAAO,CAAC,MAAM,EAAE,WAAW,aAAa,CAAC,QAAQ,IAAI,EAAE,QAAQ,CAAC;AACnF,QAAM,UAAU,KAAK,OAAO,CAAC,MAAM,EAAE,WAAW,SAAS,EAAE,SAAS,MAAM;AAE1E,QAAM,UAAU,KAAK,OAAO,CAAC,MAAM,EAAE,WAAW,SAAS,EAAE,SAAS,MAAM;AAC1E,QAAM,SAAS,IAAI,IAAI,KAAK,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,GAAG,CAAC,CAAC;AACjE,QAAM,OAAO,MAAM,OAAO;AAE1B,SAAO;AAAA,IACL,QAAQ,KAAK;AAAA,IACb;AAAA,IACA;AAAA,IACA,UAAU,KAAK,OAAO,CAAC,MAAM,EAAE,WAAW,WAAW,EAAE;AAAA;AAAA,IAEvD,OAAO,UAAU,UAAU,WAAW,UAAU,WAAW;AAAA,IAC3D,eAAe,KAAK,OAAO,CAAC,MAAM,EAAE,WAAW,SAAS;AAAA,IACxD;AAAA,IACA,UAAU,QAAQ,KAAK,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC,OAAO,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,CAAC,CAAC;AAAA,EACxF;AACF;AAcA,IAAM,WAAW,CAAC,SAAiB,6DAA6D,KAAK,IAAI;AAOlG,SAAS,aAAa,MAAkC;AAC7D,QAAM,QAAQ,KAAK,QAAQ,sBAAsB,EAAE,EAAE,MAAM,GAAG,EAAE,CAAC,KAAK;AACtE,QAAM,WAAqB,CAAC;AAE5B,aAAW,KAAK,MAAM,MAAM,GAAG,EAAE,OAAO,OAAO,GAAG;AAChD,QAAI,EAAE,WAAW,GAAG,KAAK,EAAE,SAAS,GAAG,EAAG;AAC1C,aAAS,KAAK,CAAC;AAAA,EACjB;AAEA,QAAM,WAAW,SAAS,KAAK,CAAC,MAAM,CAAC,wBAAwB,KAAK,CAAC,CAAC;AAEtE,SAAO,WAAW,IAAI,SAAS,KAAK,GAAG,CAAC,KAAK;AAC/C;AAEA,IAAM,SAAS,CAAC,MAAc,EAAE,QAAQ,uBAAuB,MAAM;AAS9D,SAAS,kBACd,EAAE,SAAS,KAAK,SAAS,KAAK,GAC9B;AACA,QAAM,QAAQ,oBAAI,IAAsB;AACxC,QAAM,UAAU,oBAAI,IAAY;AAEhC,QAAM,MAAM,CAAC,MAAc,QAAgB,SAAkB;AAC3D,QAAI,CAAC,QAAQ,IAAI,IAAI,EAAG;AAExB,UAAM,UAAU,MAAM,IAAI,IAAI,KAAK,CAAC;AAEpC,QAAI,CAAC,QAAQ,KAAK,CAAC,MAAM,EAAE,SAAS,OAAO,QAAQ,EAAE,WAAW,OAAO,MAAM,EAAG,SAAQ,KAAK,MAAM;AACnG,UAAM,IAAI,MAAM,OAAO;AAEvB,QAAI,KAAM,SAAQ,IAAI,IAAI;AAAA,EAC5B;AAEA,QAAM,SAAS,GAAG,IAAI,QAAQ,SAAS,EAAE,EAAE,QAAQ,OAAO,EAAE,CAAC;AAG7D,aAAW,QAAQ,SAAS;AAC1B,QAAI,CAAC,KAAK,KAAK,WAAW,MAAM,KAAK,CAAC,KAAK,KAAK,SAAS,OAAO,KAAK,KAAK,YAAY,OAAW;AAEjG,QAAI;AAEF,YAAM,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EAAE,UAAU,KAAK,MAAM,KAAK,OAAO,CAAC;AAE/E,UAAI,MAAM,QAAS,KAAI,MAAM,KAAK,MAAM,EAAE,MAAM,YAAY,QAAQ,KAAK,KAAK,GAAG,KAAK,IAAI;AAAA,IAC5F,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,WAAW,CAAC,WAChB,QAAQ,KAAK,OAAO,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC,CAAC,EAAE,KAAK,EAAE,SAAS,MAAM,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI;AAEzF,QAAM,QAAQ,CAAC,GAAG,MAAM,KAAK,CAAC;AAE9B,WAAS,SAAS,MAAM,MAAM,GAAG,WAAW,QAAW,SAAS,MAAM,MAAM,GAAG;AAC7E,eAAW,SAAS,SAAS,MAAM,GAAG;AACpC,UAAI,CAAC,MAAM,IAAI,KAAK,EAAG,OAAM,KAAK,KAAK;AACvC,UAAI,OAAO,EAAE,MAAM,WAAW,QAAQ,OAAO,CAAC;AAAA,IAChD;AAAA,EACF;AAEA,QAAM,UAAU,QAAQ,OAAO,CAAC,MAAM,SAAS,EAAE,IAAI,CAAC;AAGtD,aAAW,OAAO,MAAM;AACtB,eAAW,UAAU,IAAI,QAAQ;AAC/B,YAAM,SAAS,OAAO,QAAQ,SAAS,EAAE;AAEzC,iBAAW,QAAQ,SAAS;AAC1B,YAAI,KAAK,SAAS,UAAU,KAAK,KAAK,WAAW,OAAO,SAAS,GAAG,IAAI,SAAS,GAAG,MAAM,GAAG,GAAG;AAC9F,qBAAW,QAAQ,IAAI,SAAS,MAAM,GAAG,EAAE,OAAO,OAAO,GAAG;AAC1D,gBAAI,MAAM,EAAE,MAAM,OAAO,QAAQ,GAAG,IAAI,WAAW,IAAI,KAAK,KAAK,MAAM,IAAI,GAAG,KAAK,IAAI;AAAA,UACzF;AAAA,QACF;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAGA,aAAW,KAAK,QAAQ,MAAM;AAC5B,UAAM,WAAW,QAAQ,QAAQ,CAAC;AAElC,eAAW,SAAS,SAAS,WAAW,CAAC,GAAG;AAC1C,YAAM,OAAO,aAAa,MAAM,IAAI;AAEpC,UAAI,CAAC,KAAM;AACX,YAAM,UAAU,IAAI,OAAO,GAAG,OAAO,IAAI,CAAC,YAAY;AAEtD,iBAAW,QAAQ,SAAS;AAC1B,YAAI,KAAK,WAAW,QAAQ,KAAK,KAAK,OAAO,GAAG;AAC9C,cAAI,EAAE,MAAM,EAAE,MAAM,YAAY,QAAQ,GAAG,KAAK,IAAI,UAAU,IAAI,GAAG,GAAG,KAAK,IAAI;AAAA,QACnF;AAAA,MACF;AAAA,IACF;AAEA,UAAM,QAAQ,SAAS,MAAM,MAAM,OAAO,EAAE,KAAK,CAAC,MAAM,EAAE,UAAU,KAAK,CAAC,EAAE,WAAW,GAAG,CAAC,GACvF,YAAY;AAEhB,QAAI,CAAC,MAAO;AAEZ,eAAW,QAAQ,SAAS;AAC1B,YAAM,QAAQ,KAAK,KAAK,YAAY,EAAE,MAAM,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,QAAQ,YAAY,EAAE,CAAC;AAErF,UAAI,MAAM,SAAS,KAAK,GAAG;AACzB,YAAI,EAAE,MAAM,EAAE,MAAM,SAAS,QAAQ,GAAG,KAAK,IAAI,cAAc,KAAK,GAAG,GAAG,KAAK,IAAI;AAAA,MACrF;AAAA,IACF;AAAA,EACF;AAEA,QAAM,WAA6B,CAAC,YAAY,WAAW,OAAO,YAAY,OAAO;AACrF,QAAM,OAAO,CAAC,YAAsB,KAAK,IAAI,GAAG,QAAQ,IAAI,CAAC,MAAM,SAAS,QAAQ,EAAE,IAAI,CAAC,CAAC;AAE5F,SAAO;AAAA,IACL,WAAW,CAAC,GAAG,KAAK,EACjB,IAAI,CAAC,CAAC,MAAM,OAAO,OAAO,EAAE,MAAM,QAAQ,EAAE,EAC5C,SAAS,CAAC,GAAG,MAAM,KAAK,EAAE,OAAO,IAAI,KAAK,EAAE,OAAO,KAAK,EAAE,KAAK,cAAc,EAAE,IAAI,CAAC;AAAA;AAAA,IAEvF,WAAW,QAAQ,OAAO,CAAC,MAAM,CAAC,QAAQ,IAAI,EAAE,IAAI,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI;AAAA,EAC1E;AACF;","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
|
+
}[];
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CliError
|
|
3
|
+
} from "./chunk-GCYOLAFU.js";
|
|
4
|
+
|
|
5
|
+
// src/record.ts
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
import { copyFileSync } from "fs";
|
|
8
|
+
function visibleCrop(box, viewport) {
|
|
9
|
+
const x = Math.max(0, box.x);
|
|
10
|
+
const y = Math.max(0, box.y);
|
|
11
|
+
const width = Math.min(viewport.width, box.x + box.width) - x;
|
|
12
|
+
const height = Math.min(viewport.height, box.y + box.height) - y;
|
|
13
|
+
if (width < 2 || height < 2) return void 0;
|
|
14
|
+
return { crop: { x, y, width, height }, clipped: width < box.width || height < box.height };
|
|
15
|
+
}
|
|
16
|
+
var even = (n) => Math.max(2, Math.floor(n / 2) * 2);
|
|
17
|
+
function ffmpegArgs({ input, output, format, start, crop, fps, width }) {
|
|
18
|
+
const rate = fps ?? (format === "gif" ? 12 : void 0);
|
|
19
|
+
const filters = [
|
|
20
|
+
crop && `crop=${even(crop.width)}:${even(crop.height)}:${Math.round(crop.x)}:${Math.round(crop.y)}`,
|
|
21
|
+
rate && `fps=${rate}`,
|
|
22
|
+
// Quoted so the comma in min() isn't read as a filter separator.
|
|
23
|
+
width && `scale='trunc(min(${width},iw)/2)*2':-2:flags=lanczos`
|
|
24
|
+
].filter(Boolean);
|
|
25
|
+
const codec = {
|
|
26
|
+
mp4: ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "23", "-movflags", "+faststart"],
|
|
27
|
+
webm: ["-c:v", "libvpx-vp9", "-b:v", "0", "-crf", "32"],
|
|
28
|
+
gif: []
|
|
29
|
+
}[format];
|
|
30
|
+
const vf = format === "gif" ? `${filters.join(",")},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer` : filters.join(",");
|
|
31
|
+
return [
|
|
32
|
+
"-y",
|
|
33
|
+
"-loglevel",
|
|
34
|
+
"error",
|
|
35
|
+
"-i",
|
|
36
|
+
input,
|
|
37
|
+
"-ss",
|
|
38
|
+
start.toFixed(2),
|
|
39
|
+
...vf ? ["-vf", vf] : [],
|
|
40
|
+
"-an",
|
|
41
|
+
...codec,
|
|
42
|
+
output
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
function encodeVideo(options) {
|
|
46
|
+
if (options.format === "webm" && !options.crop && !options.fps && !options.width) {
|
|
47
|
+
copyFileSync(options.input, options.output);
|
|
48
|
+
return { start: 0 };
|
|
49
|
+
}
|
|
50
|
+
const ffmpeg = process.env.FFMPEG_PATH || "ffmpeg";
|
|
51
|
+
const result = spawnSync(ffmpeg, ffmpegArgs(options), { encoding: "utf8" });
|
|
52
|
+
if (result.error) {
|
|
53
|
+
throw new CliError(
|
|
54
|
+
"FFMPEG_MISSING",
|
|
55
|
+
`record needs ffmpeg for ${options.format}${options.format === "webm" ? " with --element, --fps or --width" : ""}: install it (https://ffmpeg.org/download.html) or set FFMPEG_PATH`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (result.status !== 0) throw new Error(`ffmpeg failed: ${result.stderr.trim()}`);
|
|
59
|
+
return { start: options.start };
|
|
60
|
+
}
|
|
61
|
+
function outputSize(source, width) {
|
|
62
|
+
if (!width) return source;
|
|
63
|
+
const w = Math.trunc(Math.min(width, source.width) / 2) * 2;
|
|
64
|
+
return { width: w, height: Math.round(w * source.height / (source.width * 2)) * 2 };
|
|
65
|
+
}
|
|
66
|
+
function videoManifest({ name, description, video, viewport, crop, width, start, end, clicks }) {
|
|
67
|
+
const source = crop ? { ...crop, width: even(crop.width), height: even(crop.height) } : { x: 0, y: 0, ...viewport };
|
|
68
|
+
const size = outputSize({ width: source.width, height: source.height }, width);
|
|
69
|
+
const ratio = size.width / source.width;
|
|
70
|
+
return {
|
|
71
|
+
name,
|
|
72
|
+
description,
|
|
73
|
+
video,
|
|
74
|
+
width: size.width,
|
|
75
|
+
height: size.height,
|
|
76
|
+
durationMs: Math.round(end - start),
|
|
77
|
+
clicks: clicks.map((c) => ({
|
|
78
|
+
t: Math.round(c.at - start),
|
|
79
|
+
x: Math.round((c.x - Math.round(source.x)) * ratio),
|
|
80
|
+
y: Math.round((c.y - Math.round(source.y)) * ratio)
|
|
81
|
+
})).filter((c) => c.t >= 0 && c.x >= 0 && c.y >= 0 && c.x < size.width && c.y < size.height)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async function pagePoint(frame, x, y) {
|
|
85
|
+
if (!frame.parentFrame()) return { x, y };
|
|
86
|
+
const element = await frame.frameElement();
|
|
87
|
+
const [box, layout] = await Promise.all([
|
|
88
|
+
element.boundingBox(),
|
|
89
|
+
element.evaluate((node) => {
|
|
90
|
+
if (!(node instanceof HTMLElement)) return void 0;
|
|
91
|
+
const style = getComputedStyle(node);
|
|
92
|
+
return {
|
|
93
|
+
left: node.clientLeft + parseFloat(style.paddingLeft),
|
|
94
|
+
top: node.clientTop + parseFloat(style.paddingTop),
|
|
95
|
+
width: node.offsetWidth,
|
|
96
|
+
height: node.offsetHeight
|
|
97
|
+
};
|
|
98
|
+
})
|
|
99
|
+
]);
|
|
100
|
+
if (!box || !layout?.width || !layout.height) throw new Error("The iframe is not visible");
|
|
101
|
+
const scaleX = box.width / layout.width;
|
|
102
|
+
const scaleY = box.height / layout.height;
|
|
103
|
+
return { x: box.x + (layout.left + x) * scaleX, y: box.y + (layout.top + y) * scaleY };
|
|
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
|
+
}
|
|
127
|
+
export {
|
|
128
|
+
encodeVideo,
|
|
129
|
+
ffmpegArgs,
|
|
130
|
+
glideBeforeClick,
|
|
131
|
+
outputSize,
|
|
132
|
+
pagePoint,
|
|
133
|
+
videoManifest,
|
|
134
|
+
visibleCrop
|
|
135
|
+
};
|
|
136
|
+
//# sourceMappingURL=record-QUE3LDEG.js.map
|