@graphty/visual-review 0.0.1 → 0.1.1
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/LICENSE +21 -0
- package/README.md +590 -4
- package/capture/capture.mjs +764 -0
- package/package.json +70 -11
- package/templates/visual-review.yml +141 -0
- package/templates/visual-seed.yml +144 -0
- package/trusted/cli.mjs +334 -0
- package/trusted/gate.mjs +272 -0
- package/trusted/lib/accept.mjs +537 -0
- package/trusted/lib/compare.mjs +203 -0
- package/trusted/lib/config.mjs +168 -0
- package/trusted/lib/github.mjs +231 -0
- package/trusted/lib/init.mjs +196 -0
- package/trusted/lib/results.mjs +158 -0
- package/trusted/lib/serve.mjs +663 -0
- package/trusted/page/index.html +19 -0
- package/trusted/page/review.css +503 -0
- package/trusted/page/review.js +1768 -0
- package/trusted/vendor/pixelmatch.mjs +336 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `visual-review init`: writes a repository's config, its Git LFS rule and its GitHub Actions
|
|
3
|
+
* workflows from the templates in this package's templates/ directory.
|
|
4
|
+
*
|
|
5
|
+
* The workflows are the templates with four placeholders filled in (`# __SETUP__` is a comment,
|
|
6
|
+
* so a template is valid YAML): the default branch, the
|
|
7
|
+
* package manager's setup and install steps, the command that runs this CLI, and this package's
|
|
8
|
+
* version (the gate job runs exactly that published version with npx). A workflow init wrote
|
|
9
|
+
* starts with GENERATED, so `--force` rewrites it and never a workflow someone wrote by hand.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
import { CONFIG_FILE, loadConfig } from "./config.mjs";
|
|
18
|
+
|
|
19
|
+
const PACKAGE = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
20
|
+
export const GENERATED = "# Generated by visual-review init";
|
|
21
|
+
|
|
22
|
+
/** Per package manager: the steps that install Node and the dependencies, and how to run the CLI. */
|
|
23
|
+
const MANAGERS = {
|
|
24
|
+
npm: { lock: "package-lock.json", cli: "npx visual-review", setup: setup("npm", "npm ci") },
|
|
25
|
+
pnpm: {
|
|
26
|
+
lock: "pnpm-lock.yaml",
|
|
27
|
+
cli: "pnpm exec visual-review",
|
|
28
|
+
setup: `- uses: pnpm/action-setup@v4\n\n${setup("pnpm", "pnpm install --frozen-lockfile")}`,
|
|
29
|
+
},
|
|
30
|
+
yarn: { lock: "yarn.lock", cli: "yarn visual-review", setup: setup("yarn", "yarn install --frozen-lockfile") },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function setup(cache, install) {
|
|
34
|
+
return [
|
|
35
|
+
"- uses: actions/setup-node@v4",
|
|
36
|
+
" with:",
|
|
37
|
+
" node-version: 22.x",
|
|
38
|
+
` cache: ${cache}`,
|
|
39
|
+
"",
|
|
40
|
+
"# HUSKY=0: git hooks are for people, not for CI's install.",
|
|
41
|
+
"- name: Install dependencies",
|
|
42
|
+
` run: ${install}`,
|
|
43
|
+
" env:",
|
|
44
|
+
' HUSKY: "0"',
|
|
45
|
+
].join("\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The package manager a repository uses, from its lockfile.
|
|
50
|
+
* @param {string} root the repository
|
|
51
|
+
* @returns {"npm" | "pnpm" | "yarn"} the manager
|
|
52
|
+
*/
|
|
53
|
+
export function packageManager(root) {
|
|
54
|
+
const found = Object.keys(MANAGERS).find((m) => m !== "npm" && existsSync(join(root, MANAGERS[m].lock)));
|
|
55
|
+
return /** @type {"npm" | "pnpm" | "yarn"} */ (found ?? "npm");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Fills in a template.
|
|
60
|
+
* @param {string} name the template's file name in templates/
|
|
61
|
+
* @param {{ branch: string, manager: "npm" | "pnpm" | "yarn", version: string }} values what goes in
|
|
62
|
+
* @returns {string} the workflow
|
|
63
|
+
*/
|
|
64
|
+
export function renderTemplate(name, { branch, manager, version }) {
|
|
65
|
+
const m = MANAGERS[manager];
|
|
66
|
+
return readFileSync(join(PACKAGE, "templates", name), "utf8").replace(
|
|
67
|
+
/^( *)# __SETUP__$|__BRANCH__|__CLI__|__VERSION__/gm,
|
|
68
|
+
(match, indent) =>
|
|
69
|
+
indent !== undefined
|
|
70
|
+
? m.setup
|
|
71
|
+
.split("\n")
|
|
72
|
+
.map((l) => (l === "" ? "" : indent + l))
|
|
73
|
+
.join("\n")
|
|
74
|
+
: { __BRANCH__: branch, __CLI__: m.cli, __VERSION__: version }[match],
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function defaultBranch(root) {
|
|
79
|
+
const git = (...args) => execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: "pipe" }).trim();
|
|
80
|
+
try {
|
|
81
|
+
return git("symbolic-ref", "--short", "refs/remotes/origin/HEAD").replace(/^origin\//, "");
|
|
82
|
+
} catch {
|
|
83
|
+
try {
|
|
84
|
+
return git("branch", "--show-current") || "main";
|
|
85
|
+
} catch {
|
|
86
|
+
return "main";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Appends a line to a file unless a line already starts with `prefix`.
|
|
93
|
+
* @param {string} file the file, created when missing
|
|
94
|
+
* @param {string} prefix what an existing line starts with when the text is already there
|
|
95
|
+
* @param {string} text the lines to append
|
|
96
|
+
* @returns {boolean} whether it wrote
|
|
97
|
+
*/
|
|
98
|
+
function appendOnce(file, prefix, text) {
|
|
99
|
+
const old = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
100
|
+
if (old.split("\n").some((l) => l.startsWith(prefix))) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
writeFileSync(file, `${old}${old === "" || old.endsWith("\n") ? "" : "\n"}${text}\n`);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Whether git already ignores a directory (asked of a file in it, since a rule such as `dir/*`
|
|
109
|
+
* ignores the files but not the path itself).
|
|
110
|
+
* @param {string} root the repository
|
|
111
|
+
* @param {string} dir the directory, relative to the repository
|
|
112
|
+
* @returns {boolean} true when a file in it is ignored
|
|
113
|
+
*/
|
|
114
|
+
function ignored(root, dir) {
|
|
115
|
+
try {
|
|
116
|
+
execFileSync("git", ["check-ignore", "-q", "--no-index", `${dir}/x`], { cwd: root, stdio: "ignore" });
|
|
117
|
+
return true;
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Sets up a repository.
|
|
125
|
+
* @param {string} root the repository's top level
|
|
126
|
+
* @param {{ force?: boolean }} [options] rewrite the workflows init wrote before (never the
|
|
127
|
+
* config, which is the repository's own, and never a workflow written by hand)
|
|
128
|
+
* @returns {string[]} one line per file: what was written or kept
|
|
129
|
+
*/
|
|
130
|
+
export function init(root, { force = false } = {}) {
|
|
131
|
+
const report = [];
|
|
132
|
+
const manager = packageManager(root);
|
|
133
|
+
const configFile = join(root, CONFIG_FILE);
|
|
134
|
+
if (existsSync(configFile)) {
|
|
135
|
+
report.push(`kept ${CONFIG_FILE}`);
|
|
136
|
+
} else {
|
|
137
|
+
const starter = {
|
|
138
|
+
defaultBranch: defaultBranch(root),
|
|
139
|
+
workflow: "visual-review.yml",
|
|
140
|
+
baselines: "visual-baselines",
|
|
141
|
+
workDir: ".visual-review",
|
|
142
|
+
commitPrefix: "test",
|
|
143
|
+
issueLabels: ["bug"],
|
|
144
|
+
projects: {
|
|
145
|
+
storybook: {
|
|
146
|
+
storybook: "storybook-static",
|
|
147
|
+
build: `${manager} run build-storybook`,
|
|
148
|
+
workers: 4,
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
writeFileSync(configFile, `${JSON.stringify(starter, null, 4)}\n`);
|
|
153
|
+
report.push(`wrote ${CONFIG_FILE}`);
|
|
154
|
+
}
|
|
155
|
+
const config = loadConfig(root);
|
|
156
|
+
|
|
157
|
+
const rule = `${config.baselines}/**/*.png`;
|
|
158
|
+
report.push(
|
|
159
|
+
appendOnce(
|
|
160
|
+
join(root, ".gitattributes"),
|
|
161
|
+
`${rule} `,
|
|
162
|
+
`# Visual review baselines are stored in Git LFS.\n${rule} filter=lfs diff=lfs merge=lfs -text`,
|
|
163
|
+
)
|
|
164
|
+
? `wrote .gitattributes (${rule} in Git LFS)`
|
|
165
|
+
: "kept .gitattributes",
|
|
166
|
+
);
|
|
167
|
+
report.push(
|
|
168
|
+
!ignored(root, config.workDir) &&
|
|
169
|
+
appendOnce(join(root, ".gitignore"), `/${config.workDir}/`, `/${config.workDir}/`)
|
|
170
|
+
? `wrote .gitignore (/${config.workDir}/)`
|
|
171
|
+
: "kept .gitignore",
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const version = JSON.parse(readFileSync(join(PACKAGE, "package.json"), "utf8")).version;
|
|
175
|
+
const values = { branch: config.defaultBranch, manager, version };
|
|
176
|
+
for (const [template, file] of [
|
|
177
|
+
["visual-review.yml", config.workflow],
|
|
178
|
+
["visual-seed.yml", "visual-seed.yml"],
|
|
179
|
+
]) {
|
|
180
|
+
const path = join(root, ".github/workflows", file);
|
|
181
|
+
const rel = `.github/workflows/${file}`;
|
|
182
|
+
if (existsSync(path) && !(force && readFileSync(path, "utf8").startsWith(GENERATED))) {
|
|
183
|
+
report.push(`kept ${rel}${force ? " (not written by init, so never replaced)" : ""}`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
187
|
+
writeFileSync(path, renderTemplate(template, values));
|
|
188
|
+
report.push(`wrote ${rel}`);
|
|
189
|
+
}
|
|
190
|
+
report.push(
|
|
191
|
+
"",
|
|
192
|
+
`Next: edit ${CONFIG_FILE} (one entry per Storybook), commit these files, and push a pull request.`,
|
|
193
|
+
'Then make the "Visual gate" check required, and seed the first baselines (README, "Seeding").',
|
|
194
|
+
);
|
|
195
|
+
return report;
|
|
196
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* results.json: what one capture run of one Storybook project found, item by item.
|
|
3
|
+
*
|
|
4
|
+
* Capture writes it; CI, the review page and later the MCP server read it. It arrives from a CI
|
|
5
|
+
* artifact, so every reader validates it before using a field: nothing in it is trusted, and a
|
|
6
|
+
* `file` name is later joined to a directory, so it may never leave that directory.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every status an item can have. `unseeded` ("no baseline yet") is a story with no baseline whose
|
|
11
|
+
* capture matches master's newest capture of it, so the pull request did not change it. `moved`
|
|
12
|
+
* is a story that looks exactly as the baseline of the id it was renamed from (`from`, named in
|
|
13
|
+
* the project's renames.json); a renamed story that looks different is `changed` with `from`.
|
|
14
|
+
*/
|
|
15
|
+
const STATUSES = ["unchanged", "moved", "changed", "new", "unseeded", "removed", "unstable", "failed", "excluded"];
|
|
16
|
+
|
|
17
|
+
/** The most items one file may hold (compact-mantine has about 830 today). */
|
|
18
|
+
export const MAX_ITEMS = 5000;
|
|
19
|
+
|
|
20
|
+
const MAX_STRING = 2000;
|
|
21
|
+
const MAX_CONSOLE = 100;
|
|
22
|
+
|
|
23
|
+
const SHA1 = /^[0-9a-f]{40}$/;
|
|
24
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
25
|
+
const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
26
|
+
|
|
27
|
+
// Which hashes a status requires (true), forbids (false) or leaves open (absent).
|
|
28
|
+
const HASHES = {
|
|
29
|
+
unchanged: { baseline: true, capture: true },
|
|
30
|
+
changed: { baseline: true, capture: true },
|
|
31
|
+
moved: { baseline: true, capture: true },
|
|
32
|
+
new: { baseline: false, capture: true },
|
|
33
|
+
unseeded: { baseline: false, capture: true },
|
|
34
|
+
removed: { baseline: true, capture: false },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const isInt = (v, min = 0) => Number.isInteger(v) && v >= min;
|
|
38
|
+
const isStr = (v) => typeof v === "string" && v.length > 0 && v.length <= MAX_STRING;
|
|
39
|
+
const isPair = (v) => Array.isArray(v) && v.length === 2 && v.every((n) => isInt(n, 1));
|
|
40
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Checks a parsed results.json.
|
|
44
|
+
* @param {any} r the parsed JSON, untrusted
|
|
45
|
+
* @returns {string[]} one message per problem; empty when the file is valid
|
|
46
|
+
*/
|
|
47
|
+
export function validateResults(r) {
|
|
48
|
+
if (!isObj(r)) {
|
|
49
|
+
return ["results must be an object"];
|
|
50
|
+
}
|
|
51
|
+
const errors = [];
|
|
52
|
+
const check = (ok, message) => {
|
|
53
|
+
if (!ok) {
|
|
54
|
+
errors.push(message);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
check(r.version === 1, "version must be 1");
|
|
59
|
+
check(typeof r.project === "string" && NAME.test(r.project), "project must be a lowercase name");
|
|
60
|
+
check(typeof r.commit === "string" && SHA1.test(r.commit), "commit must be a 40-character sha");
|
|
61
|
+
check(
|
|
62
|
+
r.headSha === null || (typeof r.headSha === "string" && SHA1.test(r.headSha)),
|
|
63
|
+
"headSha must be a sha or null",
|
|
64
|
+
);
|
|
65
|
+
for (const key of ["pr", "runId", "runAttempt"]) {
|
|
66
|
+
check(r[key] === null || isInt(r[key], 1), `${key} must be a positive integer or null`);
|
|
67
|
+
}
|
|
68
|
+
check(
|
|
69
|
+
r.local === null ||
|
|
70
|
+
(isObj(r.local) &&
|
|
71
|
+
isStr(r.local.describe) &&
|
|
72
|
+
typeof r.local.diff === "string" &&
|
|
73
|
+
SHA256.test(r.local.diff)),
|
|
74
|
+
"local must be null or { describe, diff }",
|
|
75
|
+
);
|
|
76
|
+
check(typeof r.seeded === "boolean", "seeded must be a boolean");
|
|
77
|
+
check(typeof r.complete === "boolean", "complete must be a boolean");
|
|
78
|
+
check(isInt(r.expected), "expected must be a non-negative integer");
|
|
79
|
+
check(isStr(r.capturedAt) && !Number.isNaN(Date.parse(r.capturedAt)), "capturedAt must be a date");
|
|
80
|
+
check(isObj(r.clock), "clock must be an object");
|
|
81
|
+
check(isObj(r.environment), "environment must be an object");
|
|
82
|
+
// Device pixels per CSS pixel. Captures made before it was recorded were at 1.
|
|
83
|
+
check(r.scale === undefined || r.scale === 1 || r.scale === 2, "scale must be 1 or 2");
|
|
84
|
+
|
|
85
|
+
if (!Array.isArray(r.items)) {
|
|
86
|
+
errors.push("items must be an array");
|
|
87
|
+
return errors;
|
|
88
|
+
}
|
|
89
|
+
if (r.items.length > MAX_ITEMS) {
|
|
90
|
+
errors.push(`items holds ${r.items.length} entries; the most allowed is ${MAX_ITEMS}`);
|
|
91
|
+
return errors;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
r.items.forEach((item, i) => {
|
|
96
|
+
const at = `items[${i}]`;
|
|
97
|
+
if (!isObj(item)) {
|
|
98
|
+
errors.push(`${at} must be an object`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const idOk = typeof item.id === "string" && item.id.length <= 200 && NAME.test(item.id);
|
|
102
|
+
const modeOk =
|
|
103
|
+
item.mode === null || (typeof item.mode === "string" && item.mode.length <= 50 && NAME.test(item.mode));
|
|
104
|
+
check(idOk, `${at}.id must be a Storybook story id`);
|
|
105
|
+
check(modeOk, `${at}.mode must be a lowercase name or null`);
|
|
106
|
+
// The file name is derived, never chosen: `<id>[.<mode>].png`. That alone keeps "/" and ".."
|
|
107
|
+
// out of it, because neither the id nor the mode can contain them.
|
|
108
|
+
if (idOk && modeOk) {
|
|
109
|
+
const expected = item.mode === null ? `${item.id}.png` : `${item.id}.${item.mode}.png`;
|
|
110
|
+
check(item.file === expected, `${at}.file must be "${expected}"`);
|
|
111
|
+
const key = `${item.id}\u0000${item.mode}`;
|
|
112
|
+
check(!seen.has(key), `${at} is a duplicate of ${item.id} ${item.mode ?? ""}`.trimEnd());
|
|
113
|
+
seen.add(key);
|
|
114
|
+
} else {
|
|
115
|
+
check(false, `${at}.file cannot be checked without a valid id and mode`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
check(STATUSES.includes(item.status), `${at}.status must be one of ${STATUSES.join(", ")}`);
|
|
119
|
+
// The story id a renamed story's baseline was read under (absent when it was not renamed).
|
|
120
|
+
check(
|
|
121
|
+
item.from === undefined ||
|
|
122
|
+
item.from === null ||
|
|
123
|
+
(typeof item.from === "string" && item.from.length <= 200 && NAME.test(item.from)),
|
|
124
|
+
`${at}.from must be a Storybook story id or null`,
|
|
125
|
+
);
|
|
126
|
+
check(item.status !== "moved" || typeof item.from === "string", `${at}.from is required for a moved item`);
|
|
127
|
+
check(typeof item.flaky === "boolean", `${at}.flaky must be a boolean`);
|
|
128
|
+
for (const key of ["baseline", "capture"]) {
|
|
129
|
+
const v = item[key];
|
|
130
|
+
check(v === null || (typeof v === "string" && SHA256.test(v)), `${at}.${key} must be a sha256 or null`);
|
|
131
|
+
const rule = HASHES[item.status]?.[key];
|
|
132
|
+
check(rule !== true || typeof v === "string", `${at}.${key} is required for a ${item.status} item`);
|
|
133
|
+
check(rule !== false || v === null, `${at}.${key} must be null for a ${item.status} item`);
|
|
134
|
+
}
|
|
135
|
+
for (const key of ["size", "baselineSize"]) {
|
|
136
|
+
check(item[key] === null || isPair(item[key]), `${at}.${key} must be [width, height] or null`);
|
|
137
|
+
}
|
|
138
|
+
check(item.changedPixels === null || isInt(item.changedPixels), `${at}.changedPixels must be a count or null`);
|
|
139
|
+
check(
|
|
140
|
+
item.bbox === null ||
|
|
141
|
+
(Array.isArray(item.bbox) && item.bbox.length === 4 && item.bbox.every((n) => isInt(n))),
|
|
142
|
+
`${at}.bbox must be four integers or null`,
|
|
143
|
+
);
|
|
144
|
+
check(
|
|
145
|
+
typeof item.threshold === "number" && item.threshold >= 0 && item.threshold <= 1,
|
|
146
|
+
`${at}.threshold must be 0..1`,
|
|
147
|
+
);
|
|
148
|
+
check(typeof item.includeAA === "boolean", `${at}.includeAA must be a boolean`);
|
|
149
|
+
check(item.reason === null || isStr(item.reason), `${at}.reason must be a string or null`);
|
|
150
|
+
check(
|
|
151
|
+
Array.isArray(item.console) &&
|
|
152
|
+
item.console.length <= MAX_CONSOLE &&
|
|
153
|
+
item.console.every((line) => typeof line === "string" && line.length <= MAX_STRING),
|
|
154
|
+
`${at}.console must be at most ${MAX_CONSOLE} strings`,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
return errors;
|
|
158
|
+
}
|