axstack 0.36.1 → 0.38.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 +5 -4
- package/bin/axstack.js +4 -18
- package/docs/getting-started.md +6 -6
- package/docs/host-operations.md +9 -5
- package/docs/installation.md +20 -41
- package/docs/workflows.md +12 -9
- package/package.json +1 -1
- package/profiles/presets/claude-only.json +3 -3
- package/profiles/presets/codex-only.json +3 -3
- package/profiles/presets/mixed.json +5 -4
- package/skills/axstack/references/automations.md +7 -2
- package/skills/axstack/references/lifecycle.md +2 -1
- package/skills/axstack/references/review-manager-prompt.md +44 -3
- package/skills/axstack/references/role-roster.md +13 -6
- package/skills/axstack/references/routing.md +2 -1
- package/skills/axstack/references/run-record.md +2 -0
- package/skills/axstack/references/ui-verification.md +6 -10
- package/skills/axstack-anatomy/ANATOMY.md +172 -0
- package/skills/axstack-anatomy/CHANGELOG.md +70 -0
- package/skills/axstack-anatomy/LICENSE +21 -0
- package/skills/axstack-anatomy/PROVENANCE.md +21 -0
- package/skills/axstack-anatomy/README.md +316 -0
- package/skills/axstack-anatomy/SKILL.md +74 -0
- package/skills/axstack-anatomy/SOURCE-PROOF.json +107 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/arcade-cabinet.css +44 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/build.mjs +714 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/live.js +597 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/parts.mjs +89 -0
- package/skills/axstack-anatomy/examples/arcade-cabinet/screen.mjs +114 -0
- package/skills/axstack-anatomy/examples/desk-computer/build.mjs +435 -0
- package/skills/axstack-anatomy/examples/dial-indicator/build.mjs +275 -0
- package/skills/axstack-anatomy/examples/raptor-engine/build.mjs +938 -0
- package/skills/axstack-anatomy/examples/raptor-engine/live.js +907 -0
- package/skills/axstack-anatomy/examples/raptor-engine/raptor.css +359 -0
- package/skills/axstack-anatomy/examples/ripple-tank/build.mjs +558 -0
- package/skills/axstack-anatomy/examples/ripple-tank/live.js +579 -0
- package/skills/axstack-anatomy/examples/ripple-tank/model.mjs +131 -0
- package/skills/axstack-anatomy/examples/ripple-tank/ripple.css +103 -0
- package/skills/axstack-anatomy/examples/spot-plate/art.tsx +110 -0
- package/skills/axstack-anatomy/examples/spot-plate/drops.ts +541 -0
- package/skills/axstack-anatomy/examples/spot-plate/fan.ts +84 -0
- package/skills/axstack-anatomy/examples/spot-plate/figure.tsx +25 -0
- package/skills/axstack-anatomy/examples/spot-plate/geometry.ts +280 -0
- package/skills/axstack-anatomy/examples/spot-plate/live.tsx +339 -0
- package/skills/axstack-anatomy/examples/spot-plate/rig.ts +230 -0
- package/skills/axstack-anatomy/examples/spot-plate/spot-plate.css +92 -0
- package/skills/axstack-anatomy/examples/test-rig/art.tsx +196 -0
- package/skills/axstack-anatomy/examples/test-rig/figure.tsx +23 -0
- package/skills/axstack-anatomy/examples/test-rig/geometry.ts +305 -0
- package/skills/axstack-anatomy/examples/test-rig/live.tsx +511 -0
- package/skills/axstack-anatomy/examples/test-rig/pose.ts +107 -0
- package/skills/axstack-anatomy/examples/test-rig/rig.ts +170 -0
- package/skills/axstack-anatomy/examples/test-rig/stretch.css +80 -0
- package/skills/axstack-anatomy/examples/test-rig/view.ts +99 -0
- package/skills/axstack-anatomy/examples/turning-dial/build.mjs +479 -0
- package/skills/axstack-anatomy/examples/turning-dial/dial.css +10 -0
- package/skills/axstack-anatomy/examples/turning-dial/live.js +342 -0
- package/skills/axstack-anatomy/examples/turning-dial/stress.mjs +269 -0
- package/skills/axstack-anatomy/kit/audit.mjs +1913 -0
- package/skills/axstack-anatomy/kit/canvas-painter.mjs +722 -0
- package/skills/axstack-anatomy/kit/gl-shared.mjs +184 -0
- package/skills/axstack-anatomy/kit/gl.mjs +184 -0
- package/skills/axstack-anatomy/kit/iso-kit.mjs +674 -0
- package/skills/axstack-anatomy/kit/iso-kit.ts +842 -0
- package/skills/axstack-anatomy/kit/iso.css +106 -0
- package/skills/axstack-anatomy/kit/lathe.mjs +1184 -0
- package/skills/axstack-anatomy/kit/react/draw.tsx +205 -0
- package/skills/axstack-anatomy/kit/react/live.tsx +140 -0
- package/skills/axstack-anatomy/kit/tube.mjs +306 -0
- package/skills/axstack-anatomy/kit/turn-audit.mjs +1096 -0
- package/skills/axstack-anatomy/kit/turn-build.mjs +1355 -0
- package/skills/axstack-anatomy/kit/turn-fixed.mjs +3800 -0
- package/skills/axstack-anatomy/kit/turn.mjs +3610 -0
- package/skills/axstack-anatomy/references/3d.md +352 -0
- package/skills/axstack-anatomy/references/craft.md +325 -0
- package/skills/axstack-anatomy/references/kit.md +419 -0
- package/skills/axstack-anatomy/references/motion.md +165 -0
- package/skills/axstack-anatomy/references/react.md +42 -0
- package/skills/axstack-anatomy/references/verify.md +351 -0
- package/skills/axstack-anatomy/references/walkthrough.md +75 -0
- package/skills/axstack-anatomy/references/webgl.md +473 -0
- package/skills/axstack-anatomy/scripts/audit-affine-bounds.test.mjs +54 -0
- package/skills/axstack-anatomy/scripts/audit-cache.test.mjs +37 -0
- package/skills/axstack-anatomy/scripts/audit-clearance-envelope.test.mjs +39 -0
- package/skills/axstack-anatomy/scripts/audit-empty-clipping.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/audit-order-convergence.test.mjs +55 -0
- package/skills/axstack-anatomy/scripts/audit-path-origin.test.mjs +26 -0
- package/skills/axstack-anatomy/scripts/audit-profile.test.mjs +41 -0
- package/skills/axstack-anatomy/scripts/audit-projection.test.mjs +36 -0
- package/skills/axstack-anatomy/scripts/audit-slide-clearance.test.mjs +58 -0
- package/skills/axstack-anatomy/scripts/audit-svg-attributes.test.mjs +46 -0
- package/skills/axstack-anatomy/scripts/audit-taper.test.mjs +29 -0
- package/skills/axstack-anatomy/scripts/capture-selector.test.mjs +131 -0
- package/skills/axstack-anatomy/scripts/capture.mjs +228 -0
- package/skills/axstack-anatomy/scripts/capture.test.mjs +42 -0
- package/skills/axstack-anatomy/scripts/chrome-port.mjs +36 -0
- package/skills/axstack-anatomy/scripts/chrome-startup.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/drive-actions.test.mjs +46 -0
- package/skills/axstack-anatomy/scripts/drive.mjs +302 -0
- package/skills/axstack-anatomy/scripts/drive.test.mjs +72 -0
- package/skills/axstack-anatomy/scripts/enter-key.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/fidelity-cut.test.mjs +54 -0
- package/skills/axstack-anatomy/scripts/fidelity-tube-coverage.test.mjs +40 -0
- package/skills/axstack-anatomy/scripts/inline-kit.mjs +26 -0
- package/skills/axstack-anatomy/scripts/line-ends.mjs +306 -0
- package/skills/axstack-anatomy/scripts/lines-arcs.test.mjs +50 -0
- package/skills/axstack-anatomy/scripts/lines-bezier.test.mjs +28 -0
- package/skills/axstack-anatomy/scripts/lines-round.test.mjs +56 -0
- package/skills/axstack-anatomy/scripts/lines-rounded-rect.test.mjs +32 -0
- package/skills/axstack-anatomy/scripts/lines.mjs +31 -0
- package/skills/axstack-anatomy/scripts/lines.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/navigation.test.mjs +55 -0
- package/skills/axstack-anatomy/scripts/orbit-sampling.test.mjs +50 -0
- package/skills/axstack-anatomy/scripts/react-dots-opacity.test.mjs +51 -0
- package/skills/axstack-anatomy/scripts/react-readout-first-write.test.mjs +37 -0
- package/skills/axstack-anatomy/scripts/react-solid-seamless.test.mjs +47 -0
- package/skills/axstack-anatomy/scripts/react-visibility-target.test.mjs +38 -0
- package/skills/axstack-anatomy/scripts/turn-bench.mjs +74 -0
- package/skills/axstack-anatomy/scripts/turn-check.mjs +641 -0
- package/skills/axstack-anatomy/scripts/turn-fidelity.mjs +413 -0
- package/skills/axstack-anatomy/scripts/turn-group-reference.test.mjs +40 -0
- package/skills/axstack-anatomy/scripts/turn-split-owner.test.mjs +62 -0
- package/skills/axstack-anatomy/theme.js +56 -0
- package/skills/axstack-explain/SKILL.md +28 -15
- package/skills/axstack-explain/references/delivery.md +22 -0
- package/skills/{axstack-diagram → axstack-explain}/references/fidelity.md +1 -7
- package/skills/axstack-explain/references/inline-pages.md +8 -8
- package/skills/axstack-explain/references/visual-qa.md +3 -5
- package/skills/axstack-explain/references/whiteboard-defense.md +59 -0
- package/skills/axstack-research/SKILL.md +18 -6
- package/src/archify-legacy.js +70 -0
- package/src/capabilities.js +1 -29
- package/src/installer.js +11 -31
- package/skills/axstack-diagram/SKILL.md +0 -28
- package/skills/axstack-diagram/references/archify.md +0 -76
- package/src/archify-pin.js +0 -5
- package/src/archify.js +0 -151
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { chromePort } from "./chrome-port.mjs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
if (typeof WebSocket === "undefined" && !process.execArgv.includes("--experimental-websocket")) {
|
|
9
|
+
const child = spawn(process.execPath, ["--experimental-websocket", ...process.argv.slice(1)], { stdio: "inherit" });
|
|
10
|
+
child.on("exit", (code) => process.exit(code ?? 1));
|
|
11
|
+
} else {
|
|
12
|
+
await main();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function usage() {
|
|
16
|
+
console.log(`Capture a figure with headless Chrome.
|
|
17
|
+
|
|
18
|
+
node capture.mjs <file.html | file.svg | url> [options]
|
|
19
|
+
|
|
20
|
+
--out <png> output path (default: capture.png in the current directory)
|
|
21
|
+
--width <px> viewport width (default 1440; use 390 for a phone)
|
|
22
|
+
--height <px> viewport height (default 1000)
|
|
23
|
+
--scale <n> device pixel ratio (default 2)
|
|
24
|
+
--wait <ms> time to let the page settle before the first shot (default 2500)
|
|
25
|
+
--select <css> clip to this element (default: .iso-plate, else the whole page)
|
|
26
|
+
--zoom <x,y,w,h> clip a region inside the selected element, in CSS px, for close-up checks
|
|
27
|
+
--zoom-scale <n> pixel ratio for --zoom shots (default 4)
|
|
28
|
+
--hover <fx,fy> move the mouse to this fraction of the selected element first (e.g. 0.6,0.5)
|
|
29
|
+
--key <keys> press keys on the focused figure first, comma separated (e.g. Tab,ArrowRight,7,Escape);
|
|
30
|
+
Tab is skipped (the figure is focused for you), a comma is written Comma
|
|
31
|
+
--times <ms,...> extra shots at these delays, each measured from the first shot (not from the previous one),
|
|
32
|
+
saved as name-<ms>.png; a capture takes 60-200 ms, so delays closer than that run late
|
|
33
|
+
--reduced emulate prefers-reduced-motion: reduce
|
|
34
|
+
--theme <dark|light> emulate prefers-color-scheme
|
|
35
|
+
|
|
36
|
+
Prints the clip box, console errors, uncaught exceptions and whether the page scrolls sideways.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parse(argv) {
|
|
40
|
+
const options = { width: 1440, height: 1000, scale: 2, wait: 2500, zoomScale: 4, times: [] };
|
|
41
|
+
const rest = [];
|
|
42
|
+
for (let index = 0; index < argv.length; index++) {
|
|
43
|
+
const arg = argv[index];
|
|
44
|
+
const next = () => argv[++index];
|
|
45
|
+
if (arg === "--out") options.out = next();
|
|
46
|
+
else if (arg === "--width") options.width = Number(next());
|
|
47
|
+
else if (arg === "--height") options.height = Number(next());
|
|
48
|
+
else if (arg === "--scale") options.scale = Number(next());
|
|
49
|
+
else if (arg === "--wait") options.wait = Number(next());
|
|
50
|
+
else if (arg === "--select") options.select = next();
|
|
51
|
+
else if (arg === "--zoom") options.zoom = next().split(",").map(Number);
|
|
52
|
+
else if (arg === "--zoom-scale") options.zoomScale = Number(next());
|
|
53
|
+
else if (arg === "--hover") options.hover = next().split(",").map(Number);
|
|
54
|
+
else if (arg === "--key") options.keys = next().split(",");
|
|
55
|
+
else if (arg === "--times") options.times = next().split(",").map(Number);
|
|
56
|
+
else if (arg === "--reduced") options.reduced = true;
|
|
57
|
+
else if (arg === "--theme") options.theme = next();
|
|
58
|
+
else if (arg === "--help" || arg === "-h") options.help = true;
|
|
59
|
+
else rest.push(arg);
|
|
60
|
+
}
|
|
61
|
+
options.target = rest[0];
|
|
62
|
+
return options;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function chromePath() {
|
|
66
|
+
const candidates = [
|
|
67
|
+
process.env.CHROME_PATH,
|
|
68
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
69
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
70
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
71
|
+
"/usr/bin/google-chrome",
|
|
72
|
+
"/usr/bin/google-chrome-stable",
|
|
73
|
+
"/usr/bin/chromium",
|
|
74
|
+
"/usr/bin/chromium-browser",
|
|
75
|
+
].filter(Boolean);
|
|
76
|
+
const playwright = join(homedir(), "Library/Caches/ms-playwright");
|
|
77
|
+
if (existsSync(playwright)) {
|
|
78
|
+
for (const dir of readdirSync(playwright).sort().reverse()) {
|
|
79
|
+
for (const sub of ["chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", "chrome-mac/Chromium.app/Contents/MacOS/Chromium"]) {
|
|
80
|
+
candidates.push(join(playwright, dir, sub));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return candidates.find((path) => existsSync(path));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function main() {
|
|
88
|
+
const options = parse(process.argv.slice(2));
|
|
89
|
+
if (options.help || !options.target) {
|
|
90
|
+
usage();
|
|
91
|
+
process.exit(options.help ? 0 : 1);
|
|
92
|
+
}
|
|
93
|
+
const url = /^https?:|^file:/.test(options.target) ? options.target : pathToFileURL(resolve(options.target)).href;
|
|
94
|
+
const out = resolve(options.out ?? "capture.png");
|
|
95
|
+
const binary = chromePath();
|
|
96
|
+
if (!binary) {
|
|
97
|
+
console.error("No Chrome found. Set CHROME_PATH to a Chrome or Chromium binary.");
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
const profile = mkdtempSync(join(tmpdir(), "iso-capture-"));
|
|
101
|
+
const chrome = spawn(binary, [
|
|
102
|
+
"--headless=new",
|
|
103
|
+
"--remote-debugging-port=0",
|
|
104
|
+
`--user-data-dir=${profile}`,
|
|
105
|
+
"--no-first-run",
|
|
106
|
+
"--no-default-browser-check",
|
|
107
|
+
"--hide-scrollbars",
|
|
108
|
+
"--allow-file-access-from-files",
|
|
109
|
+
"--enable-unsafe-swiftshader",
|
|
110
|
+
"--ignore-gpu-blocklist",
|
|
111
|
+
"about:blank",
|
|
112
|
+
]);
|
|
113
|
+
const port = await chromePort(chrome, profile);
|
|
114
|
+
const finish = (code) => {
|
|
115
|
+
chrome.kill("SIGKILL");
|
|
116
|
+
try {
|
|
117
|
+
rmSync(profile, { recursive: true, force: true });
|
|
118
|
+
} catch {}
|
|
119
|
+
process.exit(code);
|
|
120
|
+
};
|
|
121
|
+
try {
|
|
122
|
+
const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: "PUT" })).json();
|
|
123
|
+
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
|
124
|
+
let id = 0;
|
|
125
|
+
const pending = new Map();
|
|
126
|
+
const problems = [];
|
|
127
|
+
socket.onmessage = (message) => {
|
|
128
|
+
const data = JSON.parse(message.data);
|
|
129
|
+
if (data.id && pending.has(data.id)) {
|
|
130
|
+
pending.get(data.id)(data);
|
|
131
|
+
pending.delete(data.id);
|
|
132
|
+
} else if (data.method === "Runtime.consoleAPICalled" && (data.params.type === "error" || data.params.type === "warning")) {
|
|
133
|
+
problems.push(`console.${data.params.type}: ${data.params.args.map((arg) => arg.value ?? arg.description ?? "").join(" ").slice(0, 400)}`);
|
|
134
|
+
} else if (data.method === "Runtime.exceptionThrown") {
|
|
135
|
+
const detail = data.params.exceptionDetails;
|
|
136
|
+
problems.push(`exception: ${detail.exception?.description ?? detail.text}`.slice(0, 600));
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
await new Promise((done) => (socket.onopen = done));
|
|
140
|
+
const send = (method, params = {}) =>
|
|
141
|
+
new Promise((done) => {
|
|
142
|
+
const call = ++id;
|
|
143
|
+
pending.set(call, done);
|
|
144
|
+
socket.send(JSON.stringify({ id: call, method, params }));
|
|
145
|
+
});
|
|
146
|
+
const evaluate = async (expression) => (await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true })).result?.result?.value;
|
|
147
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
148
|
+
await send("Runtime.enable");
|
|
149
|
+
await send("Page.enable");
|
|
150
|
+
const features = [];
|
|
151
|
+
if (options.reduced) features.push({ name: "prefers-reduced-motion", value: "reduce" });
|
|
152
|
+
if (options.theme) features.push({ name: "prefers-color-scheme", value: options.theme });
|
|
153
|
+
if (features.length) await send("Emulation.setEmulatedMedia", { features });
|
|
154
|
+
await send("Emulation.setDeviceMetricsOverride", { width: options.width, height: options.height, deviceScaleFactor: options.scale, mobile: options.width < 600 });
|
|
155
|
+
const navigation = await send("Page.navigate", { url });
|
|
156
|
+
if (navigation.error) throw new Error(`Navigation failed: ${navigation.error.message}`);
|
|
157
|
+
if (navigation.result?.errorText) throw new Error(`Navigation failed: ${navigation.result.errorText}`);
|
|
158
|
+
await sleep(Math.min(options.wait, 1500));
|
|
159
|
+
await evaluate(`(async()=>{const h=document.documentElement.scrollHeight;for(let y=0;y<h;y+=400){scrollTo(0,y);await new Promise(r=>setTimeout(r,60))}scrollTo(0,0)})()`);
|
|
160
|
+
await sleep(Math.max(0, options.wait - 1500));
|
|
161
|
+
const select = options.select ?? ".iso-plate";
|
|
162
|
+
const boxOf = async () => {
|
|
163
|
+
const box = await evaluate(`(()=>{let el;try{el=document.querySelector(${JSON.stringify(select)})}catch{};if(!el&&${options.select === undefined})el=document.querySelector("svg")||document.body;if(!el)return null;el.scrollIntoView({block:"center"});const r=el.getBoundingClientRect();return {x:r.left+scrollX,y:r.top+scrollY,w:r.width,h:r.height,vx:r.left,vy:r.top}})()`);
|
|
164
|
+
if (!box) throw new Error(`Capture target not found: ${select}`);
|
|
165
|
+
return box;
|
|
166
|
+
};
|
|
167
|
+
let box = await boxOf();
|
|
168
|
+
if (options.hover) {
|
|
169
|
+
await sleep(300);
|
|
170
|
+
box = await boxOf();
|
|
171
|
+
const x = box.vx + box.w * options.hover[0];
|
|
172
|
+
const y = box.vy + box.h * options.hover[1];
|
|
173
|
+
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: box.vx + 2, y: box.vy + 2 });
|
|
174
|
+
await sleep(60);
|
|
175
|
+
for (let step = 1; step <= 12; step++) {
|
|
176
|
+
await send("Input.dispatchMouseEvent", { type: "mouseMoved", x: box.vx + 2 + ((x - box.vx - 2) * step) / 12, y: box.vy + 2 + ((y - box.vy - 2) * step) / 12 });
|
|
177
|
+
await sleep(30);
|
|
178
|
+
}
|
|
179
|
+
await sleep(1200);
|
|
180
|
+
}
|
|
181
|
+
if (options.keys) {
|
|
182
|
+
const focus = options.select === undefined ? `document.querySelector('[tabindex="0"]')` : `(()=>{const el=document.querySelector(${JSON.stringify(select)});return el.matches('[tabindex="0"]')?el:el.querySelector('[tabindex="0"]')||el.closest('[tabindex="0"]')||el})()`;
|
|
183
|
+
await evaluate(`(()=>{const f=${focus};if(f)f.focus()})()`);
|
|
184
|
+
const named = { " ": "Space", "-": "Minus", "=": "Equal", ",": "Comma", ".": "Period", "/": "Slash", ";": "Semicolon", "'": "Quote", "[": "BracketLeft", "]": "BracketRight", "\\": "Backslash", "`": "Backquote" };
|
|
185
|
+
for (const entry of options.keys) {
|
|
186
|
+
if (entry === "Tab") continue;
|
|
187
|
+
const key = entry === "Comma" ? "," : entry === "Space" ? " " : entry;
|
|
188
|
+
const code = named[key] ?? (/^[0-9]$/.test(key) ? `Digit${key}` : key.length === 1 ? `Key${key.toUpperCase()}` : key);
|
|
189
|
+
await send("Input.dispatchKeyEvent", { type: "keyDown", key, code, text: key === "Enter" ? "\r" : key.length === 1 ? key : undefined, windowsVirtualKeyCode: key === " " ? 32 : key === "Enter" ? 13 : 0 });
|
|
190
|
+
await send("Input.dispatchKeyEvent", { type: "keyUp", key, code, windowsVirtualKeyCode: key === "Enter" ? 13 : undefined });
|
|
191
|
+
await sleep(250);
|
|
192
|
+
}
|
|
193
|
+
await sleep(1200);
|
|
194
|
+
}
|
|
195
|
+
const shoot = async (path, clip, scale) => {
|
|
196
|
+
const shot = await send("Page.captureScreenshot", { format: "png", captureBeyondViewport: true, clip: { ...clip, scale } });
|
|
197
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
198
|
+
writeFileSync(path, Buffer.from(shot.result.data, "base64"));
|
|
199
|
+
};
|
|
200
|
+
const clipOf = () => {
|
|
201
|
+
if (!options.zoom) return { clip: { x: box.x - 6, y: box.y - 6, width: box.w + 12, height: box.h + 12 }, scale: 1 };
|
|
202
|
+
const [zx, zy, zw, zh] = options.zoom;
|
|
203
|
+
return { clip: { x: box.x + zx, y: box.y + zy, width: zw, height: zh }, scale: options.zoomScale / options.scale };
|
|
204
|
+
};
|
|
205
|
+
const first = clipOf();
|
|
206
|
+
await shoot(out, first.clip, first.scale);
|
|
207
|
+
const shotAt = Date.now();
|
|
208
|
+
const readoutOf = () => evaluate(options.select === undefined ? `(document.querySelector("[data-readout]")||{}).textContent||""` : `(()=>{const el=document.querySelector(${JSON.stringify(select)});const plate=el.closest(".iso-plate")||el.querySelector(".iso-plate")||el;return(plate.querySelector("[data-readout]")||{}).textContent||""})()`);
|
|
209
|
+
const readouts = [];
|
|
210
|
+
for (const delay of [...options.times].sort((a, b) => a - b)) {
|
|
211
|
+
await sleep(Math.max(0, shotAt + delay - Date.now()));
|
|
212
|
+
const next = clipOf();
|
|
213
|
+
await shoot(out.replace(/\.png$/, `-${delay}.png`), next.clip, next.scale);
|
|
214
|
+
readouts.push(`+${delay}ms "${await readoutOf()}"`);
|
|
215
|
+
}
|
|
216
|
+
const sideways = await evaluate(`document.documentElement.scrollWidth > innerWidth + 1`);
|
|
217
|
+
const readout = await readoutOf();
|
|
218
|
+
console.log(`saved ${out}`);
|
|
219
|
+
console.log(`clip ${Math.round(box.x)},${Math.round(box.y)} ${Math.round(box.w)}x${Math.round(box.h)} · readout "${readout}" · scrolls sideways: ${sideways ? "YES" : "no"}`);
|
|
220
|
+
if (readouts.length) console.log(`readout over time: ${readouts.join(" · ")}`);
|
|
221
|
+
console.log(problems.length ? problems.join("\n") : "console: clean");
|
|
222
|
+
socket.close();
|
|
223
|
+
finish(0);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
console.error(error);
|
|
226
|
+
finish(1);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
const capture = fileURLToPath(new URL("capture.mjs", import.meta.url));
|
|
12
|
+
|
|
13
|
+
test("capture creates nested output directories for initial and timed screenshots", async () => {
|
|
14
|
+
const directory = await mkdtemp(join(tmpdir(), "anatomy-capture-"));
|
|
15
|
+
try {
|
|
16
|
+
const page = join(directory, "figure.html");
|
|
17
|
+
const output = join(directory, "shots", "closeups", "figure.png");
|
|
18
|
+
await writeFile(page, '<!doctype html><div class="iso-plate" style="width:200px;height:100px;background:papayawhip">Figure</div>');
|
|
19
|
+
const result = await execute(process.execPath, [capture, page, "--out", output, "--wait", "0", "--times", "20", "--scale", "1"], { timeout: 30000 });
|
|
20
|
+
assert.match(result.stdout, /console: clean/);
|
|
21
|
+
for (const name of [output, output.replace(/\.png$/, "-20.png")]) {
|
|
22
|
+
const png = await readFile(name);
|
|
23
|
+
assert.deepEqual([...png.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]);
|
|
24
|
+
assert.ok(png.length > 100);
|
|
25
|
+
}
|
|
26
|
+
} finally {
|
|
27
|
+
await rm(directory, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("capture retains existing-directory output", async () => {
|
|
32
|
+
const directory = await mkdtemp(join(tmpdir(), "anatomy-capture-"));
|
|
33
|
+
try {
|
|
34
|
+
const page = join(directory, "figure.html");
|
|
35
|
+
const output = join(directory, "figure.png");
|
|
36
|
+
await writeFile(page, '<!doctype html><div class="iso-plate" style="width:200px;height:100px;background:papayawhip">Figure</div>');
|
|
37
|
+
await execute(process.execPath, [capture, page, "--out", output, "--wait", "0"], { timeout: 30000 });
|
|
38
|
+
assert.ok((await readFile(output)).length > 100);
|
|
39
|
+
} finally {
|
|
40
|
+
await rm(directory, { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export async function chromePort(chrome, profile) {
|
|
4
|
+
try {
|
|
5
|
+
return await new Promise((done, fail) => {
|
|
6
|
+
let text = "";
|
|
7
|
+
const clean = () => {
|
|
8
|
+
clearTimeout(timer);
|
|
9
|
+
chrome.stderr.off("data", data);
|
|
10
|
+
chrome.off("error", error);
|
|
11
|
+
chrome.off("exit", exited);
|
|
12
|
+
};
|
|
13
|
+
const error = (cause) => {
|
|
14
|
+
clean();
|
|
15
|
+
fail(cause);
|
|
16
|
+
};
|
|
17
|
+
const exited = (code, signal) => error(new Error(`Chrome exited before startup: ${code ?? signal}`));
|
|
18
|
+
const data = (chunk) => {
|
|
19
|
+
text += chunk;
|
|
20
|
+
const match = text.match(/DevTools listening on ws:\/\/[^:]+:(\d+)\//);
|
|
21
|
+
if (match) {
|
|
22
|
+
clean();
|
|
23
|
+
done(Number(match[1]));
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const timer = setTimeout(() => error(new Error("Chrome did not start")), 20000);
|
|
27
|
+
chrome.stderr.on("data", data);
|
|
28
|
+
chrome.on("error", error);
|
|
29
|
+
chrome.on("exit", exited);
|
|
30
|
+
});
|
|
31
|
+
} catch (error) {
|
|
32
|
+
chrome.kill("SIGKILL");
|
|
33
|
+
rmSync(profile, { recursive: true, force: true });
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { chmod, mkdtemp, readFile, rm, writeFile, access } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
for (const name of ["capture", "drive", "turn-check"]) {
|
|
13
|
+
test(`${name} removes its profile when Chrome exits before startup`, async () => {
|
|
14
|
+
const directory = await mkdtemp(join(tmpdir(), "anatomy-chrome-startup-"));
|
|
15
|
+
let profile;
|
|
16
|
+
try {
|
|
17
|
+
const binary = join(directory, "chrome");
|
|
18
|
+
const receipt = join(directory, "profile.txt");
|
|
19
|
+
await writeFile(binary, '#!/bin/sh\nfor arg in "$@"; do\n case "$arg" in --user-data-dir=*) printf "%s" "${arg#--user-data-dir=}" > "$ANATOMY_PROFILE_RECEIPT";; esac\ndone\nexit 7\n');
|
|
20
|
+
await chmod(binary, 0o755);
|
|
21
|
+
const started = performance.now();
|
|
22
|
+
let result;
|
|
23
|
+
try {
|
|
24
|
+
await execute(process.execPath, [fileURLToPath(new URL(`${name}.mjs`, import.meta.url)), join(directory, "figure.html"), ...(name === "drive" ? ["[]"] : name === "turn-check" ? ["--lines"] : [])], { timeout: 30000, env: { ...process.env, CHROME_PATH: binary, ANATOMY_PROFILE_RECEIPT: receipt } });
|
|
25
|
+
} catch (error) {
|
|
26
|
+
result = error;
|
|
27
|
+
}
|
|
28
|
+
profile = await readFile(receipt, "utf8");
|
|
29
|
+
assert.equal(result?.code, 1);
|
|
30
|
+
await assert.rejects(access(profile), { code: "ENOENT" });
|
|
31
|
+
assert.ok(performance.now() - started < 10000);
|
|
32
|
+
assert.match(result.stderr, /Chrome exited before startup.*7/);
|
|
33
|
+
} finally {
|
|
34
|
+
if (profile) await rm(profile, { recursive: true, force: true });
|
|
35
|
+
await rm(directory, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
|
|
10
|
+
const execute = promisify(execFile);
|
|
11
|
+
const drive = fileURLToPath(new URL("drive.mjs", import.meta.url));
|
|
12
|
+
|
|
13
|
+
async function run(actions) {
|
|
14
|
+
const directory = await mkdtemp(join(tmpdir(), "anatomy-drive-actions-"));
|
|
15
|
+
try {
|
|
16
|
+
const page = join(directory, "figure.html");
|
|
17
|
+
const file = join(directory, "actions.json");
|
|
18
|
+
await writeFile(page, '<!doctype html><div class="iso-plate">Figure</div>');
|
|
19
|
+
await writeFile(file, JSON.stringify(actions));
|
|
20
|
+
try {
|
|
21
|
+
return { code: 0, ...await execute(process.execPath, [drive, page, "--actions", file], { timeout: 30000 }) };
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return { code: error.code, stdout: error.stdout, stderr: error.stderr };
|
|
24
|
+
}
|
|
25
|
+
} finally {
|
|
26
|
+
await rm(directory, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("unknown actions fail instead of silently skipping verification", async () => {
|
|
31
|
+
const result = await run([["unti", "false", 1]]);
|
|
32
|
+
assert.equal(result.code, 1);
|
|
33
|
+
assert.match(result.stderr, /unknown action unti/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("valid actions still execute after validation", async () => {
|
|
37
|
+
const result = await run([["wait", 1], ["eval", "({checked:true})"]]);
|
|
38
|
+
assert.equal(result.code, 0);
|
|
39
|
+
assert.match(result.stdout, /eval: \{"checked":true\}/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("an invalid later action fails before earlier actions execute", async () => {
|
|
43
|
+
const result = await run([["eval", "({shouldNotRun:true})"], ["screenshot", "out.png"]]);
|
|
44
|
+
assert.equal(result.code, 1);
|
|
45
|
+
assert.doesNotMatch(result.stdout, /shouldNotRun/);
|
|
46
|
+
});
|