framewatch-mcp-server 0.1.1 → 0.2.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 +797 -8
- package/dist/constants.d.ts +264 -0
- package/dist/constants.js +268 -0
- package/dist/constants.js.map +1 -1
- package/dist/engine/browser.js +4 -0
- package/dist/engine/browser.js.map +1 -1
- package/dist/engine/clicks.d.ts +221 -0
- package/dist/engine/clicks.js +801 -0
- package/dist/engine/clicks.js.map +1 -0
- package/dist/engine/forms.d.ts +137 -0
- package/dist/engine/forms.js +474 -0
- package/dist/engine/forms.js.map +1 -0
- package/dist/engine/hmr.d.ts +41 -0
- package/dist/engine/hmr.js +91 -0
- package/dist/engine/hmr.js.map +1 -0
- package/dist/engine/inspect.d.ts +31 -0
- package/dist/engine/inspect.js +383 -0
- package/dist/engine/inspect.js.map +1 -0
- package/dist/engine/interaction.d.ts +6 -1
- package/dist/engine/interaction.js +21 -6
- package/dist/engine/interaction.js.map +1 -1
- package/dist/engine/links.d.ts +134 -0
- package/dist/engine/links.js +384 -0
- package/dist/engine/links.js.map +1 -0
- package/dist/engine/mocks.d.ts +53 -0
- package/dist/engine/mocks.js +148 -0
- package/dist/engine/mocks.js.map +1 -0
- package/dist/engine/rtl.d.ts +129 -0
- package/dist/engine/rtl.js +540 -0
- package/dist/engine/rtl.js.map +1 -0
- package/dist/engine/seo.d.ts +189 -0
- package/dist/engine/seo.js +398 -0
- package/dist/engine/seo.js.map +1 -0
- package/dist/engine/snapshot.d.ts +29 -0
- package/dist/engine/snapshot.js +10 -0
- package/dist/engine/snapshot.js.map +1 -0
- package/dist/engine/vue.d.ts +54 -0
- package/dist/engine/vue.js +419 -0
- package/dist/engine/vue.js.map +1 -0
- package/dist/index.js +42 -1
- package/dist/index.js.map +1 -1
- package/dist/tools/accessibility.js +4 -3
- package/dist/tools/accessibility.js.map +1 -1
- package/dist/tools/api-mock.d.ts +405 -0
- package/dist/tools/api-mock.js +186 -0
- package/dist/tools/api-mock.js.map +1 -0
- package/dist/tools/capture.d.ts +83 -23
- package/dist/tools/capture.js +105 -60
- package/dist/tools/capture.js.map +1 -1
- package/dist/tools/compare.js +4 -3
- package/dist/tools/compare.js.map +1 -1
- package/dist/tools/dead-clicks.d.ts +128 -0
- package/dist/tools/dead-clicks.js +570 -0
- package/dist/tools/dead-clicks.js.map +1 -0
- package/dist/tools/form-test.d.ts +112 -0
- package/dist/tools/form-test.js +477 -0
- package/dist/tools/form-test.js.map +1 -0
- package/dist/tools/index.d.ts +16 -1
- package/dist/tools/index.js +42 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inspect.d.ts +78 -0
- package/dist/tools/inspect.js +136 -0
- package/dist/tools/inspect.js.map +1 -0
- package/dist/tools/interact.d.ts +29 -16
- package/dist/tools/interact.js +99 -13
- package/dist/tools/interact.js.map +1 -1
- package/dist/tools/links.d.ts +129 -0
- package/dist/tools/links.js +640 -0
- package/dist/tools/links.js.map +1 -0
- package/dist/tools/responsive.d.ts +6 -6
- package/dist/tools/responsive.js +9 -9
- package/dist/tools/responsive.js.map +1 -1
- package/dist/tools/rtl.d.ts +241 -0
- package/dist/tools/rtl.js +410 -0
- package/dist/tools/rtl.js.map +1 -0
- package/dist/tools/save-auth.d.ts +22 -22
- package/dist/tools/screenshot.js +13 -5
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/seo.d.ts +113 -0
- package/dist/tools/seo.js +281 -0
- package/dist/tools/seo.js.map +1 -0
- package/dist/tools/snapshot.d.ts +122 -0
- package/dist/tools/snapshot.js +183 -0
- package/dist/tools/snapshot.js.map +1 -0
- package/dist/tools/wait-for.d.ts +107 -0
- package/dist/tools/wait-for.js +167 -0
- package/dist/tools/wait-for.js.map +1 -0
- package/dist/utils/arabic-text.d.ts +14 -0
- package/dist/utils/arabic-text.js +193 -0
- package/dist/utils/arabic-text.js.map +1 -0
- package/dist/utils/budget.d.ts +41 -0
- package/dist/utils/budget.js +182 -0
- package/dist/utils/budget.js.map +1 -0
- package/dist/utils/format.d.ts +11 -1
- package/dist/utils/format.js +27 -4
- package/dist/utils/format.js.map +1 -1
- package/dist/utils/highlight.d.ts +69 -0
- package/dist/utils/highlight.js +181 -0
- package/dist/utils/highlight.js.map +1 -0
- package/dist/utils/link-rules.d.ts +100 -0
- package/dist/utils/link-rules.js +284 -0
- package/dist/utils/link-rules.js.map +1 -0
- package/dist/utils/mock-rules.d.ts +144 -0
- package/dist/utils/mock-rules.js +224 -0
- package/dist/utils/mock-rules.js.map +1 -0
- package/dist/utils/rtl-rules.d.ts +142 -0
- package/dist/utils/rtl-rules.js +296 -0
- package/dist/utils/rtl-rules.js.map +1 -0
- package/dist/utils/seo-rules.d.ts +129 -0
- package/dist/utils/seo-rules.js +726 -0
- package/dist/utils/seo-rules.js.map +1 -0
- package/dist/utils/snapshot-rules.d.ts +33 -0
- package/dist/utils/snapshot-rules.js +111 -0
- package/dist/utils/snapshot-rules.js.map +1 -0
- package/dist/utils/storage-state.d.ts +42 -1
- package/dist/utils/storage-state.js +115 -4
- package/dist/utils/storage-state.js.map +1 -1
- package/dist/utils/style-rules.d.ts +107 -0
- package/dist/utils/style-rules.js +223 -0
- package/dist/utils/style-rules.js.map +1 -0
- package/dist/utils/test-data.d.ts +75 -0
- package/dist/utils/test-data.js +294 -0
- package/dist/utils/test-data.js.map +1 -0
- package/dist/utils/vue-rules.d.ts +72 -0
- package/dist/utils/vue-rules.js +108 -0
- package/dist/utils/vue-rules.js.map +1 -0
- package/package.json +6 -4
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
import { BUDGET_CHARS_PER_TOKEN, BUDGET_JPEG_QUALITY, BUDGET_LAST_RESORT_WIDTHS, BUDGET_MARGIN_TOKENS, BUDGET_SUGGESTED_TOKENS, BUDGET_WIDTHS, DEFAULT_MCP_OUTPUT_TOKENS, } from "../constants.js";
|
|
3
|
+
const META_KEY = "framewatch";
|
|
4
|
+
/** Stamp an image block with its role, for `fitToBudget` to read (and strip). */
|
|
5
|
+
export function markImage(block, mark) {
|
|
6
|
+
return { ...block, _meta: { ...(block._meta ?? {}), [META_KEY]: mark } };
|
|
7
|
+
}
|
|
8
|
+
/** The cap in force: what the shell that started Claude Code exported, or the default. */
|
|
9
|
+
export function capFromEnv(env = process.env) {
|
|
10
|
+
const raw = Number(env.MAX_MCP_OUTPUT_TOKENS);
|
|
11
|
+
return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MCP_OUTPUT_TOKENS;
|
|
12
|
+
}
|
|
13
|
+
/** Base64 characters of image data a result may carry once its text is paid for. */
|
|
14
|
+
export function imageBudgetChars(capTokens, textChars) {
|
|
15
|
+
const textTokens = Math.ceil(textChars / BUDGET_CHARS_PER_TOKEN);
|
|
16
|
+
return Math.max(0, (capTokens - textTokens - BUDGET_MARGIN_TOKENS) * BUDGET_CHARS_PER_TOKEN);
|
|
17
|
+
}
|
|
18
|
+
export function describeBudget(stats) {
|
|
19
|
+
const cuts = [];
|
|
20
|
+
if (stats.crops_dropped > 0)
|
|
21
|
+
cuts.push(`${stats.crops_dropped} crop${stats.crops_dropped === 1 ? "" : "s"} dropped`);
|
|
22
|
+
if (stats.frames_dropped > 0)
|
|
23
|
+
cuts.push(`${stats.frames_dropped} frame${stats.frames_dropped === 1 ? "" : "s"} dropped`);
|
|
24
|
+
if (stats.width !== undefined)
|
|
25
|
+
cuts.push(`frames at ${stats.width}px`);
|
|
26
|
+
const kb = Math.round((imageBudgetChars(stats.cap_tokens, 0) * 0.75) / 1024);
|
|
27
|
+
return (`Image budget: ${stats.kept} of ${stats.total} images kept — ${cuts.join(", ")} — to fit MAX_MCP_OUTPUT_TOKENS=${stats.cap_tokens} ` +
|
|
28
|
+
`(~${kb} KB of images per result). Set MAX_MCP_OUTPUT_TOKENS=${BUDGET_SUGGESTED_TOKENS} in the shell that starts Claude Code for full results.`);
|
|
29
|
+
}
|
|
30
|
+
const OMITTED = "(image omitted — see the Image budget line)";
|
|
31
|
+
export async function fitToBudget(result, options = {}) {
|
|
32
|
+
const content = result.content;
|
|
33
|
+
const slots = [];
|
|
34
|
+
for (let i = 0; i < content.length; i++) {
|
|
35
|
+
const block = content[i];
|
|
36
|
+
if (block.type !== "image")
|
|
37
|
+
continue;
|
|
38
|
+
const mark = ((block._meta ?? {})[META_KEY] ?? {});
|
|
39
|
+
const source = Buffer.from(block.data, "base64");
|
|
40
|
+
let width = 0;
|
|
41
|
+
try {
|
|
42
|
+
width = (await sharp(source).metadata()).width ?? 0;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Not an image sharp can read: leave the block exactly as it is.
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
slots.push({
|
|
49
|
+
index: i,
|
|
50
|
+
original: { data: block.data, mimeType: block.mimeType },
|
|
51
|
+
role: mark.role === "crop" ? "crop" : "frame",
|
|
52
|
+
keep: mark.keep === true,
|
|
53
|
+
source,
|
|
54
|
+
width,
|
|
55
|
+
encoded: new Map(),
|
|
56
|
+
dropped: false,
|
|
57
|
+
at: width,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (slots.length === 0)
|
|
61
|
+
return result;
|
|
62
|
+
const cap = options.cap_tokens ?? capFromEnv();
|
|
63
|
+
const textChars = content.reduce((n, c) => (c.type === "text" ? n + c.text.length : n), 0);
|
|
64
|
+
// Leave room for the note this may have to add.
|
|
65
|
+
const budget = imageBudgetChars(cap, textChars + 400);
|
|
66
|
+
const encode = async (slot, width) => {
|
|
67
|
+
const target = Math.min(width, slot.width);
|
|
68
|
+
const cached = slot.encoded.get(target);
|
|
69
|
+
if (cached)
|
|
70
|
+
return cached;
|
|
71
|
+
let image = sharp(slot.source);
|
|
72
|
+
if (target < slot.width)
|
|
73
|
+
image = image.resize(target, null, { fit: "inside", withoutEnlargement: true });
|
|
74
|
+
const [png, jpeg] = await Promise.all([
|
|
75
|
+
image.clone().png({ quality: 80, effort: 1, compressionLevel: 6 }).toBuffer(),
|
|
76
|
+
image.clone().jpeg({ quality: BUDGET_JPEG_QUALITY, mozjpeg: true }).toBuffer(),
|
|
77
|
+
]);
|
|
78
|
+
let best = jpeg.length < png.length
|
|
79
|
+
? { data: jpeg.toString("base64"), mimeType: "image/jpeg" }
|
|
80
|
+
: { data: png.toString("base64"), mimeType: "image/png" };
|
|
81
|
+
// At its own size, what the tool produced is a candidate too — and when
|
|
82
|
+
// it is already the smallest, it goes out untouched.
|
|
83
|
+
if (target === slot.width && slot.original.data.length <= best.data.length)
|
|
84
|
+
best = slot.original;
|
|
85
|
+
slot.encoded.set(target, best);
|
|
86
|
+
return best;
|
|
87
|
+
};
|
|
88
|
+
const total = async () => {
|
|
89
|
+
let sum = 0;
|
|
90
|
+
for (const slot of slots) {
|
|
91
|
+
if (slot.dropped)
|
|
92
|
+
continue;
|
|
93
|
+
sum += (await encode(slot, slot.at)).data.length;
|
|
94
|
+
}
|
|
95
|
+
return sum;
|
|
96
|
+
};
|
|
97
|
+
const fits = async () => (await total()) <= budget;
|
|
98
|
+
let cropsDropped = 0;
|
|
99
|
+
let framesDropped = 0;
|
|
100
|
+
let width;
|
|
101
|
+
if (!(await fits())) {
|
|
102
|
+
// 1. Crops duplicate what a frame already shows.
|
|
103
|
+
for (const slot of slots) {
|
|
104
|
+
if (slot.role === "crop" && !slot.dropped) {
|
|
105
|
+
slot.dropped = true;
|
|
106
|
+
cropsDropped++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// 2. Smaller frames, all of them together, so they still compare.
|
|
111
|
+
for (const step of BUDGET_WIDTHS) {
|
|
112
|
+
if (await fits())
|
|
113
|
+
break;
|
|
114
|
+
for (const slot of slots)
|
|
115
|
+
if (!slot.dropped)
|
|
116
|
+
slot.at = Math.min(slot.at, step);
|
|
117
|
+
width = step;
|
|
118
|
+
}
|
|
119
|
+
// 3. Frames from the middle outwards: unprotected first, then protected,
|
|
120
|
+
// never the last one standing.
|
|
121
|
+
if (!(await fits())) {
|
|
122
|
+
const live = () => slots.filter((s) => !s.dropped);
|
|
123
|
+
const order = (protectedToo) => {
|
|
124
|
+
const alive = live();
|
|
125
|
+
const first = alive[0];
|
|
126
|
+
const last = alive[alive.length - 1];
|
|
127
|
+
const candidates = alive.filter((s) => s !== first && s !== last && (protectedToo || !s.keep));
|
|
128
|
+
// Middle outwards: sort by distance from the centre, farthest last.
|
|
129
|
+
const centre = (alive.length - 1) / 2;
|
|
130
|
+
return candidates.sort((a, b) => Math.abs(alive.indexOf(a) - centre) - Math.abs(alive.indexOf(b) - centre));
|
|
131
|
+
};
|
|
132
|
+
for (const protectedToo of [false, true]) {
|
|
133
|
+
for (const slot of order(protectedToo)) {
|
|
134
|
+
if (await fits())
|
|
135
|
+
break;
|
|
136
|
+
slot.dropped = true;
|
|
137
|
+
framesDropped++;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Down to the first and the last; then the first goes too.
|
|
141
|
+
while (!(await fits()) && live().length > 1) {
|
|
142
|
+
live()[0].dropped = true;
|
|
143
|
+
framesDropped++;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// 4. One image left and still over: shrink it as far as it takes.
|
|
147
|
+
for (const step of BUDGET_LAST_RESORT_WIDTHS) {
|
|
148
|
+
if (await fits())
|
|
149
|
+
break;
|
|
150
|
+
for (const slot of slots)
|
|
151
|
+
if (!slot.dropped)
|
|
152
|
+
slot.at = Math.min(slot.at, step);
|
|
153
|
+
width = step;
|
|
154
|
+
}
|
|
155
|
+
const degraded = cropsDropped > 0 || framesDropped > 0 || width !== undefined;
|
|
156
|
+
const out = [];
|
|
157
|
+
for (let i = 0; i < content.length; i++) {
|
|
158
|
+
const slot = slots.find((s) => s.index === i);
|
|
159
|
+
if (!slot) {
|
|
160
|
+
out.push(content[i]);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (slot.dropped) {
|
|
164
|
+
out.push({ type: "text", text: OMITTED });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const best = await encode(slot, slot.at);
|
|
168
|
+
const block = content[i];
|
|
169
|
+
const { _meta, ...rest } = block;
|
|
170
|
+
const meta = _meta ? Object.fromEntries(Object.entries(_meta).filter(([key]) => key !== META_KEY)) : undefined;
|
|
171
|
+
out.push({ ...rest, data: best.data, mimeType: best.mimeType, ...(meta && Object.keys(meta).length > 0 ? { _meta: meta } : {}) });
|
|
172
|
+
}
|
|
173
|
+
if (degraded) {
|
|
174
|
+
const kept = slots.filter((s) => !s.dropped).length;
|
|
175
|
+
out.push({
|
|
176
|
+
type: "text",
|
|
177
|
+
text: describeBudget({ total: slots.length, kept, crops_dropped: cropsDropped, frames_dropped: framesDropped, width, cap_tokens: cap }),
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return { ...result, content: out };
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=budget.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"budget.js","sourceRoot":"","sources":["../../src/utils/budget.ts"],"names":[],"mappings":"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","sourcesContent":["import sharp from \"sharp\";\nimport type { CallToolResult, ImageContent } from \"@modelcontextprotocol/sdk/types.js\";\nimport {\n BUDGET_CHARS_PER_TOKEN,\n BUDGET_JPEG_QUALITY,\n BUDGET_LAST_RESORT_WIDTHS,\n BUDGET_MARGIN_TOKENS,\n BUDGET_SUGGESTED_TOKENS,\n BUDGET_WIDTHS,\n DEFAULT_MCP_OUTPUT_TOKENS,\n} from \"../constants.js\";\n\n/**\n * The image budget.\n *\n * Claude Code caps one MCP tool result at `MAX_MCP_OUTPUT_TOKENS` (25,000 by\n * default) and counts base64 image data toward it. A result over the cap is\n * written to a file and replaced with a reference, so the model sees no\n * images at all — and a single screenshot of a real page is 180 KB of PNG,\n * which is over on its own. Every result therefore passes through here on\n * its way out: each image is encoded the cheapest way that is still faithful,\n * and if the result still does not fit it is degraded in a fixed order —\n * crops first, then frame size, then frames from the middle — and says so.\n * Fewer images that arrive beat all of them lost.\n */\n\nexport interface ImageMark {\n /** A `crop` duplicates part of a frame and is the first thing to go. */\n role?: \"frame\" | \"crop\";\n /** Never dropped while any unmarked frame remains (first/last/interaction frames). */\n keep?: boolean;\n}\n\nconst META_KEY = \"framewatch\";\n\n/** Stamp an image block with its role, for `fitToBudget` to read (and strip). */\nexport function markImage(block: ImageContent, mark: ImageMark): ImageContent {\n return { ...block, _meta: { ...(block._meta ?? {}), [META_KEY]: mark } };\n}\n\n/** The cap in force: what the shell that started Claude Code exported, or the default. */\nexport function capFromEnv(env: NodeJS.ProcessEnv = process.env): number {\n const raw = Number(env.MAX_MCP_OUTPUT_TOKENS);\n return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MCP_OUTPUT_TOKENS;\n}\n\n/** Base64 characters of image data a result may carry once its text is paid for. */\nexport function imageBudgetChars(capTokens: number, textChars: number): number {\n const textTokens = Math.ceil(textChars / BUDGET_CHARS_PER_TOKEN);\n return Math.max(0, (capTokens - textTokens - BUDGET_MARGIN_TOKENS) * BUDGET_CHARS_PER_TOKEN);\n}\n\nexport interface BudgetStats {\n total: number;\n kept: number;\n crops_dropped: number;\n frames_dropped: number;\n /** Width the surviving frames were shrunk to, or undefined when untouched. */\n width?: number;\n cap_tokens: number;\n}\n\nexport function describeBudget(stats: BudgetStats): string {\n const cuts: string[] = [];\n if (stats.crops_dropped > 0) cuts.push(`${stats.crops_dropped} crop${stats.crops_dropped === 1 ? \"\" : \"s\"} dropped`);\n if (stats.frames_dropped > 0) cuts.push(`${stats.frames_dropped} frame${stats.frames_dropped === 1 ? \"\" : \"s\"} dropped`);\n if (stats.width !== undefined) cuts.push(`frames at ${stats.width}px`);\n const kb = Math.round((imageBudgetChars(stats.cap_tokens, 0) * 0.75) / 1024);\n return (\n `Image budget: ${stats.kept} of ${stats.total} images kept — ${cuts.join(\", \")} — to fit MAX_MCP_OUTPUT_TOKENS=${stats.cap_tokens} ` +\n `(~${kb} KB of images per result). Set MAX_MCP_OUTPUT_TOKENS=${BUDGET_SUGGESTED_TOKENS} in the shell that starts Claude Code for full results.`\n );\n}\n\nexport interface FitOptions {\n /** Override the cap (tests); defaults to `capFromEnv()`. */\n cap_tokens?: number;\n}\n\ninterface Candidate {\n data: string;\n mimeType: string;\n}\n\ninterface Slot {\n /** Index into `result.content`. */\n index: number;\n /** The block as the tool produced it — kept verbatim when nothing beats it. */\n original: Candidate;\n role: \"frame\" | \"crop\";\n keep: boolean;\n source: Buffer;\n width: number;\n /** Encodings by width, filled lazily. */\n encoded: Map<number, Candidate>;\n dropped: boolean;\n /** Width in force for this slot (its own width when untouched). */\n at: number;\n}\n\nconst OMITTED = \"(image omitted — see the Image budget line)\";\n\nexport async function fitToBudget(result: CallToolResult, options: FitOptions = {}): Promise<CallToolResult> {\n const content = result.content;\n const slots: Slot[] = [];\n for (let i = 0; i < content.length; i++) {\n const block = content[i];\n if (block.type !== \"image\") continue;\n const mark = ((block._meta ?? {})[META_KEY] ?? {}) as ImageMark;\n const source = Buffer.from(block.data, \"base64\");\n let width = 0;\n try {\n width = (await sharp(source).metadata()).width ?? 0;\n } catch {\n // Not an image sharp can read: leave the block exactly as it is.\n continue;\n }\n slots.push({\n index: i,\n original: { data: block.data, mimeType: block.mimeType },\n role: mark.role === \"crop\" ? \"crop\" : \"frame\",\n keep: mark.keep === true,\n source,\n width,\n encoded: new Map(),\n dropped: false,\n at: width,\n });\n }\n if (slots.length === 0) return result;\n\n const cap = options.cap_tokens ?? capFromEnv();\n const textChars = content.reduce((n, c) => (c.type === \"text\" ? n + c.text.length : n), 0);\n // Leave room for the note this may have to add.\n const budget = imageBudgetChars(cap, textChars + 400);\n\n const encode = async (slot: Slot, width: number): Promise<Candidate> => {\n const target = Math.min(width, slot.width);\n const cached = slot.encoded.get(target);\n if (cached) return cached;\n let image = sharp(slot.source);\n if (target < slot.width) image = image.resize(target, null, { fit: \"inside\", withoutEnlargement: true });\n const [png, jpeg] = await Promise.all([\n image.clone().png({ quality: 80, effort: 1, compressionLevel: 6 }).toBuffer(),\n image.clone().jpeg({ quality: BUDGET_JPEG_QUALITY, mozjpeg: true }).toBuffer(),\n ]);\n let best: Candidate =\n jpeg.length < png.length\n ? { data: jpeg.toString(\"base64\"), mimeType: \"image/jpeg\" }\n : { data: png.toString(\"base64\"), mimeType: \"image/png\" };\n // At its own size, what the tool produced is a candidate too — and when\n // it is already the smallest, it goes out untouched.\n if (target === slot.width && slot.original.data.length <= best.data.length) best = slot.original;\n slot.encoded.set(target, best);\n return best;\n };\n const total = async (): Promise<number> => {\n let sum = 0;\n for (const slot of slots) {\n if (slot.dropped) continue;\n sum += (await encode(slot, slot.at)).data.length;\n }\n return sum;\n };\n const fits = async (): Promise<boolean> => (await total()) <= budget;\n\n let cropsDropped = 0;\n let framesDropped = 0;\n let width: number | undefined;\n\n if (!(await fits())) {\n // 1. Crops duplicate what a frame already shows.\n for (const slot of slots) {\n if (slot.role === \"crop\" && !slot.dropped) {\n slot.dropped = true;\n cropsDropped++;\n }\n }\n }\n // 2. Smaller frames, all of them together, so they still compare.\n for (const step of BUDGET_WIDTHS) {\n if (await fits()) break;\n for (const slot of slots) if (!slot.dropped) slot.at = Math.min(slot.at, step);\n width = step;\n }\n // 3. Frames from the middle outwards: unprotected first, then protected,\n // never the last one standing.\n if (!(await fits())) {\n const live = () => slots.filter((s) => !s.dropped);\n const order = (protectedToo: boolean): Slot[] => {\n const alive = live();\n const first = alive[0];\n const last = alive[alive.length - 1];\n const candidates = alive.filter((s) => s !== first && s !== last && (protectedToo || !s.keep));\n // Middle outwards: sort by distance from the centre, farthest last.\n const centre = (alive.length - 1) / 2;\n return candidates.sort((a, b) => Math.abs(alive.indexOf(a) - centre) - Math.abs(alive.indexOf(b) - centre));\n };\n for (const protectedToo of [false, true]) {\n for (const slot of order(protectedToo)) {\n if (await fits()) break;\n slot.dropped = true;\n framesDropped++;\n }\n }\n // Down to the first and the last; then the first goes too.\n while (!(await fits()) && live().length > 1) {\n live()[0].dropped = true;\n framesDropped++;\n }\n }\n // 4. One image left and still over: shrink it as far as it takes.\n for (const step of BUDGET_LAST_RESORT_WIDTHS) {\n if (await fits()) break;\n for (const slot of slots) if (!slot.dropped) slot.at = Math.min(slot.at, step);\n width = step;\n }\n\n const degraded = cropsDropped > 0 || framesDropped > 0 || width !== undefined;\n const out: CallToolResult[\"content\"] = [];\n for (let i = 0; i < content.length; i++) {\n const slot = slots.find((s) => s.index === i);\n if (!slot) {\n out.push(content[i]);\n continue;\n }\n if (slot.dropped) {\n out.push({ type: \"text\", text: OMITTED });\n continue;\n }\n const best = await encode(slot, slot.at);\n const block = content[i] as ImageContent;\n const { _meta, ...rest } = block;\n const meta = _meta ? Object.fromEntries(Object.entries(_meta).filter(([key]) => key !== META_KEY)) : undefined;\n out.push({ ...rest, data: best.data, mimeType: best.mimeType, ...(meta && Object.keys(meta).length > 0 ? { _meta: meta } : {}) });\n }\n if (degraded) {\n const kept = slots.filter((s) => !s.dropped).length;\n out.push({\n type: \"text\",\n text: describeBudget({ total: slots.length, kept, crops_dropped: cropsDropped, frames_dropped: framesDropped, width, cap_tokens: cap }),\n });\n }\n return { ...result, content: out };\n}\n"]}
|
package/dist/utils/format.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
-
import type { DiffCard } from "../types.js";
|
|
2
|
+
import type { DiffCard, Viewport } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* How the images relate to the page. Frames are shrunk to OUTPUT_MAX_WIDTH,
|
|
5
|
+
* but every coordinate a tool prints — change regions, boxes, click points —
|
|
6
|
+
* is in viewport pixels. A reader who measures a position off the image and
|
|
7
|
+
* clicks there lands 1.6× off on the default viewport, so every tool that
|
|
8
|
+
* returns frames says which it is.
|
|
9
|
+
*/
|
|
10
|
+
export declare function describeScale(viewport: Viewport): string;
|
|
3
11
|
/** What became of a replayed interaction script. */
|
|
4
12
|
export interface InteractionReport {
|
|
5
13
|
/** Steps in the script. */
|
|
@@ -27,6 +35,8 @@ export interface CaptureSummary {
|
|
|
27
35
|
dropped?: number;
|
|
28
36
|
/** Present only when an interaction script was replayed. */
|
|
29
37
|
interactions?: InteractionReport;
|
|
38
|
+
/** The viewport the frames were taken at; adds the image-scale line when known. */
|
|
39
|
+
viewport?: Viewport;
|
|
30
40
|
/**
|
|
31
41
|
* Remarks about the capture itself rather than about any one frame — a
|
|
32
42
|
* context layer that hit its cap, requests still in flight when the
|
package/dist/utils/format.js
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
import { OUTPUT_MAX_WIDTH } from "../constants.js";
|
|
2
|
+
import { markImage } from "./budget.js";
|
|
3
|
+
/**
|
|
4
|
+
* How the images relate to the page. Frames are shrunk to OUTPUT_MAX_WIDTH,
|
|
5
|
+
* but every coordinate a tool prints — change regions, boxes, click points —
|
|
6
|
+
* is in viewport pixels. A reader who measures a position off the image and
|
|
7
|
+
* clicks there lands 1.6× off on the default viewport, so every tool that
|
|
8
|
+
* returns frames says which it is.
|
|
9
|
+
*/
|
|
10
|
+
export function describeScale(viewport) {
|
|
11
|
+
const base = `viewport ${viewport.width}x${viewport.height}`;
|
|
12
|
+
if (viewport.width <= OUTPUT_MAX_WIDTH)
|
|
13
|
+
return `${base}, images at full size`;
|
|
14
|
+
const factor = (OUTPUT_MAX_WIDTH / viewport.width).toFixed(2);
|
|
15
|
+
return `${base}, images ${OUTPUT_MAX_WIDTH}px wide (${factor}×) — coordinates and regions are in viewport px`;
|
|
16
|
+
}
|
|
1
17
|
/**
|
|
2
18
|
* Build the MCP CallToolResult for a capture, following the "MCP Response
|
|
3
19
|
* Format" in CLAUDE.md: one summary text block, then per card an image block,
|
|
@@ -5,6 +21,10 @@
|
|
|
5
21
|
*/
|
|
6
22
|
export function formatDiffCards(summary) {
|
|
7
23
|
const lines = [formatSummaryLine(summary)];
|
|
24
|
+
if (summary.viewport) {
|
|
25
|
+
const scale = describeScale(summary.viewport);
|
|
26
|
+
lines.push(scale.charAt(0).toUpperCase() + scale.slice(1));
|
|
27
|
+
}
|
|
8
28
|
if (summary.interactions) {
|
|
9
29
|
lines.push(formatInteractionLine(summary.interactions));
|
|
10
30
|
}
|
|
@@ -12,13 +32,16 @@ export function formatDiffCards(summary) {
|
|
|
12
32
|
lines.push(note);
|
|
13
33
|
}
|
|
14
34
|
const content = [{ type: "text", text: lines.join("\n") }];
|
|
15
|
-
|
|
16
|
-
|
|
35
|
+
const last = summary.cards.length - 1;
|
|
36
|
+
summary.cards.forEach((card, i) => {
|
|
37
|
+
// First, last and event frames survive the image budget longest; crops go first.
|
|
38
|
+
const keep = i === 0 || i === last || card.trigger !== "animation";
|
|
39
|
+
content.push(markImage({ type: "image", data: card.full_frame, mimeType: "image/png" }, { role: "frame", keep }));
|
|
17
40
|
content.push({ type: "text", text: formatCardMeta(card) });
|
|
18
41
|
if (card.change_region?.crop) {
|
|
19
|
-
content.push({ type: "image", data: card.change_region.crop, mimeType: "image/png" });
|
|
42
|
+
content.push(markImage({ type: "image", data: card.change_region.crop, mimeType: "image/png" }, { role: "crop" }));
|
|
20
43
|
}
|
|
21
|
-
}
|
|
44
|
+
});
|
|
22
45
|
return { content };
|
|
23
46
|
}
|
|
24
47
|
/**
|
package/dist/utils/format.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"format.js","sourceRoot":"","sources":["../../src/utils/format.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CallToolResult } from \"@modelcontextprotocol/sdk/types.js\";\nimport type { DiffCard } from \"../types.js\";\n\n/** What became of a replayed interaction script. */\nexport interface InteractionReport {\n /** Steps in the script. */\n total: number;\n /** Steps that ran successfully. */\n completed: number;\n /** One-line description of each completed step, in order. */\n steps: string[];\n /** Message from the step that failed, if any (already a single line). */\n error?: string;\n /** 1-based position of the failing step. */\n failed_index?: number;\n}\n\n/** Everything the formatter needs to describe one capture session. */\nexport interface CaptureSummary {\n cards: DiffCard[];\n total_frames: number;\n duration_ms: number;\n /** URL that was requested. */\n url: string;\n /** URL the page ended on (after redirects / in-page navigation). */\n final_url?: string;\n title?: string;\n /** Frames dropped by the recorder (screenshot failures). */\n dropped?: number;\n /** Present only when an interaction script was replayed. */\n interactions?: InteractionReport;\n /**\n * Remarks about the capture itself rather than about any one frame — a\n * context layer that hit its cap, requests still in flight when the\n * recording ended. One line each, after the summary.\n */\n notes?: string[];\n}\n\n/**\n * Build the MCP CallToolResult for a capture, following the \"MCP Response\n * Format\" in CLAUDE.md: one summary text block, then per card an image block,\n * a metadata text block and (when present) the change-region crop image.\n */\nexport function formatDiffCards(summary: CaptureSummary): CallToolResult {\n const lines = [formatSummaryLine(summary)];\n if (summary.interactions) {\n lines.push(formatInteractionLine(summary.interactions));\n }\n for (const note of summary.notes ?? []) {\n lines.push(note);\n }\n const content: CallToolResult[\"content\"] = [{ type: \"text\", text: lines.join(\"\\n\") }];\n for (const card of summary.cards) {\n content.push({ type: \"image\", data: card.full_frame, mimeType: \"image/png\" });\n content.push({ type: \"text\", text: formatCardMeta(card) });\n if (card.change_region?.crop) {\n content.push({ type: \"image\", data: card.change_region.crop, mimeType: \"image/png\" });\n }\n }\n return { content };\n}\n\n/**\n * One-line description of the whole capture. Always names the requested url;\n * appends the final url only when the page ended somewhere else, the page\n * title when known, and the dropped-frame count only when frames were lost.\n */\nfunction formatSummaryLine(summary: CaptureSummary): string {\n const { cards, total_frames, duration_ms, url, final_url, title, dropped } = summary;\n const recording = `from ${total_frames} raw frames (${duration_ms}ms recording) of ${url}`;\n let text = cards.length === 0 ? `No frames captured ${recording}` : `Captured ${cards.length} meaningful frames ${recording}`;\n if (final_url !== undefined && !isSameUrl(url, final_url)) {\n text += ` → ${final_url}`;\n }\n if (title) {\n text += ` — \"${title}\"`;\n }\n if (dropped !== undefined && dropped > 0) {\n text += ` (${dropped} frames dropped)`;\n }\n return text;\n}\n\n/**\n * How the interaction script went: how many steps ran, what they were, and —\n * when one failed — which one and why. A failed step is a finding about the\n * page, not a tool failure, so it is reported here alongside the frames rather\n * than replacing them with an error.\n */\nexport function formatInteractionLine(report: InteractionReport): string {\n let text = `Interactions: ${report.completed}/${report.total} replayed`;\n if (report.steps.length > 0) {\n text += ` — ${report.steps.join(\", \")}`;\n }\n if (report.error !== undefined) {\n text += `. Step ${report.failed_index ?? report.completed + 1}: ${report.error}`;\n }\n return text;\n}\n\n/**\n * Compare two URLs as URLs, not as strings: `page.url()` returns the\n * WHATWG-normalised form, so a request for `http://localhost:3000` comes back\n * as `http://localhost:3000/` without anything having navigated.\n */\nfunction isSameUrl(a: string, b: string): boolean {\n if (a === b) return true;\n try {\n return new URL(a).href === new URL(b).href;\n } catch {\n return false;\n }\n}\n\n/**\n * The metadata text block for one card (exported for tests and for reuse by\n * the interact tool). Line 1 is always `Frame N @ Tms [trigger]`; the\n * optional sections follow in a fixed order — Changed, Console, Network,\n * Performance, DOM — and are omitted entirely when their data is absent.\n */\nexport function formatCardMeta(card: DiffCard): string {\n const lines: string[] = [`Frame ${card.index} @ ${card.timestamp_ms}ms [${card.trigger}]`];\n\n const region = card.change_region;\n if (region) {\n if (region.change_percent === 0) {\n lines.push(\"Changed: 0.0% — no visual change since previous frame\");\n } else {\n const { bbox } = region;\n let changed = `Changed: ${region.change_percent.toFixed(1)}% — region: ${bbox.x},${bbox.y} ${bbox.width}x${bbox.height}`;\n if (!region.crop) {\n changed += \" (full-frame change, see frame image)\";\n }\n lines.push(changed);\n }\n }\n\n if (card.console_entries?.length) {\n lines.push(\"Console:\");\n for (const entry of card.console_entries) {\n lines.push(` [${entry.level}] ${entry.text}`);\n }\n }\n\n if (card.network_events?.length) {\n lines.push(\"Network:\");\n for (const event of card.network_events) {\n // A request that never got a response has no status to print; what\n // stopped it (or that it is still running) is the useful part.\n const outcome = event.status > 0 ? String(event.status) : (event.error ?? \"no response\");\n const why = event.status > 0 && event.error !== undefined ? ` ${event.error}` : \"\";\n lines.push(` ${event.method} ${event.url} → ${outcome}${why} (${event.duration_ms}ms)`);\n }\n }\n\n const perf = card.performance;\n if (perf) {\n lines.push(\"Performance:\");\n if (perf.paint_time_ms !== undefined) lines.push(` paint ${perf.paint_time_ms}ms`);\n if (perf.layout_shifts !== undefined) {\n const score = perf.layout_shift_score !== undefined ? ` (score ${perf.layout_shift_score})` : \"\";\n lines.push(` layout shifts ${perf.layout_shifts}${score}`);\n }\n if (perf.lcp_ms !== undefined) lines.push(` lcp ${perf.lcp_ms}ms`);\n }\n\n if (card.dom_snapshot) {\n lines.push(`DOM:\\n${card.dom_snapshot}`);\n }\n\n return lines.join(\"\\n\");\n}\n"]}
|
|
1
|
+
{"version":3,"file":"format.js","sourceRoot":"","sources":["../../src/utils/format.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CallToolResult } from \"@modelcontextprotocol/sdk/types.js\";\nimport { OUTPUT_MAX_WIDTH } from \"../constants.js\";\nimport { markImage } from \"./budget.js\";\nimport type { DiffCard, Viewport } from \"../types.js\";\n\n/**\n * How the images relate to the page. Frames are shrunk to OUTPUT_MAX_WIDTH,\n * but every coordinate a tool prints — change regions, boxes, click points —\n * is in viewport pixels. A reader who measures a position off the image and\n * clicks there lands 1.6× off on the default viewport, so every tool that\n * returns frames says which it is.\n */\nexport function describeScale(viewport: Viewport): string {\n const base = `viewport ${viewport.width}x${viewport.height}`;\n if (viewport.width <= OUTPUT_MAX_WIDTH) return `${base}, images at full size`;\n const factor = (OUTPUT_MAX_WIDTH / viewport.width).toFixed(2);\n return `${base}, images ${OUTPUT_MAX_WIDTH}px wide (${factor}×) — coordinates and regions are in viewport px`;\n}\n\n/** What became of a replayed interaction script. */\nexport interface InteractionReport {\n /** Steps in the script. */\n total: number;\n /** Steps that ran successfully. */\n completed: number;\n /** One-line description of each completed step, in order. */\n steps: string[];\n /** Message from the step that failed, if any (already a single line). */\n error?: string;\n /** 1-based position of the failing step. */\n failed_index?: number;\n}\n\n/** Everything the formatter needs to describe one capture session. */\nexport interface CaptureSummary {\n cards: DiffCard[];\n total_frames: number;\n duration_ms: number;\n /** URL that was requested. */\n url: string;\n /** URL the page ended on (after redirects / in-page navigation). */\n final_url?: string;\n title?: string;\n /** Frames dropped by the recorder (screenshot failures). */\n dropped?: number;\n /** Present only when an interaction script was replayed. */\n interactions?: InteractionReport;\n /** The viewport the frames were taken at; adds the image-scale line when known. */\n viewport?: Viewport;\n /**\n * Remarks about the capture itself rather than about any one frame — a\n * context layer that hit its cap, requests still in flight when the\n * recording ended. One line each, after the summary.\n */\n notes?: string[];\n}\n\n/**\n * Build the MCP CallToolResult for a capture, following the \"MCP Response\n * Format\" in CLAUDE.md: one summary text block, then per card an image block,\n * a metadata text block and (when present) the change-region crop image.\n */\nexport function formatDiffCards(summary: CaptureSummary): CallToolResult {\n const lines = [formatSummaryLine(summary)];\n if (summary.viewport) {\n const scale = describeScale(summary.viewport);\n lines.push(scale.charAt(0).toUpperCase() + scale.slice(1));\n }\n if (summary.interactions) {\n lines.push(formatInteractionLine(summary.interactions));\n }\n for (const note of summary.notes ?? []) {\n lines.push(note);\n }\n const content: CallToolResult[\"content\"] = [{ type: \"text\", text: lines.join(\"\\n\") }];\n const last = summary.cards.length - 1;\n summary.cards.forEach((card, i) => {\n // First, last and event frames survive the image budget longest; crops go first.\n const keep = i === 0 || i === last || card.trigger !== \"animation\";\n content.push(markImage({ type: \"image\", data: card.full_frame, mimeType: \"image/png\" }, { role: \"frame\", keep }));\n content.push({ type: \"text\", text: formatCardMeta(card) });\n if (card.change_region?.crop) {\n content.push(markImage({ type: \"image\", data: card.change_region.crop, mimeType: \"image/png\" }, { role: \"crop\" }));\n }\n });\n return { content };\n}\n\n/**\n * One-line description of the whole capture. Always names the requested url;\n * appends the final url only when the page ended somewhere else, the page\n * title when known, and the dropped-frame count only when frames were lost.\n */\nfunction formatSummaryLine(summary: CaptureSummary): string {\n const { cards, total_frames, duration_ms, url, final_url, title, dropped } = summary;\n const recording = `from ${total_frames} raw frames (${duration_ms}ms recording) of ${url}`;\n let text = cards.length === 0 ? `No frames captured ${recording}` : `Captured ${cards.length} meaningful frames ${recording}`;\n if (final_url !== undefined && !isSameUrl(url, final_url)) {\n text += ` → ${final_url}`;\n }\n if (title) {\n text += ` — \"${title}\"`;\n }\n if (dropped !== undefined && dropped > 0) {\n text += ` (${dropped} frames dropped)`;\n }\n return text;\n}\n\n/**\n * How the interaction script went: how many steps ran, what they were, and —\n * when one failed — which one and why. A failed step is a finding about the\n * page, not a tool failure, so it is reported here alongside the frames rather\n * than replacing them with an error.\n */\nexport function formatInteractionLine(report: InteractionReport): string {\n let text = `Interactions: ${report.completed}/${report.total} replayed`;\n if (report.steps.length > 0) {\n text += ` — ${report.steps.join(\", \")}`;\n }\n if (report.error !== undefined) {\n text += `. Step ${report.failed_index ?? report.completed + 1}: ${report.error}`;\n }\n return text;\n}\n\n/**\n * Compare two URLs as URLs, not as strings: `page.url()` returns the\n * WHATWG-normalised form, so a request for `http://localhost:3000` comes back\n * as `http://localhost:3000/` without anything having navigated.\n */\nfunction isSameUrl(a: string, b: string): boolean {\n if (a === b) return true;\n try {\n return new URL(a).href === new URL(b).href;\n } catch {\n return false;\n }\n}\n\n/**\n * The metadata text block for one card (exported for tests and for reuse by\n * the interact tool). Line 1 is always `Frame N @ Tms [trigger]`; the\n * optional sections follow in a fixed order — Changed, Console, Network,\n * Performance, DOM — and are omitted entirely when their data is absent.\n */\nexport function formatCardMeta(card: DiffCard): string {\n const lines: string[] = [`Frame ${card.index} @ ${card.timestamp_ms}ms [${card.trigger}]`];\n\n const region = card.change_region;\n if (region) {\n if (region.change_percent === 0) {\n lines.push(\"Changed: 0.0% — no visual change since previous frame\");\n } else {\n const { bbox } = region;\n let changed = `Changed: ${region.change_percent.toFixed(1)}% — region: ${bbox.x},${bbox.y} ${bbox.width}x${bbox.height}`;\n if (!region.crop) {\n changed += \" (full-frame change, see frame image)\";\n }\n lines.push(changed);\n }\n }\n\n if (card.console_entries?.length) {\n lines.push(\"Console:\");\n for (const entry of card.console_entries) {\n lines.push(` [${entry.level}] ${entry.text}`);\n }\n }\n\n if (card.network_events?.length) {\n lines.push(\"Network:\");\n for (const event of card.network_events) {\n // A request that never got a response has no status to print; what\n // stopped it (or that it is still running) is the useful part.\n const outcome = event.status > 0 ? String(event.status) : (event.error ?? \"no response\");\n const why = event.status > 0 && event.error !== undefined ? ` ${event.error}` : \"\";\n lines.push(` ${event.method} ${event.url} → ${outcome}${why} (${event.duration_ms}ms)`);\n }\n }\n\n const perf = card.performance;\n if (perf) {\n lines.push(\"Performance:\");\n if (perf.paint_time_ms !== undefined) lines.push(` paint ${perf.paint_time_ms}ms`);\n if (perf.layout_shifts !== undefined) {\n const score = perf.layout_shift_score !== undefined ? ` (score ${perf.layout_shift_score})` : \"\";\n lines.push(` layout shifts ${perf.layout_shifts}${score}`);\n }\n if (perf.lcp_ms !== undefined) lines.push(` lcp ${perf.lcp_ms}ms`);\n }\n\n if (card.dom_snapshot) {\n lines.push(`DOM:\\n${card.dom_snapshot}`);\n }\n\n return lines.join(\"\\n\");\n}\n"]}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { BoundingBox } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Element highlight overlay.
|
|
5
|
+
*
|
|
6
|
+
* A selector like `.nav > button:nth-of-type(3)` says nothing about where the
|
|
7
|
+
* problem is on screen. This draws a labelled box over each named element and
|
|
8
|
+
* leaves it there until the screenshot has been taken, so a report about
|
|
9
|
+
* elements comes with a picture of those elements.
|
|
10
|
+
*
|
|
11
|
+
* The boxes are drawn in *document* coordinates and appended to `<body>`, so
|
|
12
|
+
* one full-page screenshot shows every highlight, including the ones below the
|
|
13
|
+
* fold. They are `pointer-events: none` and are removed again by
|
|
14
|
+
* `clearHighlights`, so nothing here changes what the page does — but the
|
|
15
|
+
* overlay is still a DOM mutation, which is why callers that watch for
|
|
16
|
+
* mutations (framewatch_dead_clicks) draw it only once everything has been
|
|
17
|
+
* measured.
|
|
18
|
+
*
|
|
19
|
+
* Every style is set through the CSSOM rather than as a `style` attribute or a
|
|
20
|
+
* `<style>` block: a page with a strict Content-Security-Policy blocks both of
|
|
21
|
+
* those, and the pages most worth auditing are the ones that lock themselves
|
|
22
|
+
* down.
|
|
23
|
+
*/
|
|
24
|
+
/** The id of the container every highlight lives in, so `clearHighlights` can find it. */
|
|
25
|
+
export declare const HIGHLIGHT_CONTAINER_ID = "__framewatch_highlights";
|
|
26
|
+
export interface Highlight {
|
|
27
|
+
/**
|
|
28
|
+
* CSS selector for the element to draw over — or, when `box` is given, just
|
|
29
|
+
* the name this highlight is reported under in `drawn` / `missing`.
|
|
30
|
+
*/
|
|
31
|
+
selector: string;
|
|
32
|
+
/**
|
|
33
|
+
* Draw here instead of looking `selector` up, in viewport pixels. For an
|
|
34
|
+
* element that was resolved some other way (an aria ref, a handle) and has
|
|
35
|
+
* already been measured.
|
|
36
|
+
*/
|
|
37
|
+
box?: BoundingBox;
|
|
38
|
+
/**
|
|
39
|
+
* Which match of `selector` to draw over, when the selector is not unique.
|
|
40
|
+
* Defaults to the first.
|
|
41
|
+
*/
|
|
42
|
+
match_index?: number;
|
|
43
|
+
/** Drawn in the corner of the box — a number, or a short phrase. */
|
|
44
|
+
label?: string;
|
|
45
|
+
/** Any CSS colour. The box is outlined in it, and the label badge is painted with it. */
|
|
46
|
+
colour?: string;
|
|
47
|
+
/**
|
|
48
|
+
* The translucent fill inside the box. Give it a low alpha: what is under
|
|
49
|
+
* the box is the thing being reported on, and it has to stay readable.
|
|
50
|
+
*/
|
|
51
|
+
wash?: string;
|
|
52
|
+
}
|
|
53
|
+
export interface HighlightResult {
|
|
54
|
+
/** Selectors that were drawn. */
|
|
55
|
+
drawn: string[];
|
|
56
|
+
/** Selectors that matched nothing, or matched something with no box to draw. */
|
|
57
|
+
missing: string[];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Draw a box over each element in `highlights` and return which ones landed.
|
|
61
|
+
*
|
|
62
|
+
* An element that is gone, or that has no size, is reported in `missing`
|
|
63
|
+
* rather than being an error: by the time a report is drawn the page may have
|
|
64
|
+
* re-rendered, and a screenshot missing one box is far better than no
|
|
65
|
+
* screenshot at all.
|
|
66
|
+
*/
|
|
67
|
+
export declare function highlightElements(page: Page, highlights: readonly Highlight[], max?: number): Promise<HighlightResult>;
|
|
68
|
+
/** Remove every box this module drew. Safe to call when there are none. */
|
|
69
|
+
export declare function clearHighlights(page: Page): Promise<void>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { MAX_HIGHLIGHTS } from "../constants.js";
|
|
2
|
+
/**
|
|
3
|
+
* Element highlight overlay.
|
|
4
|
+
*
|
|
5
|
+
* A selector like `.nav > button:nth-of-type(3)` says nothing about where the
|
|
6
|
+
* problem is on screen. This draws a labelled box over each named element and
|
|
7
|
+
* leaves it there until the screenshot has been taken, so a report about
|
|
8
|
+
* elements comes with a picture of those elements.
|
|
9
|
+
*
|
|
10
|
+
* The boxes are drawn in *document* coordinates and appended to `<body>`, so
|
|
11
|
+
* one full-page screenshot shows every highlight, including the ones below the
|
|
12
|
+
* fold. They are `pointer-events: none` and are removed again by
|
|
13
|
+
* `clearHighlights`, so nothing here changes what the page does — but the
|
|
14
|
+
* overlay is still a DOM mutation, which is why callers that watch for
|
|
15
|
+
* mutations (framewatch_dead_clicks) draw it only once everything has been
|
|
16
|
+
* measured.
|
|
17
|
+
*
|
|
18
|
+
* Every style is set through the CSSOM rather than as a `style` attribute or a
|
|
19
|
+
* `<style>` block: a page with a strict Content-Security-Policy blocks both of
|
|
20
|
+
* those, and the pages most worth auditing are the ones that lock themselves
|
|
21
|
+
* down.
|
|
22
|
+
*/
|
|
23
|
+
/** The id of the container every highlight lives in, so `clearHighlights` can find it. */
|
|
24
|
+
export const HIGHLIGHT_CONTAINER_ID = "__framewatch_highlights";
|
|
25
|
+
/**
|
|
26
|
+
* Draw a box over each element in `highlights` and return which ones landed.
|
|
27
|
+
*
|
|
28
|
+
* An element that is gone, or that has no size, is reported in `missing`
|
|
29
|
+
* rather than being an error: by the time a report is drawn the page may have
|
|
30
|
+
* re-rendered, and a screenshot missing one box is far better than no
|
|
31
|
+
* screenshot at all.
|
|
32
|
+
*/
|
|
33
|
+
export async function highlightElements(page, highlights, max = MAX_HIGHLIGHTS) {
|
|
34
|
+
const wanted = highlights.slice(0, Math.max(0, max));
|
|
35
|
+
if (wanted.length === 0)
|
|
36
|
+
return { drawn: [], missing: [] };
|
|
37
|
+
try {
|
|
38
|
+
return await page.evaluate(drawHighlights, {
|
|
39
|
+
container_id: HIGHLIGHT_CONTAINER_ID,
|
|
40
|
+
items: wanted.map((item) => ({
|
|
41
|
+
selector: item.selector,
|
|
42
|
+
...(item.box ? { box: item.box } : {}),
|
|
43
|
+
match_index: item.match_index ?? 0,
|
|
44
|
+
label: item.label ?? "",
|
|
45
|
+
colour: item.colour ?? "#e5194b",
|
|
46
|
+
wash: item.wash ?? "rgba(229, 25, 75, 0.16)",
|
|
47
|
+
})),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// A page that will not run script (torn down, mid-navigation) simply gets
|
|
52
|
+
// no overlay. The caller still has its screenshot.
|
|
53
|
+
return { drawn: [], missing: wanted.map((item) => item.selector) };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Remove every box this module drew. Safe to call when there are none. */
|
|
57
|
+
export async function clearHighlights(page) {
|
|
58
|
+
await page
|
|
59
|
+
.evaluate((id) => {
|
|
60
|
+
const node = globalThis.document?.getElementById(id);
|
|
61
|
+
if (node && node.parentNode)
|
|
62
|
+
node.parentNode.removeChild(node);
|
|
63
|
+
}, HIGHLIGHT_CONTAINER_ID)
|
|
64
|
+
.catch(() => { });
|
|
65
|
+
}
|
|
66
|
+
function drawHighlights(options) {
|
|
67
|
+
const doc = globalThis.document;
|
|
68
|
+
const drawn = [];
|
|
69
|
+
const missing = [];
|
|
70
|
+
if (!doc || !doc.body)
|
|
71
|
+
return { drawn, missing: options.items.map((item) => item.selector) };
|
|
72
|
+
const existing = doc.getElementById(options.container_id);
|
|
73
|
+
if (existing && existing.parentNode)
|
|
74
|
+
existing.parentNode.removeChild(existing);
|
|
75
|
+
const container = doc.createElement("div");
|
|
76
|
+
container.id = options.container_id;
|
|
77
|
+
const style = (node, rules) => {
|
|
78
|
+
for (const name in rules) {
|
|
79
|
+
try {
|
|
80
|
+
node.style.setProperty(name, rules[name], "important");
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// A patched CSSOM; the box may look wrong but the screenshot survives.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
style(container, {
|
|
88
|
+
position: "absolute",
|
|
89
|
+
left: "0",
|
|
90
|
+
top: "0",
|
|
91
|
+
width: "0",
|
|
92
|
+
height: "0",
|
|
93
|
+
margin: "0",
|
|
94
|
+
padding: "0",
|
|
95
|
+
border: "0",
|
|
96
|
+
"pointer-events": "none",
|
|
97
|
+
// Above anything the page itself can stack.
|
|
98
|
+
"z-index": "2147483647",
|
|
99
|
+
});
|
|
100
|
+
// The container goes in empty and is measured, so every box below can be
|
|
101
|
+
// positioned relative to it. Absolute coordinates would be wrong the moment
|
|
102
|
+
// the page gives <body> a `position` or a `transform` of its own, which is
|
|
103
|
+
// exactly the kind of page worth auditing.
|
|
104
|
+
doc.body.appendChild(container);
|
|
105
|
+
let originLeft = 0;
|
|
106
|
+
let originTop = 0;
|
|
107
|
+
try {
|
|
108
|
+
const origin = container.getBoundingClientRect();
|
|
109
|
+
originLeft = origin.left;
|
|
110
|
+
originTop = origin.top;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// No rect to correct against; boxes land in document coordinates.
|
|
114
|
+
}
|
|
115
|
+
for (const item of options.items) {
|
|
116
|
+
let rect = null;
|
|
117
|
+
if (item.box) {
|
|
118
|
+
// Already measured, in viewport pixels — the same space as
|
|
119
|
+
// getBoundingClientRect, so it corrects against the origin the same way.
|
|
120
|
+
rect = { left: item.box.x, top: item.box.y, width: item.box.width, height: item.box.height };
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
let element = null;
|
|
124
|
+
try {
|
|
125
|
+
const matches = doc.querySelectorAll(item.selector);
|
|
126
|
+
element = matches[item.match_index] ?? matches[0] ?? null;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
element = null;
|
|
130
|
+
}
|
|
131
|
+
rect = element && element.getBoundingClientRect ? element.getBoundingClientRect() : null;
|
|
132
|
+
}
|
|
133
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
|
134
|
+
missing.push(item.selector);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const left = rect.left - originLeft;
|
|
138
|
+
const top = rect.top - originTop;
|
|
139
|
+
const box = doc.createElement("div");
|
|
140
|
+
style(box, {
|
|
141
|
+
position: "absolute",
|
|
142
|
+
// Laid out against the container, which sits at the document origin —
|
|
143
|
+
// so one full-page screenshot catches every box, including the ones
|
|
144
|
+
// below the fold.
|
|
145
|
+
left: `${Math.round(left)}px`,
|
|
146
|
+
top: `${Math.round(top)}px`,
|
|
147
|
+
width: `${Math.round(rect.width)}px`,
|
|
148
|
+
height: `${Math.round(rect.height)}px`,
|
|
149
|
+
"box-sizing": "border-box",
|
|
150
|
+
border: `3px solid ${item.colour}`,
|
|
151
|
+
"border-radius": "3px",
|
|
152
|
+
// A wash rather than a fill: what is under the box is the thing being
|
|
153
|
+
// reported on, and it has to stay readable.
|
|
154
|
+
"background-color": item.wash,
|
|
155
|
+
"box-shadow": "0 0 0 2px rgba(255, 255, 255, 0.85)",
|
|
156
|
+
"pointer-events": "none",
|
|
157
|
+
});
|
|
158
|
+
if (item.label !== "") {
|
|
159
|
+
const tag = doc.createElement("div");
|
|
160
|
+
tag.textContent = item.label;
|
|
161
|
+
style(tag, {
|
|
162
|
+
position: "absolute",
|
|
163
|
+
left: "-3px",
|
|
164
|
+
// Above the box, unless that would fall off the top of the document.
|
|
165
|
+
top: top >= 20 ? "-20px" : `${Math.round(rect.height)}px`,
|
|
166
|
+
padding: "1px 6px",
|
|
167
|
+
"background-color": item.colour,
|
|
168
|
+
color: "#ffffff",
|
|
169
|
+
font: "700 12px/16px ui-monospace, SFMono-Regular, Menlo, monospace",
|
|
170
|
+
"white-space": "nowrap",
|
|
171
|
+
"border-radius": "3px",
|
|
172
|
+
"pointer-events": "none",
|
|
173
|
+
});
|
|
174
|
+
box.appendChild(tag);
|
|
175
|
+
}
|
|
176
|
+
container.appendChild(box);
|
|
177
|
+
drawn.push(item.selector);
|
|
178
|
+
}
|
|
179
|
+
return { drawn, missing };
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=highlight.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"highlight.js","sourceRoot":"","sources":["../../src/utils/highlight.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Page } from \"playwright\";\nimport { MAX_HIGHLIGHTS } from \"../constants.js\";\nimport type { BoundingBox } from \"../types.js\";\n\n/**\n * Element highlight overlay.\n *\n * A selector like `.nav > button:nth-of-type(3)` says nothing about where the\n * problem is on screen. This draws a labelled box over each named element and\n * leaves it there until the screenshot has been taken, so a report about\n * elements comes with a picture of those elements.\n *\n * The boxes are drawn in *document* coordinates and appended to `<body>`, so\n * one full-page screenshot shows every highlight, including the ones below the\n * fold. They are `pointer-events: none` and are removed again by\n * `clearHighlights`, so nothing here changes what the page does — but the\n * overlay is still a DOM mutation, which is why callers that watch for\n * mutations (framewatch_dead_clicks) draw it only once everything has been\n * measured.\n *\n * Every style is set through the CSSOM rather than as a `style` attribute or a\n * `<style>` block: a page with a strict Content-Security-Policy blocks both of\n * those, and the pages most worth auditing are the ones that lock themselves\n * down.\n */\n\n/** The id of the container every highlight lives in, so `clearHighlights` can find it. */\nexport const HIGHLIGHT_CONTAINER_ID = \"__framewatch_highlights\";\n\nexport interface Highlight {\n /**\n * CSS selector for the element to draw over — or, when `box` is given, just\n * the name this highlight is reported under in `drawn` / `missing`.\n */\n selector: string;\n /**\n * Draw here instead of looking `selector` up, in viewport pixels. For an\n * element that was resolved some other way (an aria ref, a handle) and has\n * already been measured.\n */\n box?: BoundingBox;\n /**\n * Which match of `selector` to draw over, when the selector is not unique.\n * Defaults to the first.\n */\n match_index?: number;\n /** Drawn in the corner of the box — a number, or a short phrase. */\n label?: string;\n /** Any CSS colour. The box is outlined in it, and the label badge is painted with it. */\n colour?: string;\n /**\n * The translucent fill inside the box. Give it a low alpha: what is under\n * the box is the thing being reported on, and it has to stay readable.\n */\n wash?: string;\n}\n\nexport interface HighlightResult {\n /** Selectors that were drawn. */\n drawn: string[];\n /** Selectors that matched nothing, or matched something with no box to draw. */\n missing: string[];\n}\n\n/**\n * Draw a box over each element in `highlights` and return which ones landed.\n *\n * An element that is gone, or that has no size, is reported in `missing`\n * rather than being an error: by the time a report is drawn the page may have\n * re-rendered, and a screenshot missing one box is far better than no\n * screenshot at all.\n */\nexport async function highlightElements(\n page: Page,\n highlights: readonly Highlight[],\n max: number = MAX_HIGHLIGHTS,\n): Promise<HighlightResult> {\n const wanted = highlights.slice(0, Math.max(0, max));\n if (wanted.length === 0) return { drawn: [], missing: [] };\n\n try {\n return await page.evaluate(drawHighlights, {\n container_id: HIGHLIGHT_CONTAINER_ID,\n items: wanted.map((item) => ({\n selector: item.selector,\n ...(item.box ? { box: item.box } : {}),\n match_index: item.match_index ?? 0,\n label: item.label ?? \"\",\n colour: item.colour ?? \"#e5194b\",\n wash: item.wash ?? \"rgba(229, 25, 75, 0.16)\",\n })),\n });\n } catch {\n // A page that will not run script (torn down, mid-navigation) simply gets\n // no overlay. The caller still has its screenshot.\n return { drawn: [], missing: wanted.map((item) => item.selector) };\n }\n}\n\n/** Remove every box this module drew. Safe to call when there are none. */\nexport async function clearHighlights(page: Page): Promise<void> {\n await page\n .evaluate((id: string) => {\n const node = (globalThis as any).document?.getElementById(id);\n if (node && node.parentNode) node.parentNode.removeChild(node);\n }, HIGHLIGHT_CONTAINER_ID)\n .catch(() => {});\n}\n\n/* ── In-page ──────────────────────────────────────────────────────────────\n * Everything below runs inside Chromium, so it is written against\n * `globalThis` and untyped nodes: this package is compiled with the Node lib\n * only, and the page it lands in may have patched half of these properties.\n */\n\ninterface DrawOptions {\n container_id: string;\n items: Array<{\n selector: string;\n box?: { x: number; y: number; width: number; height: number };\n match_index: number;\n label: string;\n colour: string;\n wash: string;\n }>;\n}\n\nfunction drawHighlights(options: DrawOptions): { drawn: string[]; missing: string[] } {\n const doc = (globalThis as any).document;\n const drawn: string[] = [];\n const missing: string[] = [];\n if (!doc || !doc.body) return { drawn, missing: options.items.map((item) => item.selector) };\n\n const existing = doc.getElementById(options.container_id);\n if (existing && existing.parentNode) existing.parentNode.removeChild(existing);\n\n const container = doc.createElement(\"div\");\n container.id = options.container_id;\n const style = (node: any, rules: Record<string, string>): void => {\n for (const name in rules) {\n try {\n node.style.setProperty(name, rules[name], \"important\");\n } catch {\n // A patched CSSOM; the box may look wrong but the screenshot survives.\n }\n }\n };\n\n style(container, {\n position: \"absolute\",\n left: \"0\",\n top: \"0\",\n width: \"0\",\n height: \"0\",\n margin: \"0\",\n padding: \"0\",\n border: \"0\",\n \"pointer-events\": \"none\",\n // Above anything the page itself can stack.\n \"z-index\": \"2147483647\",\n });\n\n // The container goes in empty and is measured, so every box below can be\n // positioned relative to it. Absolute coordinates would be wrong the moment\n // the page gives <body> a `position` or a `transform` of its own, which is\n // exactly the kind of page worth auditing.\n doc.body.appendChild(container);\n let originLeft = 0;\n let originTop = 0;\n try {\n const origin = container.getBoundingClientRect();\n originLeft = origin.left;\n originTop = origin.top;\n } catch {\n // No rect to correct against; boxes land in document coordinates.\n }\n\n for (const item of options.items) {\n let rect: { left: number; top: number; width: number; height: number } | null = null;\n if (item.box) {\n // Already measured, in viewport pixels — the same space as\n // getBoundingClientRect, so it corrects against the origin the same way.\n rect = { left: item.box.x, top: item.box.y, width: item.box.width, height: item.box.height };\n } else {\n let element: any = null;\n try {\n const matches = doc.querySelectorAll(item.selector);\n element = matches[item.match_index] ?? matches[0] ?? null;\n } catch {\n element = null;\n }\n rect = element && element.getBoundingClientRect ? element.getBoundingClientRect() : null;\n }\n if (!rect || rect.width <= 0 || rect.height <= 0) {\n missing.push(item.selector);\n continue;\n }\n\n const left = rect.left - originLeft;\n const top = rect.top - originTop;\n const box = doc.createElement(\"div\");\n style(box, {\n position: \"absolute\",\n // Laid out against the container, which sits at the document origin —\n // so one full-page screenshot catches every box, including the ones\n // below the fold.\n left: `${Math.round(left)}px`,\n top: `${Math.round(top)}px`,\n width: `${Math.round(rect.width)}px`,\n height: `${Math.round(rect.height)}px`,\n \"box-sizing\": \"border-box\",\n border: `3px solid ${item.colour}`,\n \"border-radius\": \"3px\",\n // A wash rather than a fill: what is under the box is the thing being\n // reported on, and it has to stay readable.\n \"background-color\": item.wash,\n \"box-shadow\": \"0 0 0 2px rgba(255, 255, 255, 0.85)\",\n \"pointer-events\": \"none\",\n });\n\n if (item.label !== \"\") {\n const tag = doc.createElement(\"div\");\n tag.textContent = item.label;\n style(tag, {\n position: \"absolute\",\n left: \"-3px\",\n // Above the box, unless that would fall off the top of the document.\n top: top >= 20 ? \"-20px\" : `${Math.round(rect.height)}px`,\n padding: \"1px 6px\",\n \"background-color\": item.colour,\n color: \"#ffffff\",\n font: \"700 12px/16px ui-monospace, SFMono-Regular, Menlo, monospace\",\n \"white-space\": \"nowrap\",\n \"border-radius\": \"3px\",\n \"pointer-events\": \"none\",\n });\n box.appendChild(tag);\n }\n\n container.appendChild(box);\n drawn.push(item.selector);\n }\n\n return { drawn, missing };\n}\n"]}
|