howone 0.2.0 → 0.2.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/bin/index.mjs +1 -1
- package/package.json +1 -1
- package/templates/vite/.howone/skills/howone/01-architect/01-app-generation.md +19 -16
- package/templates/vite/.howone/skills/howone/03-ai-capabilities/01-ai-capability-architecture.md +14 -24
- package/templates/vite/.howone/skills/howone/03-ai-capabilities/02-workflow-contract-rules.md +0 -107
- package/templates/vite/.howone/skills/howone/03-ai-capabilities/03-service-capability-catalog.md +26 -101
- package/templates/vite/.howone/skills/howone/03-ai-capabilities/04-workflow-operations.md +23 -17
- package/templates/vite/.howone/skills/howone/03-ai-capabilities/05-ai-feature-playbooks.md +1 -66
- package/templates/vite/.howone/skills/howone/SKILL.md +5 -4
- package/templates/vite/.howone/skills/web-clone/LICENSE +0 -21
- package/templates/vite/.howone/skills/web-clone/README.md +0 -179
- package/templates/vite/.howone/skills/web-clone/SKILL.md +0 -243
- package/templates/vite/.howone/skills/web-clone/references/assessment.md +0 -77
- package/templates/vite/.howone/skills/web-clone/references/complex-playbooks.md +0 -46
- package/templates/vite/.howone/skills/web-clone/references/deliverables.md +0 -144
- package/templates/vite/.howone/skills/web-clone/references/design-dna.md +0 -125
- package/templates/vite/.howone/skills/web-clone/references/effect-extraction.md +0 -73
- package/templates/vite/.howone/skills/web-clone/references/marbles-case.md +0 -31
- package/templates/vite/.howone/skills/web-clone/references/reverse-engineering.md +0 -34
- package/templates/vite/.howone/skills/web-clone/references/static-mirror.md +0 -72
- package/templates/vite/.howone/skills/web-clone/scripts/asset-harvest.mjs +0 -101
- package/templates/vite/.howone/skills/web-clone/scripts/audit-clone.mjs +0 -151
- package/templates/vite/.howone/skills/web-clone/scripts/compare-recon.mjs +0 -265
- package/templates/vite/.howone/skills/web-clone/scripts/dna-scaffold.mjs +0 -214
- package/templates/vite/.howone/skills/web-clone/scripts/init-clone.mjs +0 -136
- package/templates/vite/.howone/skills/web-clone/scripts/interaction-probe.mjs +0 -314
- package/templates/vite/.howone/skills/web-clone/scripts/lib/playwright-loader.mjs +0 -39
- package/templates/vite/.howone/skills/web-clone/scripts/mirror-site.mjs +0 -121
- package/templates/vite/.howone/skills/web-clone/scripts/network-capture.mjs +0 -127
- package/templates/vite/.howone/skills/web-clone/scripts/recon-site.mjs +0 -235
- package/templates/vite/.howone/skills/web-clone/scripts/route-crawl.mjs +0 -228
- package/templates/vite/.howone/skills/web-clone/scripts/sourcemap-hunt.mjs +0 -112
- package/templates/vite/.howone/skills/web-clone/scripts/visual-diff.mjs +0 -161
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import crypto from "node:crypto";
|
|
5
|
-
import { loadPlaywright, launchChromium } from "./lib/playwright-loader.mjs";
|
|
6
|
-
|
|
7
|
-
function usage() {
|
|
8
|
-
console.log(`Usage:
|
|
9
|
-
node scripts/route-crawl.mjs --url <url> --out RECON/routes [--label original] [--max-pages 25] [--max-depth 2] [--width 1440] [--wait 800] [--allow-subdomains]
|
|
10
|
-
|
|
11
|
-
Crawls same-site internal links, captures a screenshot per route, and writes a route map.
|
|
12
|
-
`);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function parseArgs(argv) {
|
|
16
|
-
const out = {
|
|
17
|
-
url: "",
|
|
18
|
-
outDir: "RECON/routes",
|
|
19
|
-
label: "site",
|
|
20
|
-
maxPages: 25,
|
|
21
|
-
maxDepth: 2,
|
|
22
|
-
width: 1440,
|
|
23
|
-
waitMs: 800,
|
|
24
|
-
allowSubdomains: false,
|
|
25
|
-
};
|
|
26
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
27
|
-
const arg = argv[i];
|
|
28
|
-
if (arg === "--help" || arg === "-h") out.help = true;
|
|
29
|
-
else if (arg === "--url") out.url = argv[++i] || "";
|
|
30
|
-
else if (arg === "--out") out.outDir = argv[++i] || "RECON/routes";
|
|
31
|
-
else if (arg === "--label") out.label = argv[++i] || "site";
|
|
32
|
-
else if (arg === "--max-pages") out.maxPages = Number(argv[++i] || "25");
|
|
33
|
-
else if (arg === "--max-depth") out.maxDepth = Number(argv[++i] || "2");
|
|
34
|
-
else if (arg === "--width") out.width = Number(argv[++i] || "1440");
|
|
35
|
-
else if (arg === "--wait") out.waitMs = Number(argv[++i] || "800");
|
|
36
|
-
else if (arg === "--allow-subdomains") out.allowSubdomains = true;
|
|
37
|
-
else throw new Error(`Unexpected argument: ${arg}`);
|
|
38
|
-
}
|
|
39
|
-
return out;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function normalizeUrl(rawUrl, baseUrl) {
|
|
43
|
-
try {
|
|
44
|
-
const url = new URL(rawUrl, baseUrl);
|
|
45
|
-
if (!["http:", "https:"].includes(url.protocol)) return "";
|
|
46
|
-
url.hash = "";
|
|
47
|
-
url.searchParams.sort();
|
|
48
|
-
return url.toString().replace(/\/$/, "");
|
|
49
|
-
} catch {
|
|
50
|
-
return "";
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function sameSite(candidate, origin, allowSubdomains) {
|
|
55
|
-
const url = new URL(candidate);
|
|
56
|
-
const root = new URL(origin);
|
|
57
|
-
if (url.origin === root.origin) return true;
|
|
58
|
-
return allowSubdomains && url.hostname.endsWith(`.${root.hostname}`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function routeFileName(url) {
|
|
62
|
-
const parsed = new URL(url);
|
|
63
|
-
const clean = `${parsed.hostname}${parsed.pathname}`.replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 90) || "route";
|
|
64
|
-
const hash = crypto.createHash("sha1").update(url).digest("hex").slice(0, 8);
|
|
65
|
-
return `${clean}-${hash}.png`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function summarizeMarkdown(result) {
|
|
69
|
-
const lines = [
|
|
70
|
-
`# ${result.label} route map`,
|
|
71
|
-
"",
|
|
72
|
-
`- URL: ${result.url}`,
|
|
73
|
-
`- Captured routes: ${result.routes.length}`,
|
|
74
|
-
`- Max depth: ${result.maxDepth}`,
|
|
75
|
-
`- Max pages: ${result.maxPages}`,
|
|
76
|
-
"",
|
|
77
|
-
"## Routes",
|
|
78
|
-
"| Depth | Status | Path | Title | H1 | Links | Screenshot |",
|
|
79
|
-
"|---:|---:|---|---|---|---:|---|",
|
|
80
|
-
];
|
|
81
|
-
for (const route of result.routes) {
|
|
82
|
-
const url = new URL(route.url);
|
|
83
|
-
const pathLabel = `${url.pathname}${url.search}`;
|
|
84
|
-
lines.push(`| ${route.depth} | ${route.status || ""} | ${pathLabel || "/"} | ${route.title.replaceAll("|", "\\|")} | ${route.h1.join(" / ").replaceAll("|", "\\|")} | ${route.linkCount} | ${route.screenshot} |`);
|
|
85
|
-
}
|
|
86
|
-
if (result.skipped.length) {
|
|
87
|
-
lines.push("");
|
|
88
|
-
lines.push("## Skipped / Failed");
|
|
89
|
-
for (const item of result.skipped.slice(0, 80)) {
|
|
90
|
-
lines.push(`- ${item.url} · ${item.reason}`);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return `${lines.join("\n")}\n`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function collectPage(page) {
|
|
97
|
-
return page.evaluate(() => {
|
|
98
|
-
const text = (node) => (node?.textContent || "").trim().replace(/\s+/g, " ");
|
|
99
|
-
const links = Array.from(document.querySelectorAll("a[href]")).map((a) => ({
|
|
100
|
-
href: a.href,
|
|
101
|
-
text: text(a).slice(0, 120),
|
|
102
|
-
}));
|
|
103
|
-
const headings = Array.from(document.querySelectorAll("h1,h2,h3")).slice(0, 40).map((node) => ({
|
|
104
|
-
tag: node.tagName.toLowerCase(),
|
|
105
|
-
text: text(node).slice(0, 160),
|
|
106
|
-
}));
|
|
107
|
-
return {
|
|
108
|
-
href: location.href,
|
|
109
|
-
title: document.title || "",
|
|
110
|
-
lang: document.documentElement.lang || "",
|
|
111
|
-
metaDescription: document.querySelector("meta[name='description']")?.content || "",
|
|
112
|
-
h1: Array.from(document.querySelectorAll("h1")).map((node) => text(node)).filter(Boolean).slice(0, 8),
|
|
113
|
-
headings,
|
|
114
|
-
scrollHeight: document.documentElement.scrollHeight,
|
|
115
|
-
counts: {
|
|
116
|
-
links: links.length,
|
|
117
|
-
images: document.images.length,
|
|
118
|
-
canvas: document.querySelectorAll("canvas").length,
|
|
119
|
-
forms: document.forms.length,
|
|
120
|
-
buttons: document.querySelectorAll("button,[role='button']").length,
|
|
121
|
-
},
|
|
122
|
-
links,
|
|
123
|
-
};
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
const args = parseArgs(process.argv.slice(2));
|
|
129
|
-
if (args.help || !args.url) {
|
|
130
|
-
usage();
|
|
131
|
-
process.exit(args.help ? 0 : 1);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const startUrl = normalizeUrl(args.url, args.url);
|
|
135
|
-
if (!startUrl) throw new Error(`Invalid URL: ${args.url}`);
|
|
136
|
-
|
|
137
|
-
const outDir = path.resolve(args.outDir);
|
|
138
|
-
const screenshotsDir = path.join(outDir, "screenshots");
|
|
139
|
-
fs.mkdirSync(screenshotsDir, { recursive: true });
|
|
140
|
-
|
|
141
|
-
const { chromium } = loadPlaywright();
|
|
142
|
-
const browser = await launchChromium(chromium);
|
|
143
|
-
const page = await browser.newPage({ viewport: { width: args.width, height: 900 }, deviceScaleFactor: 1 });
|
|
144
|
-
|
|
145
|
-
const queue = [{ url: startUrl, depth: 0, from: "" }];
|
|
146
|
-
const seen = new Set();
|
|
147
|
-
const routes = [];
|
|
148
|
-
const skipped = [];
|
|
149
|
-
|
|
150
|
-
while (queue.length && routes.length < args.maxPages) {
|
|
151
|
-
const current = queue.shift();
|
|
152
|
-
if (!current || seen.has(current.url)) continue;
|
|
153
|
-
seen.add(current.url);
|
|
154
|
-
|
|
155
|
-
if (current.depth > args.maxDepth) {
|
|
156
|
-
skipped.push({ url: current.url, reason: `depth>${args.maxDepth}` });
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
|
-
if (!sameSite(current.url, startUrl, args.allowSubdomains)) {
|
|
160
|
-
skipped.push({ url: current.url, reason: "external" });
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const consoleErrors = [];
|
|
165
|
-
const onConsole = (message) => {
|
|
166
|
-
if (message.type() === "error") consoleErrors.push(message.text());
|
|
167
|
-
};
|
|
168
|
-
page.on("console", onConsole);
|
|
169
|
-
|
|
170
|
-
try {
|
|
171
|
-
const response = await page.goto(current.url, { waitUntil: "domcontentloaded", timeout: 45000 });
|
|
172
|
-
await page.waitForLoadState("networkidle", { timeout: 6000 }).catch(() => {});
|
|
173
|
-
if (args.waitMs > 0) await page.waitForTimeout(args.waitMs);
|
|
174
|
-
const data = await collectPage(page);
|
|
175
|
-
const screenshotName = routeFileName(current.url);
|
|
176
|
-
const screenshotPath = path.join(screenshotsDir, screenshotName);
|
|
177
|
-
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
178
|
-
|
|
179
|
-
routes.push({
|
|
180
|
-
url: current.url,
|
|
181
|
-
from: current.from,
|
|
182
|
-
depth: current.depth,
|
|
183
|
-
status: response?.status() || 0,
|
|
184
|
-
title: data.title,
|
|
185
|
-
lang: data.lang,
|
|
186
|
-
metaDescription: data.metaDescription,
|
|
187
|
-
h1: data.h1,
|
|
188
|
-
headings: data.headings,
|
|
189
|
-
counts: data.counts,
|
|
190
|
-
linkCount: data.links.length,
|
|
191
|
-
screenshot: path.relative(outDir, screenshotPath),
|
|
192
|
-
consoleErrors,
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
for (const link of data.links) {
|
|
196
|
-
const nextUrl = normalizeUrl(link.href, current.url);
|
|
197
|
-
if (!nextUrl || seen.has(nextUrl)) continue;
|
|
198
|
-
if (!sameSite(nextUrl, startUrl, args.allowSubdomains)) continue;
|
|
199
|
-
queue.push({ url: nextUrl, depth: current.depth + 1, from: current.url });
|
|
200
|
-
}
|
|
201
|
-
} catch (error) {
|
|
202
|
-
skipped.push({ url: current.url, reason: error.message });
|
|
203
|
-
} finally {
|
|
204
|
-
page.off("console", onConsole);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
await browser.close();
|
|
209
|
-
|
|
210
|
-
const result = {
|
|
211
|
-
label: args.label,
|
|
212
|
-
url: startUrl,
|
|
213
|
-
capturedAt: new Date().toISOString(),
|
|
214
|
-
maxPages: args.maxPages,
|
|
215
|
-
maxDepth: args.maxDepth,
|
|
216
|
-
allowSubdomains: args.allowSubdomains,
|
|
217
|
-
routes,
|
|
218
|
-
skipped,
|
|
219
|
-
};
|
|
220
|
-
const jsonFile = path.join(outDir, `${args.label}-route-map.json`);
|
|
221
|
-
const mdFile = path.join(outDir, `${args.label}-route-map.md`);
|
|
222
|
-
fs.writeFileSync(jsonFile, `${JSON.stringify(result, null, 2)}\n`);
|
|
223
|
-
fs.writeFileSync(mdFile, summarizeMarkdown(result));
|
|
224
|
-
console.log(jsonFile);
|
|
225
|
-
} catch (error) {
|
|
226
|
-
console.error(`route-crawl failed: ${error.message}`);
|
|
227
|
-
process.exit(1);
|
|
228
|
-
}
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import crypto from "node:crypto";
|
|
5
|
-
|
|
6
|
-
function usage() {
|
|
7
|
-
console.log(`Usage:
|
|
8
|
-
node scripts/sourcemap-hunt.mjs --recon original-recon.json --out RECON/sourcemaps [--all-external]
|
|
9
|
-
|
|
10
|
-
Finds sourceMappingURL hints in discovered JavaScript bundles and tries to download source maps.
|
|
11
|
-
`);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function parseArgs(argv) {
|
|
15
|
-
const out = { recon: "", outDir: "RECON/sourcemaps", allExternal: false };
|
|
16
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
17
|
-
const arg = argv[i];
|
|
18
|
-
if (arg === "--help" || arg === "-h") out.help = true;
|
|
19
|
-
else if (arg === "--recon") out.recon = argv[++i] || "";
|
|
20
|
-
else if (arg === "--out") out.outDir = argv[++i] || "RECON/sourcemaps";
|
|
21
|
-
else if (arg === "--all-external") out.allExternal = true;
|
|
22
|
-
else throw new Error(`Unexpected argument: ${arg}`);
|
|
23
|
-
}
|
|
24
|
-
return out;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function collectScripts(recon) {
|
|
28
|
-
const scripts = new Set();
|
|
29
|
-
for (const capture of recon.captures || []) {
|
|
30
|
-
for (const script of capture.signals?.scripts || []) {
|
|
31
|
-
if (/^https?:\/\//i.test(script)) scripts.add(script);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return Array.from(scripts);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function fileNameFor(url, suffix = "") {
|
|
38
|
-
const parsed = new URL(url);
|
|
39
|
-
const base = path.basename(parsed.pathname).replace(/[^a-z0-9._-]+/gi, "-").slice(0, 90) || "bundle.js";
|
|
40
|
-
const hash = crypto.createHash("sha1").update(url).digest("hex").slice(0, 10);
|
|
41
|
-
return `${base}-${hash}${suffix}`;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function resolveMapUrl(scriptUrl, mapHint) {
|
|
45
|
-
if (!mapHint) return `${scriptUrl}.map`;
|
|
46
|
-
if (mapHint.startsWith("data:")) return "";
|
|
47
|
-
return new URL(mapHint, scriptUrl).toString();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function fetchText(url) {
|
|
51
|
-
const response = await fetch(url, {
|
|
52
|
-
headers: {
|
|
53
|
-
"user-agent": "web-clone-skill/1.0 sourcemap-hunt",
|
|
54
|
-
"accept": "*/*",
|
|
55
|
-
},
|
|
56
|
-
});
|
|
57
|
-
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
58
|
-
return response.text();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
const args = parseArgs(process.argv.slice(2));
|
|
63
|
-
if (args.help || !args.recon) {
|
|
64
|
-
usage();
|
|
65
|
-
process.exit(args.help ? 0 : 1);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const recon = JSON.parse(fs.readFileSync(args.recon, "utf8"));
|
|
69
|
-
const originHost = recon.url ? new URL(recon.url).hostname : "";
|
|
70
|
-
const scripts = collectScripts(recon).filter((script) => args.allExternal || new URL(script).hostname === originHost);
|
|
71
|
-
const outDir = path.resolve(args.outDir);
|
|
72
|
-
fs.mkdirSync(outDir, { recursive: true });
|
|
73
|
-
|
|
74
|
-
const results = [];
|
|
75
|
-
for (const scriptUrl of scripts) {
|
|
76
|
-
const entry = { scriptUrl, status: "unknown", mapUrl: "", mapFile: "", error: "" };
|
|
77
|
-
try {
|
|
78
|
-
const js = await fetchText(scriptUrl);
|
|
79
|
-
const hint = js.match(/[#@]\s*sourceMappingURL=([^\s*]+)/)?.[1] || "";
|
|
80
|
-
entry.mapUrl = resolveMapUrl(scriptUrl, hint);
|
|
81
|
-
if (!entry.mapUrl) {
|
|
82
|
-
entry.status = "inline-or-data-map";
|
|
83
|
-
results.push(entry);
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
const mapText = await fetchText(entry.mapUrl);
|
|
87
|
-
const mapFile = path.join(outDir, fileNameFor(entry.mapUrl, ".map"));
|
|
88
|
-
fs.writeFileSync(mapFile, mapText);
|
|
89
|
-
entry.mapFile = mapFile;
|
|
90
|
-
entry.status = "ok";
|
|
91
|
-
} catch (error) {
|
|
92
|
-
entry.status = "error";
|
|
93
|
-
entry.error = error.message;
|
|
94
|
-
}
|
|
95
|
-
results.push(entry);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const manifest = {
|
|
99
|
-
source: args.recon,
|
|
100
|
-
url: recon.url,
|
|
101
|
-
allExternal: args.allExternal,
|
|
102
|
-
scriptCount: scripts.length,
|
|
103
|
-
mapCount: results.filter((item) => item.status === "ok").length,
|
|
104
|
-
results,
|
|
105
|
-
};
|
|
106
|
-
const manifestFile = path.join(outDir, "sourcemap-manifest.json");
|
|
107
|
-
fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
108
|
-
console.log(manifestFile);
|
|
109
|
-
} catch (error) {
|
|
110
|
-
console.error(`sourcemap-hunt failed: ${error.message}`);
|
|
111
|
-
process.exit(1);
|
|
112
|
-
}
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { loadPlaywright, launchChromium } from "./lib/playwright-loader.mjs";
|
|
5
|
-
|
|
6
|
-
function usage() {
|
|
7
|
-
console.log(`Usage:
|
|
8
|
-
node scripts/visual-diff.mjs --original <png> --clone <png> --out visual-diff.json [--diff visual-diff.png] [--threshold 0.08]
|
|
9
|
-
|
|
10
|
-
Compares screenshots in a real browser canvas and outputs numeric visual-diff metrics.
|
|
11
|
-
`);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function parseArgs(argv) {
|
|
15
|
-
const out = { original: "", clone: "", out: "visual-diff.json", diff: "", threshold: 0.08 };
|
|
16
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
17
|
-
const arg = argv[i];
|
|
18
|
-
if (arg === "--help" || arg === "-h") out.help = true;
|
|
19
|
-
else if (arg === "--original") out.original = argv[++i] || "";
|
|
20
|
-
else if (arg === "--clone") out.clone = argv[++i] || "";
|
|
21
|
-
else if (arg === "--out") out.out = argv[++i] || "visual-diff.json";
|
|
22
|
-
else if (arg === "--diff") out.diff = argv[++i] || "";
|
|
23
|
-
else if (arg === "--threshold") out.threshold = Number(argv[++i] || "0.08");
|
|
24
|
-
else throw new Error(`Unexpected argument: ${arg}`);
|
|
25
|
-
}
|
|
26
|
-
return out;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function imageDataUrl(file) {
|
|
30
|
-
const ext = path.extname(file).slice(1).toLowerCase() || "png";
|
|
31
|
-
const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
|
|
32
|
-
return `data:${mime};base64,${fs.readFileSync(file).toString("base64")}`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function scoreFromDiff(diffRatio, meanAbsDiff) {
|
|
36
|
-
if (diffRatio <= 0.01 && meanAbsDiff <= 0.01) return 5;
|
|
37
|
-
if (diffRatio <= 0.04 && meanAbsDiff <= 0.025) return 4.5;
|
|
38
|
-
if (diffRatio <= 0.08 && meanAbsDiff <= 0.05) return 4;
|
|
39
|
-
if (diffRatio <= 0.16 && meanAbsDiff <= 0.08) return 3;
|
|
40
|
-
if (diffRatio <= 0.3 && meanAbsDiff <= 0.14) return 2;
|
|
41
|
-
return 1;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function compareInBrowser(page, original, clone, threshold) {
|
|
45
|
-
return page.evaluate(async ({ original, clone, threshold }) => {
|
|
46
|
-
const loadImage = (src) => new Promise((resolve, reject) => {
|
|
47
|
-
const image = new Image();
|
|
48
|
-
image.onload = () => resolve(image);
|
|
49
|
-
image.onerror = () => reject(new Error(`Failed to load image: ${src.slice(0, 80)}`));
|
|
50
|
-
image.src = src;
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
const [left, right] = await Promise.all([loadImage(original), loadImage(clone)]);
|
|
54
|
-
const width = Math.max(left.naturalWidth, right.naturalWidth);
|
|
55
|
-
const height = Math.max(left.naturalHeight, right.naturalHeight);
|
|
56
|
-
|
|
57
|
-
const canvasA = document.createElement("canvas");
|
|
58
|
-
const canvasB = document.createElement("canvas");
|
|
59
|
-
const canvasD = document.createElement("canvas");
|
|
60
|
-
for (const canvas of [canvasA, canvasB, canvasD]) {
|
|
61
|
-
canvas.width = width;
|
|
62
|
-
canvas.height = height;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const ctxA = canvasA.getContext("2d");
|
|
66
|
-
const ctxB = canvasB.getContext("2d");
|
|
67
|
-
const ctxD = canvasD.getContext("2d");
|
|
68
|
-
ctxA.fillStyle = "white";
|
|
69
|
-
ctxB.fillStyle = "white";
|
|
70
|
-
ctxA.fillRect(0, 0, width, height);
|
|
71
|
-
ctxB.fillRect(0, 0, width, height);
|
|
72
|
-
ctxA.drawImage(left, 0, 0);
|
|
73
|
-
ctxB.drawImage(right, 0, 0);
|
|
74
|
-
|
|
75
|
-
const a = ctxA.getImageData(0, 0, width, height);
|
|
76
|
-
const b = ctxB.getImageData(0, 0, width, height);
|
|
77
|
-
const d = ctxD.createImageData(width, height);
|
|
78
|
-
let changed = 0;
|
|
79
|
-
let sumAbs = 0;
|
|
80
|
-
let sumSq = 0;
|
|
81
|
-
|
|
82
|
-
for (let i = 0; i < a.data.length; i += 4) {
|
|
83
|
-
const dr = Math.abs(a.data[i] - b.data[i]);
|
|
84
|
-
const dg = Math.abs(a.data[i + 1] - b.data[i + 1]);
|
|
85
|
-
const db = Math.abs(a.data[i + 2] - b.data[i + 2]);
|
|
86
|
-
const da = Math.abs(a.data[i + 3] - b.data[i + 3]);
|
|
87
|
-
const delta = (dr + dg + db + da) / 1020;
|
|
88
|
-
sumAbs += delta;
|
|
89
|
-
sumSq += delta * delta;
|
|
90
|
-
if (delta > threshold) changed += 1;
|
|
91
|
-
|
|
92
|
-
if (delta > threshold) {
|
|
93
|
-
d.data[i] = 255;
|
|
94
|
-
d.data[i + 1] = Math.max(0, 80 - delta * 80);
|
|
95
|
-
d.data[i + 2] = Math.max(0, 80 - delta * 80);
|
|
96
|
-
d.data[i + 3] = 255;
|
|
97
|
-
} else {
|
|
98
|
-
d.data[i] = Math.round(a.data[i] * 0.25 + 245 * 0.75);
|
|
99
|
-
d.data[i + 1] = Math.round(a.data[i + 1] * 0.25 + 245 * 0.75);
|
|
100
|
-
d.data[i + 2] = Math.round(a.data[i + 2] * 0.25 + 245 * 0.75);
|
|
101
|
-
d.data[i + 3] = 255;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
ctxD.putImageData(d, 0, 0);
|
|
106
|
-
const pixels = width * height;
|
|
107
|
-
const diffPixelRatio = changed / pixels;
|
|
108
|
-
const meanAbsDiff = sumAbs / pixels;
|
|
109
|
-
const rmse = Math.sqrt(sumSq / pixels);
|
|
110
|
-
|
|
111
|
-
return {
|
|
112
|
-
original: { width: left.naturalWidth, height: left.naturalHeight },
|
|
113
|
-
clone: { width: right.naturalWidth, height: right.naturalHeight },
|
|
114
|
-
comparedCanvas: { width, height },
|
|
115
|
-
threshold,
|
|
116
|
-
changedPixels: changed,
|
|
117
|
-
totalPixels: pixels,
|
|
118
|
-
diffPixelRatio,
|
|
119
|
-
meanAbsDiff,
|
|
120
|
-
rmse,
|
|
121
|
-
diffPngDataUrl: canvasD.toDataURL("image/png"),
|
|
122
|
-
};
|
|
123
|
-
}, { original, clone, threshold });
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
const args = parseArgs(process.argv.slice(2));
|
|
128
|
-
if (args.help || !args.original || !args.clone) {
|
|
129
|
-
usage();
|
|
130
|
-
process.exit(args.help ? 0 : 1);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const { chromium } = loadPlaywright();
|
|
134
|
-
const browser = await launchChromium(chromium);
|
|
135
|
-
const page = await browser.newPage();
|
|
136
|
-
const result = await compareInBrowser(page, imageDataUrl(args.original), imageDataUrl(args.clone), args.threshold);
|
|
137
|
-
await browser.close();
|
|
138
|
-
|
|
139
|
-
const diffDataUrl = result.diffPngDataUrl;
|
|
140
|
-
delete result.diffPngDataUrl;
|
|
141
|
-
result.visualScore = scoreFromDiff(result.diffPixelRatio, result.meanAbsDiff);
|
|
142
|
-
result.files = {
|
|
143
|
-
original: path.resolve(args.original),
|
|
144
|
-
clone: path.resolve(args.clone),
|
|
145
|
-
diff: args.diff ? path.resolve(args.diff) : "",
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
fs.mkdirSync(path.dirname(path.resolve(args.out)), { recursive: true });
|
|
149
|
-
fs.writeFileSync(args.out, `${JSON.stringify(result, null, 2)}\n`);
|
|
150
|
-
|
|
151
|
-
if (args.diff) {
|
|
152
|
-
const base64 = diffDataUrl.replace(/^data:image\/png;base64,/, "");
|
|
153
|
-
fs.mkdirSync(path.dirname(path.resolve(args.diff)), { recursive: true });
|
|
154
|
-
fs.writeFileSync(args.diff, Buffer.from(base64, "base64"));
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
console.log(path.resolve(args.out));
|
|
158
|
-
} catch (error) {
|
|
159
|
-
console.error(`visual-diff failed: ${error.message}`);
|
|
160
|
-
process.exit(1);
|
|
161
|
-
}
|