@aayambansal/squint 0.4.6 → 0.4.8
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 -0
- package/dist/App-766KSGON.js +23 -0
- package/dist/{cdp-OYRHOPVP.js → cdp-2VVQOHWM.js} +3 -1
- package/dist/{chunk-O2S6PAJE.js → chunk-7CAGWFAQ.js} +1 -1
- package/dist/{chunk-D7Q3M6PA.js → chunk-7RFQWOQV.js} +27 -10
- package/dist/chunk-ATNTD3V5.js +147 -0
- package/dist/{chunk-XEQ6JXXL.js → chunk-AUJJGMZG.js} +1 -1
- package/dist/{chunk-X2RZJT4L.js → chunk-BGBG2MNY.js} +198 -6
- package/dist/chunk-JZMJGLIZ.js +587 -0
- package/dist/chunk-YGPVP4YB.js +1428 -0
- package/dist/cli.js +260 -2113
- package/dist/{preview-XREFPMHX.js → preview-FBNVDQIV.js} +5 -3
- package/dist/remote-OH2P4S2H.js +114 -0
- package/dist/{sandbox-J2NCPYHJ.js → sandbox-MK7Q2YNO.js} +1 -1
- package/dist/sentinel-42NZRTTS.js +0 -0
- package/dist/shots-OKWOYF7F.js +2 -2
- package/dist/{state-QOS7WCZO.js → state-PLY7YAD2.js} +1 -1
- package/dist/variants-3IEP7DFY.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -174,6 +174,12 @@ squint doctor --probe # run every engine end to end, verify auth act
|
|
|
174
174
|
flagged from the live page, and on Next 16+ the framework's own `/_next/mcp`
|
|
175
175
|
channel feeds structured errors straight into the fix loop. `/context` itemizes
|
|
176
176
|
the injected-context bill per source, with staleness warnings.
|
|
177
|
+
- **The sentinel**: gate evasion (deleted tests, added skips, suppressed
|
|
178
|
+
diagnostics, weakened checks, locked-path touches) detected deterministically per
|
|
179
|
+
turn and reported to you — never sent back to the engine that did it.
|
|
180
|
+
- **Detachable sessions**: `squint serve` owns the session on a unix socket;
|
|
181
|
+
`squint attach` joins from any terminal (observer seats for teammates, driver
|
|
182
|
+
seat inherited on detach). Crash-proof, ssh-friendly, no cloud.
|
|
177
183
|
- **Persistent checks**: assertions the engine verifies once persist as
|
|
178
184
|
`.squint/checks/*.js` and replay against the live page every turn — one-off
|
|
179
185
|
verifications compound into repo-versioned regression checks.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
App
|
|
4
|
+
} from "./chunk-JZMJGLIZ.js";
|
|
5
|
+
import "./chunk-43NQNIJY.js";
|
|
6
|
+
import "./chunk-YGPVP4YB.js";
|
|
7
|
+
import "./chunk-ARDV4XH6.js";
|
|
8
|
+
import "./chunk-AUJJGMZG.js";
|
|
9
|
+
import "./chunk-YHRAOBI2.js";
|
|
10
|
+
import "./chunk-QCHBDP46.js";
|
|
11
|
+
import "./chunk-YGSF2TSO.js";
|
|
12
|
+
import "./chunk-VH7OOFQP.js";
|
|
13
|
+
import "./chunk-K5QJMSJH.js";
|
|
14
|
+
import "./chunk-WAJXATCO.js";
|
|
15
|
+
import "./chunk-62JNF5M2.js";
|
|
16
|
+
import "./chunk-7RFQWOQV.js";
|
|
17
|
+
import "./chunk-7CAGWFAQ.js";
|
|
18
|
+
import "./chunk-BGBG2MNY.js";
|
|
19
|
+
import "./chunk-IMDRXXFU.js";
|
|
20
|
+
import "./chunk-KVYGPLWW.js";
|
|
21
|
+
export {
|
|
22
|
+
App
|
|
23
|
+
};
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
import {
|
|
3
3
|
cdpCapture,
|
|
4
4
|
hasWebSocket,
|
|
5
|
+
pixelDiffAttributed,
|
|
5
6
|
pixelDiffPct,
|
|
6
7
|
runFlow
|
|
7
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-BGBG2MNY.js";
|
|
8
9
|
export {
|
|
9
10
|
cdpCapture,
|
|
10
11
|
hasWebSocket,
|
|
12
|
+
pixelDiffAttributed,
|
|
11
13
|
pixelDiffPct,
|
|
12
14
|
runFlow
|
|
13
15
|
};
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/state/state.ts
|
|
4
4
|
import fs from "fs";
|
|
5
5
|
import path from "path";
|
|
6
|
-
var IGNORED = ["preview/", "state.json", "variants/", "sandbox/", "sandbox.patch", "transcripts/"];
|
|
6
|
+
var IGNORED = ["preview/", "state.json", "variants/", "sandbox/", "sandbox.patch", "transcripts/", "daemon.sock"];
|
|
7
7
|
function ensureSquintIgnore(cwd) {
|
|
8
8
|
const dir = path.join(cwd, ".squint");
|
|
9
9
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
ensureSquintIgnore
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-7CAGWFAQ.js";
|
|
5
|
+
import {
|
|
6
|
+
cdpCapture,
|
|
7
|
+
hasWebSocket
|
|
8
|
+
} from "./chunk-BGBG2MNY.js";
|
|
5
9
|
import {
|
|
6
10
|
findChrome,
|
|
7
11
|
screenshot
|
|
8
12
|
} from "./chunk-IMDRXXFU.js";
|
|
9
|
-
import {
|
|
10
|
-
cdpCapture,
|
|
11
|
-
hasWebSocket
|
|
12
|
-
} from "./chunk-X2RZJT4L.js";
|
|
13
13
|
|
|
14
14
|
// src/preview/preview.ts
|
|
15
15
|
import fs2 from "fs";
|
|
@@ -75,7 +75,7 @@ async function captureViewports(cwd, url) {
|
|
|
75
75
|
if (hasWebSocket()) {
|
|
76
76
|
try {
|
|
77
77
|
const checks = loadChecks(cwd);
|
|
78
|
-
const { report, shots: shots2, a11y, slop, narration, phantoms, viewTransitions, components, checkFailures, webmcp } = await cdpCapture(chrome, url, dir, VIEWPORTS, 2500, true, checks);
|
|
78
|
+
const { report, shots: shots2, a11y, slop, narration, phantoms, viewTransitions, components, checkFailures, webmcp, jank } = await cdpCapture(chrome, url, dir, VIEWPORTS, 2500, true, checks);
|
|
79
79
|
const errors2 = [];
|
|
80
80
|
for (const route of routes.slice(1)) {
|
|
81
81
|
try {
|
|
@@ -90,7 +90,7 @@ async function captureViewports(cwd, url) {
|
|
|
90
90
|
errors2.push(`${route}: capture failed`);
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
-
return { shots: shots2, errors: errors2, runtime: report, a11y, slop, narration, phantoms, viewTransitions, components, checkFailures, webmcp };
|
|
93
|
+
return { shots: shots2, errors: errors2, runtime: report, a11y, slop, narration, phantoms, viewTransitions, components, checkFailures, webmcp, jank };
|
|
94
94
|
} catch {
|
|
95
95
|
}
|
|
96
96
|
}
|
|
@@ -163,9 +163,15 @@ async function probeRuntime(url, cwd) {
|
|
|
163
163
|
async function comparePulse(previous, current) {
|
|
164
164
|
const chrome = findChrome();
|
|
165
165
|
if (!chrome || !hasWebSocket()) return null;
|
|
166
|
-
const { pixelDiffPct } = await import("./cdp-
|
|
166
|
+
const { pixelDiffPct } = await import("./cdp-2VVQOHWM.js");
|
|
167
167
|
return pixelDiffPct(chrome, previous, current);
|
|
168
168
|
}
|
|
169
|
+
async function comparePulseAttributed(previous, current, url) {
|
|
170
|
+
const chrome = findChrome();
|
|
171
|
+
if (!chrome || !hasWebSocket()) return null;
|
|
172
|
+
const { pixelDiffAttributed } = await import("./cdp-2VVQOHWM.js");
|
|
173
|
+
return pixelDiffAttributed(chrome, previous, current, url);
|
|
174
|
+
}
|
|
169
175
|
function buildRuntimeFixPrompt(report) {
|
|
170
176
|
return `The running app has runtime problems.${runtimeSection(report)}
|
|
171
177
|
|
|
@@ -191,6 +197,16 @@ ${narration.join("\n")}
|
|
|
191
197
|
|
|
192
198
|
Judge this narration as an experience: does the reading order make sense? do names actually describe their targets? is anything announced as "(no accessible name)"? Fix real incoherence \u2014 this is how non-visual users meet the page.`;
|
|
193
199
|
}
|
|
200
|
+
function jankSection(jank) {
|
|
201
|
+
if (!jank || jank.length === 0) return "";
|
|
202
|
+
return `
|
|
203
|
+
|
|
204
|
+
## Jank attribution (main-thread frames \u226550ms)
|
|
205
|
+
|
|
206
|
+
${jank.join("\n")}
|
|
207
|
+
|
|
208
|
+
Each line names the function behind a long animation frame observed during load and a scripted scroll. Fix the work (memoize, virtualize, move off the main thread) \u2014 do not remove the animation.`;
|
|
209
|
+
}
|
|
194
210
|
function webmcpSection(webmcp) {
|
|
195
211
|
if (!webmcp || webmcp.length === 0) return "";
|
|
196
212
|
return `
|
|
@@ -241,13 +257,13 @@ ${findings.join("\n")}
|
|
|
241
257
|
|
|
242
258
|
These patterns make the page read as template output. Rework them within the committed design direction \u2014 this is style debt, not a defect list.`;
|
|
243
259
|
}
|
|
244
|
-
function buildReviewPrompt(shots, extra, runtime, a11y, slop, narration, phantoms, viewTransitions, components, webmcp) {
|
|
260
|
+
function buildReviewPrompt(shots, extra, runtime, a11y, slop, narration, phantoms, viewTransitions, components, webmcp, jank) {
|
|
245
261
|
const list = shots.map((s) => `- ${s.name}: ${s.path}`).join("\n");
|
|
246
262
|
return `Screenshots of the running app were just captured:
|
|
247
263
|
|
|
248
264
|
${list}
|
|
249
265
|
|
|
250
|
-
Read each screenshot and review the rendered UI against the design standards you were given. Check: visual hierarchy and spacing rhythm, typography, color and contrast, alignment, empty-looking or broken regions, and whether the mobile capture shows horizontal overflow or cramped layout. List the concrete issues you can SEE (not hypothetical ones), ranked by visual impact${extra ? `, with special attention to: ${extra}` : ""}. Then fix them and verify the app still builds.${runtimeSection(runtime)}${a11ySection(a11y)}${slopSection(slop)}${narrationSection(narration)}${phantomSection(phantoms)}${vtSection(viewTransitions)}${componentSection(components)}${webmcpSection(webmcp)}`;
|
|
266
|
+
Read each screenshot and review the rendered UI against the design standards you were given. Check: visual hierarchy and spacing rhythm, typography, color and contrast, alignment, empty-looking or broken regions, and whether the mobile capture shows horizontal overflow or cramped layout. List the concrete issues you can SEE (not hypothetical ones), ranked by visual impact${extra ? `, with special attention to: ${extra}` : ""}. Then fix them and verify the app still builds.${runtimeSection(runtime)}${a11ySection(a11y)}${slopSection(slop)}${narrationSection(narration)}${phantomSection(phantoms)}${vtSection(viewTransitions)}${componentSection(components)}${webmcpSection(webmcp)}${jankSection(jank)}`;
|
|
251
267
|
}
|
|
252
268
|
|
|
253
269
|
export {
|
|
@@ -259,6 +275,7 @@ export {
|
|
|
259
275
|
runtimeSummary,
|
|
260
276
|
probeRuntime,
|
|
261
277
|
comparePulse,
|
|
278
|
+
comparePulseAttributed,
|
|
262
279
|
buildRuntimeFixPrompt,
|
|
263
280
|
buildReviewPrompt
|
|
264
281
|
};
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
Session
|
|
4
|
+
} from "./chunk-YGPVP4YB.js";
|
|
5
|
+
|
|
6
|
+
// src/daemon/client.ts
|
|
7
|
+
import net from "net";
|
|
8
|
+
function connectDaemon(sock, timeoutMs = 3e3) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const socket = net.createConnection(sock);
|
|
11
|
+
const timer = setTimeout(() => {
|
|
12
|
+
socket.destroy();
|
|
13
|
+
reject(new Error(`no daemon listening at ${sock}`));
|
|
14
|
+
}, timeoutMs);
|
|
15
|
+
const handlers = [];
|
|
16
|
+
let buffer = "";
|
|
17
|
+
socket.on("connect", () => {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
resolve({
|
|
20
|
+
send: (msg) => socket.write(`${JSON.stringify(msg)}
|
|
21
|
+
`),
|
|
22
|
+
onMessage: (handler) => handlers.push(handler),
|
|
23
|
+
close: () => socket.destroy()
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
socket.on("error", (error) => {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
reject(error);
|
|
29
|
+
});
|
|
30
|
+
socket.on("data", (chunk) => {
|
|
31
|
+
buffer += chunk.toString();
|
|
32
|
+
let idx;
|
|
33
|
+
while ((idx = buffer.indexOf("\n")) >= 0) {
|
|
34
|
+
const line = buffer.slice(0, idx);
|
|
35
|
+
buffer = buffer.slice(idx + 1);
|
|
36
|
+
if (!line.trim()) continue;
|
|
37
|
+
try {
|
|
38
|
+
const msg = JSON.parse(line);
|
|
39
|
+
for (const handler of handlers) handler(msg);
|
|
40
|
+
} catch {
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/daemon/server.ts
|
|
48
|
+
import crypto from "crypto";
|
|
49
|
+
import fs from "fs";
|
|
50
|
+
import net2 from "net";
|
|
51
|
+
import os from "os";
|
|
52
|
+
import path from "path";
|
|
53
|
+
var MAX_ITEMS_SENT = 200;
|
|
54
|
+
function socketPath(cwd) {
|
|
55
|
+
const direct = path.join(cwd, ".squint", "daemon.sock");
|
|
56
|
+
if (Buffer.byteLength(direct) <= 96) return direct;
|
|
57
|
+
const hash = crypto.createHash("sha256").update(cwd).digest("hex").slice(0, 12);
|
|
58
|
+
return path.join(os.tmpdir(), `squint-${hash}.sock`);
|
|
59
|
+
}
|
|
60
|
+
function startDaemon(opts) {
|
|
61
|
+
const session = new Session(opts);
|
|
62
|
+
const sock = socketPath(opts.cwd);
|
|
63
|
+
fs.mkdirSync(path.dirname(sock), { recursive: true });
|
|
64
|
+
fs.rmSync(sock, { force: true });
|
|
65
|
+
const clients = [];
|
|
66
|
+
const driver = () => clients[0];
|
|
67
|
+
const serialize = () => {
|
|
68
|
+
const state = session.getState();
|
|
69
|
+
return `${JSON.stringify({ type: "state", state: { ...state, items: state.items.slice(-MAX_ITEMS_SENT) } })}
|
|
70
|
+
`;
|
|
71
|
+
};
|
|
72
|
+
const server = net2.createServer((socket) => {
|
|
73
|
+
clients.push(socket);
|
|
74
|
+
const role = socket === driver() ? "driver" : "observer";
|
|
75
|
+
socket.write(`${JSON.stringify({ type: "hello", role, engineId: session.getState().engineId })}
|
|
76
|
+
`);
|
|
77
|
+
socket.write(serialize());
|
|
78
|
+
let buffer = "";
|
|
79
|
+
socket.on("data", (chunk) => {
|
|
80
|
+
buffer += chunk.toString();
|
|
81
|
+
let idx;
|
|
82
|
+
while ((idx = buffer.indexOf("\n")) >= 0) {
|
|
83
|
+
const line = buffer.slice(0, idx);
|
|
84
|
+
buffer = buffer.slice(idx + 1);
|
|
85
|
+
if (!line.trim()) continue;
|
|
86
|
+
let msg;
|
|
87
|
+
try {
|
|
88
|
+
msg = JSON.parse(line);
|
|
89
|
+
} catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (msg.type === "ping") {
|
|
93
|
+
socket.write(`${JSON.stringify({ type: "ping" })}
|
|
94
|
+
`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (socket !== driver()) {
|
|
98
|
+
socket.write(`${JSON.stringify({ type: "denied", reason: "observer \u2014 the driver steers this session" })}
|
|
99
|
+
`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (msg.type === "input" && typeof msg.text === "string") session.input(msg.text);
|
|
103
|
+
else if (msg.type === "command" && typeof msg.text === "string") session.command(msg.text);
|
|
104
|
+
else if (msg.type === "interrupt") session.interrupt();
|
|
105
|
+
else if (msg.type === "cycleMode") session.cycleMode();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
const drop = () => {
|
|
109
|
+
const index = clients.indexOf(socket);
|
|
110
|
+
if (index >= 0) clients.splice(index, 1);
|
|
111
|
+
const next = driver();
|
|
112
|
+
if (index === 0 && next && !next.destroyed) {
|
|
113
|
+
next.write(`${JSON.stringify({ type: "hello", role: "driver", engineId: session.getState().engineId })}
|
|
114
|
+
`);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
socket.on("close", drop);
|
|
118
|
+
socket.on("error", drop);
|
|
119
|
+
});
|
|
120
|
+
const unsubscribe = session.subscribe(() => {
|
|
121
|
+
const payload = serialize();
|
|
122
|
+
for (const client of clients) {
|
|
123
|
+
if (!client.destroyed) client.write(payload);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
server.once("error", reject);
|
|
128
|
+
server.listen(sock, () => {
|
|
129
|
+
resolve({
|
|
130
|
+
session,
|
|
131
|
+
clientCount: () => clients.length,
|
|
132
|
+
close: () => {
|
|
133
|
+
unsubscribe();
|
|
134
|
+
for (const client of clients) client.destroy();
|
|
135
|
+
server.close();
|
|
136
|
+
fs.rmSync(sock, { force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export {
|
|
144
|
+
connectDaemon,
|
|
145
|
+
socketPath,
|
|
146
|
+
startDaemon
|
|
147
|
+
};
|
|
@@ -15,7 +15,7 @@ function sandboxExists(cwd) {
|
|
|
15
15
|
}
|
|
16
16
|
function openSandbox(cwd) {
|
|
17
17
|
const dir = sandboxDir(cwd);
|
|
18
|
-
void import("./state-
|
|
18
|
+
void import("./state-PLY7YAD2.js").then(({ ensureSquintIgnore }) => ensureSquintIgnore(cwd)).catch(() => {
|
|
19
19
|
});
|
|
20
20
|
if (sandboxExists(cwd)) return { dir, reused: true };
|
|
21
21
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -189,6 +189,7 @@ async function runFlow(chromePath, baseUrl, flow, outDir) {
|
|
|
189
189
|
const { sessionId } = await connection.send("Target.attachToTarget", { targetId, flatten: true });
|
|
190
190
|
await connection.send("Page.enable", {}, sessionId);
|
|
191
191
|
await connection.send("Page.addScriptToEvaluateOnNewDocument", { source: WEBMCP_SHIM }, sessionId).catch(() => null);
|
|
192
|
+
await connection.send("Page.addScriptToEvaluateOnNewDocument", { source: LOAF_SHIM }, sessionId).catch(() => null);
|
|
192
193
|
await connection.send(
|
|
193
194
|
"Emulation.setDeviceMetricsOverride",
|
|
194
195
|
{ width: 1280, height: 800, deviceScaleFactor: 1, mobile: false },
|
|
@@ -237,6 +238,145 @@ async function runFlow(chromePath, baseUrl, flow, outDir) {
|
|
|
237
238
|
setTimeout(() => fs.rmSync(profileDir, { recursive: true, force: true }), 500).unref?.();
|
|
238
239
|
}
|
|
239
240
|
}
|
|
241
|
+
async function pixelDiffAttributed(chromePath, pngA, pngB, url) {
|
|
242
|
+
const { child, wsUrl, profileDir } = await launchChrome(chromePath);
|
|
243
|
+
let connection = null;
|
|
244
|
+
try {
|
|
245
|
+
connection = await CdpConnection.connect(wsUrl, 1e4);
|
|
246
|
+
const { targetId } = await connection.send("Target.createTarget", { url: "about:blank" });
|
|
247
|
+
const { sessionId } = await connection.send("Target.attachToTarget", { targetId, flatten: true });
|
|
248
|
+
await connection.send("Runtime.enable", {}, sessionId);
|
|
249
|
+
const expression = `(async () => {
|
|
250
|
+
const load = (src) => new Promise((resolve, reject) => {
|
|
251
|
+
const img = new Image();
|
|
252
|
+
img.onload = () => resolve(img);
|
|
253
|
+
img.onerror = () => reject(new Error('decode'));
|
|
254
|
+
img.src = src;
|
|
255
|
+
});
|
|
256
|
+
const a = await load('data:image/png;base64,${pngA.toString("base64")}');
|
|
257
|
+
const b = await load('data:image/png;base64,${pngB.toString("base64")}');
|
|
258
|
+
const w = Math.min(a.width, b.width), h = Math.min(a.height, b.height);
|
|
259
|
+
if (w === 0 || h === 0) return null;
|
|
260
|
+
const draw = (img) => {
|
|
261
|
+
const c = document.createElement('canvas');
|
|
262
|
+
c.width = w; c.height = h;
|
|
263
|
+
const ctx = c.getContext('2d');
|
|
264
|
+
ctx.drawImage(img, 0, 0);
|
|
265
|
+
return ctx.getImageData(0, 0, w, h).data;
|
|
266
|
+
};
|
|
267
|
+
const da = draw(a), db = draw(b);
|
|
268
|
+
const CELL = 32;
|
|
269
|
+
const gw = Math.ceil(w / CELL), gh = Math.ceil(h / CELL);
|
|
270
|
+
const marked = new Uint8Array(gw * gh);
|
|
271
|
+
let differ = 0, total = 0;
|
|
272
|
+
for (let y = 0; y < h; y += 2) {
|
|
273
|
+
for (let x = 0; x < w; x += 2) {
|
|
274
|
+
total++;
|
|
275
|
+
const i = (y * w + x) * 4;
|
|
276
|
+
if (Math.abs(da[i] - db[i]) > 8 || Math.abs(da[i + 1] - db[i + 1]) > 8 || Math.abs(da[i + 2] - db[i + 2]) > 8) {
|
|
277
|
+
differ++;
|
|
278
|
+
marked[Math.floor(y / CELL) * gw + Math.floor(x / CELL)] = 1;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Flood-fill marked cells into region bounding boxes.
|
|
283
|
+
const seen = new Uint8Array(gw * gh);
|
|
284
|
+
const regions = [];
|
|
285
|
+
for (let cy = 0; cy < gh; cy++) {
|
|
286
|
+
for (let cx = 0; cx < gw; cx++) {
|
|
287
|
+
const start = cy * gw + cx;
|
|
288
|
+
if (!marked[start] || seen[start]) continue;
|
|
289
|
+
let minX = cx, maxX = cx, minY = cy, maxY = cy, cells = 0;
|
|
290
|
+
const stack = [start];
|
|
291
|
+
seen[start] = 1;
|
|
292
|
+
while (stack.length) {
|
|
293
|
+
const cell = stack.pop();
|
|
294
|
+
const x = cell % gw, y = Math.floor(cell / gw);
|
|
295
|
+
cells++;
|
|
296
|
+
if (x < minX) minX = x; if (x > maxX) maxX = x;
|
|
297
|
+
if (y < minY) minY = y; if (y > maxY) maxY = y;
|
|
298
|
+
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
|
299
|
+
const nx = x + dx, ny = y + dy;
|
|
300
|
+
if (nx < 0 || ny < 0 || nx >= gw || ny >= gh) continue;
|
|
301
|
+
const n = ny * gw + nx;
|
|
302
|
+
if (marked[n] && !seen[n]) { seen[n] = 1; stack.push(n); }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
regions.push({
|
|
306
|
+
x: minX * CELL, y: minY * CELL,
|
|
307
|
+
w: Math.min(w, (maxX + 1) * CELL) - minX * CELL,
|
|
308
|
+
h: Math.min(h, (maxY + 1) * CELL) - minY * CELL,
|
|
309
|
+
cells,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
regions.sort((p, q) => q.cells - p.cells);
|
|
314
|
+
const sizePenalty = (a.width !== b.width || a.height !== b.height) ? 1 : 0;
|
|
315
|
+
return { pct: Math.min(100, (differ / total) * 100 + sizePenalty), regions: regions.slice(0, 5), w, h };
|
|
316
|
+
})()`;
|
|
317
|
+
const { result } = await connection.send(
|
|
318
|
+
"Runtime.evaluate",
|
|
319
|
+
{ expression, awaitPromise: true, returnByValue: true },
|
|
320
|
+
sessionId
|
|
321
|
+
);
|
|
322
|
+
const value = result?.value;
|
|
323
|
+
if (!value || typeof value.pct !== "number") return null;
|
|
324
|
+
if (!url || value.regions.length === 0 || value.pct < 0.5) return { pct: value.pct, sentences: [] };
|
|
325
|
+
let sentences = [];
|
|
326
|
+
try {
|
|
327
|
+
const page = await connection.send("Target.createTarget", { url: "about:blank" });
|
|
328
|
+
const attach = await connection.send("Target.attachToTarget", { targetId: page.targetId, flatten: true });
|
|
329
|
+
const pageSession = attach.sessionId;
|
|
330
|
+
await connection.send("Runtime.enable", {}, pageSession);
|
|
331
|
+
await connection.send("Page.enable", {}, pageSession);
|
|
332
|
+
await connection.send(
|
|
333
|
+
"Emulation.setDeviceMetricsOverride",
|
|
334
|
+
{ width: value.w, height: value.h, deviceScaleFactor: 1, mobile: false },
|
|
335
|
+
pageSession
|
|
336
|
+
);
|
|
337
|
+
await connection.send("Page.navigate", { url }, pageSession);
|
|
338
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
339
|
+
const hitTest = `((regions) => regions.map((r) => {
|
|
340
|
+
const el = document.elementsFromPoint(r.x + r.w / 2, r.y + r.h / 2)
|
|
341
|
+
.find((e) => e !== document.documentElement && e !== document.body);
|
|
342
|
+
const where = r.w + '\xD7' + r.h + ' region @ (' + r.x + ',' + r.y + ')';
|
|
343
|
+
if (!el) return where + ' changed';
|
|
344
|
+
let label = el.tagName.toLowerCase();
|
|
345
|
+
if (el.id) label += '#' + el.id;
|
|
346
|
+
else if (el.classList[0]) label += '.' + el.classList[0];
|
|
347
|
+
const key = Object.keys(el).find((k) => k.startsWith('__reactFiber$'));
|
|
348
|
+
let chain = '';
|
|
349
|
+
if (key) {
|
|
350
|
+
let fiber = el[key];
|
|
351
|
+
const names = [];
|
|
352
|
+
let hops = 0;
|
|
353
|
+
while (fiber && hops < 50 && names.length < 2) {
|
|
354
|
+
const t = fiber.type;
|
|
355
|
+
const n = typeof t === 'function' ? (t.displayName || t.name || '') : '';
|
|
356
|
+
if (n && !names.includes(n)) names.push(n);
|
|
357
|
+
fiber = fiber.return; hops++;
|
|
358
|
+
}
|
|
359
|
+
chain = names.join(' < ');
|
|
360
|
+
}
|
|
361
|
+
return '<' + label + '>' + (chain ? ' (' + chain + ')' : '') + ': ' + where + ' changed';
|
|
362
|
+
}))(${JSON.stringify(value.regions)})`;
|
|
363
|
+
const hit = await connection.send(
|
|
364
|
+
"Runtime.evaluate",
|
|
365
|
+
{ expression: hitTest, returnByValue: true },
|
|
366
|
+
pageSession
|
|
367
|
+
);
|
|
368
|
+
if (Array.isArray(hit.result?.value)) sentences = hit.result.value.map(String);
|
|
369
|
+
} catch {
|
|
370
|
+
}
|
|
371
|
+
return { pct: value.pct, sentences };
|
|
372
|
+
} catch {
|
|
373
|
+
return null;
|
|
374
|
+
} finally {
|
|
375
|
+
connection?.close();
|
|
376
|
+
child.kill("SIGKILL");
|
|
377
|
+
setTimeout(() => fs.rmSync(profileDir, { recursive: true, force: true }), 500).unref?.();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
240
380
|
async function pixelDiffPct(chromePath, pngA, pngB) {
|
|
241
381
|
const { child, wsUrl, profileDir } = await launchChrome(chromePath);
|
|
242
382
|
let connection = null;
|
|
@@ -445,11 +585,51 @@ var WEBMCP_SHIM = `(() => {
|
|
|
445
585
|
if (t && t.name) window.__squintWebMcp.push(t.name + (t.description ? ' \u2014 ' + t.description : ''));
|
|
446
586
|
}
|
|
447
587
|
};
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
588
|
+
const wrap = (target) => {
|
|
589
|
+
const provide = target.provideContext && target.provideContext.bind(target);
|
|
590
|
+
target.provideContext = (params) => { record(params && params.tools); return provide ? provide(params) : undefined; };
|
|
591
|
+
const register = target.registerTool && target.registerTool.bind(target);
|
|
592
|
+
target.registerTool = (tool) => { record([tool]); return register ? register(tool) : undefined; };
|
|
593
|
+
return target;
|
|
594
|
+
};
|
|
595
|
+
// The spec moved the API to document.modelContext (Chrome 150 drops
|
|
596
|
+
// the navigator location); shim both so either registration is seen.
|
|
597
|
+
wrap(document.modelContext || (document.modelContext = {}));
|
|
598
|
+
wrap(navigator.modelContext || (navigator.modelContext = {}));
|
|
599
|
+
})()`;
|
|
600
|
+
var LOAF_SHIM = `(() => {
|
|
601
|
+
window.__squintLoaf = [];
|
|
602
|
+
try {
|
|
603
|
+
new PerformanceObserver((list) => {
|
|
604
|
+
for (const e of list.getEntries()) {
|
|
605
|
+
if (e.duration < 50) continue;
|
|
606
|
+
const s = (e.scripts && e.scripts[0]) || {};
|
|
607
|
+
window.__squintLoaf.push({
|
|
608
|
+
duration: Math.round(e.duration),
|
|
609
|
+
fn: s.sourceFunctionName || '',
|
|
610
|
+
url: (s.sourceURL || s.name || '').split('/').pop() || '',
|
|
611
|
+
invoker: s.invoker || '',
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}).observe({ type: 'long-animation-frame', buffered: true });
|
|
615
|
+
} catch {}
|
|
616
|
+
})()`;
|
|
617
|
+
var SCRIPTED_SCROLL = `(async () => {
|
|
618
|
+
const half = Math.max(0, (document.body.scrollHeight - innerHeight) / 2);
|
|
619
|
+
window.scrollTo({ top: half, behavior: 'smooth' });
|
|
620
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
621
|
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
622
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
623
|
+
const seen = new Map();
|
|
624
|
+
for (const e of window.__squintLoaf || []) {
|
|
625
|
+
const key = e.fn + '@' + e.url + '@' + e.invoker;
|
|
626
|
+
const prev = seen.get(key);
|
|
627
|
+
if (!prev || e.duration > prev.duration) seen.set(key, e);
|
|
628
|
+
}
|
|
629
|
+
return [...seen.values()]
|
|
630
|
+
.sort((a, b) => b.duration - a.duration)
|
|
631
|
+
.slice(0, 6)
|
|
632
|
+
.map((e) => e.duration + 'ms frame \u2014 ' + (e.fn || e.invoker || 'script') + (e.url ? ' @ ' + e.url : ''));
|
|
453
633
|
})()`;
|
|
454
634
|
async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, audit = false, checks = []) {
|
|
455
635
|
const { child, wsUrl, profileDir } = await launchChrome(chromePath);
|
|
@@ -464,6 +644,7 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
|
|
|
464
644
|
let components = [];
|
|
465
645
|
const checkFailures = [];
|
|
466
646
|
let webmcp = [];
|
|
647
|
+
let jank = [];
|
|
467
648
|
const requests = /* @__PURE__ */ new Map();
|
|
468
649
|
let connection = null;
|
|
469
650
|
try {
|
|
@@ -503,6 +684,7 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
|
|
|
503
684
|
await connection.send("Network.enable", {}, sessionId);
|
|
504
685
|
await connection.send("Page.enable", {}, sessionId);
|
|
505
686
|
await connection.send("Page.addScriptToEvaluateOnNewDocument", { source: WEBMCP_SHIM }, sessionId).catch(() => null);
|
|
687
|
+
await connection.send("Page.addScriptToEvaluateOnNewDocument", { source: LOAF_SHIM }, sessionId).catch(() => null);
|
|
506
688
|
await connection.send("Page.navigate", { url }, sessionId);
|
|
507
689
|
const deadline = Date.now() + 12e3;
|
|
508
690
|
while (!loaded && Date.now() < deadline) {
|
|
@@ -518,6 +700,15 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
|
|
|
518
700
|
if (result?.value && typeof result.value === "object") perf = result.value;
|
|
519
701
|
} catch {
|
|
520
702
|
}
|
|
703
|
+
try {
|
|
704
|
+
const { result } = await connection.send(
|
|
705
|
+
"Runtime.evaluate",
|
|
706
|
+
{ expression: SCRIPTED_SCROLL, returnByValue: true, awaitPromise: true },
|
|
707
|
+
sessionId
|
|
708
|
+
);
|
|
709
|
+
if (Array.isArray(result?.value)) jank = result.value.map(String);
|
|
710
|
+
} catch {
|
|
711
|
+
}
|
|
521
712
|
try {
|
|
522
713
|
const { result } = await connection.send(
|
|
523
714
|
"Runtime.evaluate",
|
|
@@ -653,12 +844,13 @@ async function cdpCapture(chromePath, url, outDir, viewports, settleMs = 2500, a
|
|
|
653
844
|
child.kill("SIGKILL");
|
|
654
845
|
setTimeout(() => fs.rmSync(profileDir, { recursive: true, force: true }), 500).unref?.();
|
|
655
846
|
}
|
|
656
|
-
return { report, shots, a11y, slop, perf, narration, phantoms, viewTransitions, components, checkFailures, webmcp };
|
|
847
|
+
return { report, shots, a11y, slop, perf, narration, phantoms, viewTransitions, components, checkFailures, webmcp, jank };
|
|
657
848
|
}
|
|
658
849
|
|
|
659
850
|
export {
|
|
660
851
|
hasWebSocket,
|
|
661
852
|
runFlow,
|
|
853
|
+
pixelDiffAttributed,
|
|
662
854
|
pixelDiffPct,
|
|
663
855
|
cdpCapture
|
|
664
856
|
};
|