framewatch-mcp-server 0.1.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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +537 -0
  3. package/dist/constants.d.ts +172 -0
  4. package/dist/constants.js +168 -0
  5. package/dist/constants.js.map +1 -0
  6. package/dist/engine/browser.d.ts +56 -0
  7. package/dist/engine/browser.js +142 -0
  8. package/dist/engine/browser.js.map +1 -0
  9. package/dist/engine/differ.d.ts +88 -0
  10. package/dist/engine/differ.js +373 -0
  11. package/dist/engine/differ.js.map +1 -0
  12. package/dist/engine/interaction.d.ts +76 -0
  13. package/dist/engine/interaction.js +254 -0
  14. package/dist/engine/interaction.js.map +1 -0
  15. package/dist/engine/layers/console.d.ts +63 -0
  16. package/dist/engine/layers/console.js +118 -0
  17. package/dist/engine/layers/console.js.map +1 -0
  18. package/dist/engine/layers/dom.d.ts +53 -0
  19. package/dist/engine/layers/dom.js +282 -0
  20. package/dist/engine/layers/dom.js.map +1 -0
  21. package/dist/engine/layers/index.d.ts +95 -0
  22. package/dist/engine/layers/index.js +184 -0
  23. package/dist/engine/layers/index.js.map +1 -0
  24. package/dist/engine/layers/network.d.ts +62 -0
  25. package/dist/engine/layers/network.js +169 -0
  26. package/dist/engine/layers/network.js.map +1 -0
  27. package/dist/engine/layers/performance.d.ts +55 -0
  28. package/dist/engine/layers/performance.js +215 -0
  29. package/dist/engine/layers/performance.js.map +1 -0
  30. package/dist/engine/layers/probe.d.ts +50 -0
  31. package/dist/engine/layers/probe.js +39 -0
  32. package/dist/engine/layers/probe.js.map +1 -0
  33. package/dist/engine/layers/session.d.ts +46 -0
  34. package/dist/engine/layers/session.js +131 -0
  35. package/dist/engine/layers/session.js.map +1 -0
  36. package/dist/engine/recorder.d.ts +61 -0
  37. package/dist/engine/recorder.js +256 -0
  38. package/dist/engine/recorder.js.map +1 -0
  39. package/dist/index.d.ts +13 -0
  40. package/dist/index.js +125 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/tools/accessibility.d.ts +140 -0
  43. package/dist/tools/accessibility.js +357 -0
  44. package/dist/tools/accessibility.js.map +1 -0
  45. package/dist/tools/capture.d.ts +279 -0
  46. package/dist/tools/capture.js +275 -0
  47. package/dist/tools/capture.js.map +1 -0
  48. package/dist/tools/compare.d.ts +86 -0
  49. package/dist/tools/compare.js +247 -0
  50. package/dist/tools/compare.js.map +1 -0
  51. package/dist/tools/index.d.ts +10 -0
  52. package/dist/tools/index.js +25 -0
  53. package/dist/tools/index.js.map +1 -0
  54. package/dist/tools/interact.d.ts +160 -0
  55. package/dist/tools/interact.js +203 -0
  56. package/dist/tools/interact.js.map +1 -0
  57. package/dist/tools/responsive.d.ts +89 -0
  58. package/dist/tools/responsive.js +197 -0
  59. package/dist/tools/responsive.js.map +1 -0
  60. package/dist/tools/screenshot.d.ts +76 -0
  61. package/dist/tools/screenshot.js +117 -0
  62. package/dist/tools/screenshot.js.map +1 -0
  63. package/dist/tools/server.d.ts +89 -0
  64. package/dist/tools/server.js +201 -0
  65. package/dist/tools/server.js.map +1 -0
  66. package/dist/types.d.ts +123 -0
  67. package/dist/types.js +9 -0
  68. package/dist/types.js.map +1 -0
  69. package/dist/utils/bounded-log.d.ts +41 -0
  70. package/dist/utils/bounded-log.js +78 -0
  71. package/dist/utils/bounded-log.js.map +1 -0
  72. package/dist/utils/format.d.ts +56 -0
  73. package/dist/utils/format.js +130 -0
  74. package/dist/utils/format.js.map +1 -0
  75. package/dist/utils/image.d.ts +44 -0
  76. package/dist/utils/image.js +81 -0
  77. package/dist/utils/image.js.map +1 -0
  78. package/dist/utils/server-process.d.ts +84 -0
  79. package/dist/utils/server-process.js +251 -0
  80. package/dist/utils/server-process.js.map +1 -0
  81. package/package.json +74 -0
@@ -0,0 +1,78 @@
1
+ /**
2
+ * A fixed-size log that gives priority to interesting entries.
3
+ *
4
+ * The context layers watch things a page can produce without limit — console
5
+ * output, network requests — so every collector needs a cap. A plain cap has
6
+ * the wrong failure mode: a page that logs in a render loop fills the budget
7
+ * with noise in the first second, and the console error thrown at second four
8
+ * (the one thing worth capturing) is dropped.
9
+ *
10
+ * So the log keeps `limit` entries, and once it is full an *important* entry
11
+ * still gets in by evicting the oldest unimportant one. Important entries are
12
+ * only ever dropped when the log holds nothing but important entries — at
13
+ * which point the page is genuinely producing more signal than the cap allows.
14
+ * `dropped` counts everything that did not make it, so the caller can say so.
15
+ */
16
+ export class BoundedLog {
17
+ #limit;
18
+ #isImportant;
19
+ #items = [];
20
+ #dropped = 0;
21
+ /**
22
+ * @param limit Maximum entries kept. Values below 1 are treated as 1.
23
+ * @param isImportant Entries worth evicting an ordinary entry for. Defaults to "nothing is".
24
+ */
25
+ constructor(limit, isImportant = () => false) {
26
+ this.#limit = Math.max(1, Math.floor(limit));
27
+ this.#isImportant = isImportant;
28
+ }
29
+ /** Entries kept, oldest first. */
30
+ get items() {
31
+ return this.#items;
32
+ }
33
+ get size() {
34
+ return this.#items.length;
35
+ }
36
+ /** How many entries were refused or evicted. */
37
+ get dropped() {
38
+ return this.#dropped;
39
+ }
40
+ add(item) {
41
+ if (this.#items.length < this.#limit) {
42
+ this.#items.push(item);
43
+ return;
44
+ }
45
+ if (!this.#isImportant(item)) {
46
+ this.#dropped++;
47
+ return;
48
+ }
49
+ const victim = this.#items.findIndex((existing) => !this.#isImportant(existing));
50
+ if (victim === -1) {
51
+ // Nothing but important entries left; the newest is the one we lose, so
52
+ // the record of how the trouble *started* survives.
53
+ this.#dropped++;
54
+ return;
55
+ }
56
+ this.#items.splice(victim, 1);
57
+ this.#items.push(item);
58
+ this.#dropped++;
59
+ }
60
+ /**
61
+ * Forget everything, including the dropped count.
62
+ *
63
+ * A capture builds a log and throws it away, but `framewatch_interact` keeps
64
+ * one page — and so one set of collectors — alive across many calls, and
65
+ * each call reports only what its own action caused. Without this, call
66
+ * twenty would repeat the console output of calls one to nineteen and then
67
+ * start dropping the entries that actually mattered.
68
+ */
69
+ clear() {
70
+ this.#items = [];
71
+ this.#dropped = 0;
72
+ }
73
+ /** A plain copy of the entries (safe to hand out and mutate). */
74
+ toArray() {
75
+ return [...this.#items];
76
+ }
77
+ }
78
+ //# sourceMappingURL=bounded-log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bounded-log.js","sourceRoot":"","sources":["../../src/utils/bounded-log.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,MAAM,OAAO,UAAU;IACZ,MAAM,CAAS;IACf,YAAY,CAAuB;IAC5C,MAAM,GAAQ,EAAE,CAAC;IACjB,QAAQ,GAAG,CAAC,CAAC;IAEb;;;OAGG;IACH,YAAY,KAAa,EAAE,cAAoC,GAAG,EAAE,CAAC,KAAK;QACxE,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC;QAC7C,IAAI,CAAC,YAAY,GAAG,WAAW,CAAC;IAClC,CAAC;IAED,kCAAkC;IAClC,IAAI,KAAK;QACP,OAAO,IAAI,CAAC,MAAM,CAAC;IACrB,CAAC;IAED,IAAI,IAAI;QACN,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC;IAC5B,CAAC;IAED,gDAAgD;IAChD,IAAI,OAAO;QACT,OAAO,IAAI,CAAC,QAAQ,CAAC;IACvB,CAAC;IAED,GAAG,CAAC,IAAO;QACT,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC;YACrC,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACvB,OAAO;QACT,CAAC;QACD,IAAI,CAAC,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC;YAC7B,IAAI,CAAC,QAAQ,EAAE,CAAC;YAChB,OAAO;QACT,CAAC;QACD,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC,CAAC;QACjF,IAAI,MAAM,KAAK,CAAC,CAAC,EAAE,CAAC;YAClB,wEAAwE;YACxE,oDAAoD;YACpD,IAAI,CAAC,QAAQ,EAAE,CAAC;YAChB,OAAO;QACT,CAAC;QACD,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QAC9B,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACvB,IAAI,CAAC,QAAQ,EAAE,CAAC;IAClB,CAAC;IAED;;;;;;;;OAQG;IACH,KAAK;QACH,IAAI,CAAC,MAAM,GAAG,EAAE,CAAC;QACjB,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAC;IACpB,CAAC;IAED,iEAAiE;IACjE,OAAO;QACL,OAAO,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC;IAC1B,CAAC;CACF","sourcesContent":["/**\n * A fixed-size log that gives priority to interesting entries.\n *\n * The context layers watch things a page can produce without limit — console\n * output, network requests — so every collector needs a cap. A plain cap has\n * the wrong failure mode: a page that logs in a render loop fills the budget\n * with noise in the first second, and the console error thrown at second four\n * (the one thing worth capturing) is dropped.\n *\n * So the log keeps `limit` entries, and once it is full an *important* entry\n * still gets in by evicting the oldest unimportant one. Important entries are\n * only ever dropped when the log holds nothing but important entries — at\n * which point the page is genuinely producing more signal than the cap allows.\n * `dropped` counts everything that did not make it, so the caller can say so.\n */\nexport class BoundedLog<T> {\n readonly #limit: number;\n readonly #isImportant: (item: T) => boolean;\n #items: T[] = [];\n #dropped = 0;\n\n /**\n * @param limit Maximum entries kept. Values below 1 are treated as 1.\n * @param isImportant Entries worth evicting an ordinary entry for. Defaults to \"nothing is\".\n */\n constructor(limit: number, isImportant: (item: T) => boolean = () => false) {\n this.#limit = Math.max(1, Math.floor(limit));\n this.#isImportant = isImportant;\n }\n\n /** Entries kept, oldest first. */\n get items(): readonly T[] {\n return this.#items;\n }\n\n get size(): number {\n return this.#items.length;\n }\n\n /** How many entries were refused or evicted. */\n get dropped(): number {\n return this.#dropped;\n }\n\n add(item: T): void {\n if (this.#items.length < this.#limit) {\n this.#items.push(item);\n return;\n }\n if (!this.#isImportant(item)) {\n this.#dropped++;\n return;\n }\n const victim = this.#items.findIndex((existing) => !this.#isImportant(existing));\n if (victim === -1) {\n // Nothing but important entries left; the newest is the one we lose, so\n // the record of how the trouble *started* survives.\n this.#dropped++;\n return;\n }\n this.#items.splice(victim, 1);\n this.#items.push(item);\n this.#dropped++;\n }\n\n /**\n * Forget everything, including the dropped count.\n *\n * A capture builds a log and throws it away, but `framewatch_interact` keeps\n * one page — and so one set of collectors — alive across many calls, and\n * each call reports only what its own action caused. Without this, call\n * twenty would repeat the console output of calls one to nineteen and then\n * start dropping the entries that actually mattered.\n */\n clear(): void {\n this.#items = [];\n this.#dropped = 0;\n }\n\n /** A plain copy of the entries (safe to hand out and mutate). */\n toArray(): T[] {\n return [...this.#items];\n }\n}\n"]}
@@ -0,0 +1,56 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import type { DiffCard } from "../types.js";
3
+ /** What became of a replayed interaction script. */
4
+ export interface InteractionReport {
5
+ /** Steps in the script. */
6
+ total: number;
7
+ /** Steps that ran successfully. */
8
+ completed: number;
9
+ /** One-line description of each completed step, in order. */
10
+ steps: string[];
11
+ /** Message from the step that failed, if any (already a single line). */
12
+ error?: string;
13
+ /** 1-based position of the failing step. */
14
+ failed_index?: number;
15
+ }
16
+ /** Everything the formatter needs to describe one capture session. */
17
+ export interface CaptureSummary {
18
+ cards: DiffCard[];
19
+ total_frames: number;
20
+ duration_ms: number;
21
+ /** URL that was requested. */
22
+ url: string;
23
+ /** URL the page ended on (after redirects / in-page navigation). */
24
+ final_url?: string;
25
+ title?: string;
26
+ /** Frames dropped by the recorder (screenshot failures). */
27
+ dropped?: number;
28
+ /** Present only when an interaction script was replayed. */
29
+ interactions?: InteractionReport;
30
+ /**
31
+ * Remarks about the capture itself rather than about any one frame — a
32
+ * context layer that hit its cap, requests still in flight when the
33
+ * recording ended. One line each, after the summary.
34
+ */
35
+ notes?: string[];
36
+ }
37
+ /**
38
+ * Build the MCP CallToolResult for a capture, following the "MCP Response
39
+ * Format" in CLAUDE.md: one summary text block, then per card an image block,
40
+ * a metadata text block and (when present) the change-region crop image.
41
+ */
42
+ export declare function formatDiffCards(summary: CaptureSummary): CallToolResult;
43
+ /**
44
+ * How the interaction script went: how many steps ran, what they were, and —
45
+ * when one failed — which one and why. A failed step is a finding about the
46
+ * page, not a tool failure, so it is reported here alongside the frames rather
47
+ * than replacing them with an error.
48
+ */
49
+ export declare function formatInteractionLine(report: InteractionReport): string;
50
+ /**
51
+ * The metadata text block for one card (exported for tests and for reuse by
52
+ * the interact tool). Line 1 is always `Frame N @ Tms [trigger]`; the
53
+ * optional sections follow in a fixed order — Changed, Console, Network,
54
+ * Performance, DOM — and are omitted entirely when their data is absent.
55
+ */
56
+ export declare function formatCardMeta(card: DiffCard): string;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Build the MCP CallToolResult for a capture, following the "MCP Response
3
+ * Format" in CLAUDE.md: one summary text block, then per card an image block,
4
+ * a metadata text block and (when present) the change-region crop image.
5
+ */
6
+ export function formatDiffCards(summary) {
7
+ const lines = [formatSummaryLine(summary)];
8
+ if (summary.interactions) {
9
+ lines.push(formatInteractionLine(summary.interactions));
10
+ }
11
+ for (const note of summary.notes ?? []) {
12
+ lines.push(note);
13
+ }
14
+ const content = [{ type: "text", text: lines.join("\n") }];
15
+ for (const card of summary.cards) {
16
+ content.push({ type: "image", data: card.full_frame, mimeType: "image/png" });
17
+ content.push({ type: "text", text: formatCardMeta(card) });
18
+ if (card.change_region?.crop) {
19
+ content.push({ type: "image", data: card.change_region.crop, mimeType: "image/png" });
20
+ }
21
+ }
22
+ return { content };
23
+ }
24
+ /**
25
+ * One-line description of the whole capture. Always names the requested url;
26
+ * appends the final url only when the page ended somewhere else, the page
27
+ * title when known, and the dropped-frame count only when frames were lost.
28
+ */
29
+ function formatSummaryLine(summary) {
30
+ const { cards, total_frames, duration_ms, url, final_url, title, dropped } = summary;
31
+ const recording = `from ${total_frames} raw frames (${duration_ms}ms recording) of ${url}`;
32
+ let text = cards.length === 0 ? `No frames captured ${recording}` : `Captured ${cards.length} meaningful frames ${recording}`;
33
+ if (final_url !== undefined && !isSameUrl(url, final_url)) {
34
+ text += ` → ${final_url}`;
35
+ }
36
+ if (title) {
37
+ text += ` — "${title}"`;
38
+ }
39
+ if (dropped !== undefined && dropped > 0) {
40
+ text += ` (${dropped} frames dropped)`;
41
+ }
42
+ return text;
43
+ }
44
+ /**
45
+ * How the interaction script went: how many steps ran, what they were, and —
46
+ * when one failed — which one and why. A failed step is a finding about the
47
+ * page, not a tool failure, so it is reported here alongside the frames rather
48
+ * than replacing them with an error.
49
+ */
50
+ export function formatInteractionLine(report) {
51
+ let text = `Interactions: ${report.completed}/${report.total} replayed`;
52
+ if (report.steps.length > 0) {
53
+ text += ` — ${report.steps.join(", ")}`;
54
+ }
55
+ if (report.error !== undefined) {
56
+ text += `. Step ${report.failed_index ?? report.completed + 1}: ${report.error}`;
57
+ }
58
+ return text;
59
+ }
60
+ /**
61
+ * Compare two URLs as URLs, not as strings: `page.url()` returns the
62
+ * WHATWG-normalised form, so a request for `http://localhost:3000` comes back
63
+ * as `http://localhost:3000/` without anything having navigated.
64
+ */
65
+ function isSameUrl(a, b) {
66
+ if (a === b)
67
+ return true;
68
+ try {
69
+ return new URL(a).href === new URL(b).href;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ /**
76
+ * The metadata text block for one card (exported for tests and for reuse by
77
+ * the interact tool). Line 1 is always `Frame N @ Tms [trigger]`; the
78
+ * optional sections follow in a fixed order — Changed, Console, Network,
79
+ * Performance, DOM — and are omitted entirely when their data is absent.
80
+ */
81
+ export function formatCardMeta(card) {
82
+ const lines = [`Frame ${card.index} @ ${card.timestamp_ms}ms [${card.trigger}]`];
83
+ const region = card.change_region;
84
+ if (region) {
85
+ if (region.change_percent === 0) {
86
+ lines.push("Changed: 0.0% — no visual change since previous frame");
87
+ }
88
+ else {
89
+ const { bbox } = region;
90
+ let changed = `Changed: ${region.change_percent.toFixed(1)}% — region: ${bbox.x},${bbox.y} ${bbox.width}x${bbox.height}`;
91
+ if (!region.crop) {
92
+ changed += " (full-frame change, see frame image)";
93
+ }
94
+ lines.push(changed);
95
+ }
96
+ }
97
+ if (card.console_entries?.length) {
98
+ lines.push("Console:");
99
+ for (const entry of card.console_entries) {
100
+ lines.push(` [${entry.level}] ${entry.text}`);
101
+ }
102
+ }
103
+ if (card.network_events?.length) {
104
+ lines.push("Network:");
105
+ for (const event of card.network_events) {
106
+ // A request that never got a response has no status to print; what
107
+ // stopped it (or that it is still running) is the useful part.
108
+ const outcome = event.status > 0 ? String(event.status) : (event.error ?? "no response");
109
+ const why = event.status > 0 && event.error !== undefined ? ` ${event.error}` : "";
110
+ lines.push(` ${event.method} ${event.url} → ${outcome}${why} (${event.duration_ms}ms)`);
111
+ }
112
+ }
113
+ const perf = card.performance;
114
+ if (perf) {
115
+ lines.push("Performance:");
116
+ if (perf.paint_time_ms !== undefined)
117
+ lines.push(` paint ${perf.paint_time_ms}ms`);
118
+ if (perf.layout_shifts !== undefined) {
119
+ const score = perf.layout_shift_score !== undefined ? ` (score ${perf.layout_shift_score})` : "";
120
+ lines.push(` layout shifts ${perf.layout_shifts}${score}`);
121
+ }
122
+ if (perf.lcp_ms !== undefined)
123
+ lines.push(` lcp ${perf.lcp_ms}ms`);
124
+ }
125
+ if (card.dom_snapshot) {
126
+ lines.push(`DOM:\n${card.dom_snapshot}`);
127
+ }
128
+ return lines.join("\n");
129
+ }
130
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.js","sourceRoot":"","sources":["../../src/utils/format.ts"],"names":[],"mappings":"AAuCA;;;;GAIG;AACH,MAAM,UAAU,eAAe,CAAC,OAAuB;IACrD,MAAM,KAAK,GAAG,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC,CAAC;IAC3C,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC;QACzB,KAAK,CAAC,IAAI,CAAC,qBAAqB,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC,CAAC;IAC1D,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,KAAK,IAAI,EAAE,EAAE,CAAC;QACvC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACnB,CAAC;IACD,MAAM,OAAO,GAA8B,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACtF,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;QACjC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,UAAU,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC,CAAC;QAC9E,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,cAAc,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QAC3D,IAAI,IAAI,CAAC,aAAa,EAAE,IAAI,EAAE,CAAC;YAC7B,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,aAAa,CAAC,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,CAAC,CAAC;QACxF,CAAC;IACH,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,CAAC;AACrB,CAAC;AAED;;;;GAIG;AACH,SAAS,iBAAiB,CAAC,OAAuB;IAChD,MAAM,EAAE,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IACrF,MAAM,SAAS,GAAG,QAAQ,YAAY,gBAAgB,WAAW,oBAAoB,GAAG,EAAE,CAAC;IAC3F,IAAI,IAAI,GAAG,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,sBAAsB,SAAS,EAAE,CAAC,CAAC,CAAC,YAAY,KAAK,CAAC,MAAM,sBAAsB,SAAS,EAAE,CAAC;IAC9H,IAAI,SAAS,KAAK,SAAS,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,SAAS,CAAC,EAAE,CAAC;QAC1D,IAAI,IAAI,MAAM,SAAS,EAAE,CAAC;IAC5B,CAAC;IACD,IAAI,KAAK,EAAE,CAAC;QACV,IAAI,IAAI,OAAO,KAAK,GAAG,CAAC;IAC1B,CAAC;IACD,IAAI,OAAO,KAAK,SAAS,IAAI,OAAO,GAAG,CAAC,EAAE,CAAC;QACzC,IAAI,IAAI,KAAK,OAAO,kBAAkB,CAAC;IACzC,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CAAC,MAAyB;IAC7D,IAAI,IAAI,GAAG,iBAAiB,MAAM,CAAC,SAAS,IAAI,MAAM,CAAC,KAAK,WAAW,CAAC;IACxE,IAAI,MAAM,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC5B,IAAI,IAAI,MAAM,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;IAC1C,CAAC;IACD,IAAI,MAAM,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;QAC/B,IAAI,IAAI,UAAU,MAAM,CAAC,YAAY,IAAI,MAAM,CAAC,SAAS,GAAG,CAAC,KAAK,MAAM,CAAC,KAAK,EAAE,CAAC;IACnF,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;GAIG;AACH,SAAS,SAAS,CAAC,CAAS,EAAE,CAAS;IACrC,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACzB,IAAI,CAAC;QACH,OAAO,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC7C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,IAAc;IAC3C,MAAM,KAAK,GAAa,CAAC,SAAS,IAAI,CAAC,KAAK,MAAM,IAAI,CAAC,YAAY,OAAO,IAAI,CAAC,OAAO,GAAG,CAAC,CAAC;IAE3F,MAAM,MAAM,GAAG,IAAI,CAAC,aAAa,CAAC;IAClC,IAAI,MAAM,EAAE,CAAC;QACX,IAAI,MAAM,CAAC,cAAc,KAAK,CAAC,EAAE,CAAC;YAChC,KAAK,CAAC,IAAI,CAAC,uDAAuD,CAAC,CAAC;QACtE,CAAC;aAAM,CAAC;YACN,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,CAAC;YACxB,IAAI,OAAO,GAAG,YAAY,MAAM,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,IAAI,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC;YACzH,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;gBACjB,OAAO,IAAI,uCAAuC,CAAC;YACrD,CAAC;YACD,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACtB,CAAC;IACH,CAAC;IAED,IAAI,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,CAAC;QACjC,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACvB,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,eAAe,EAAE,CAAC;YACzC,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,KAAK,KAAK,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;QACjD,CAAC;IACH,CAAC;IAED,IAAI,IAAI,CAAC,cAAc,EAAE,MAAM,EAAE,CAAC;QAChC,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACvB,KAAK,MAAM,KAAK,IAAI,IAAI,CAAC,cAAc,EAAE,CAAC;YACxC,mEAAmE;YACnE,+DAA+D;YAC/D,MAAM,OAAO,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,IAAI,aAAa,CAAC,CAAC;YACzF,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACnF,KAAK,CAAC,IAAI,CAAC,KAAK,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,GAAG,MAAM,OAAO,GAAG,GAAG,KAAK,KAAK,CAAC,WAAW,KAAK,CAAC,CAAC;QAC3F,CAAC;IACH,CAAC;IAED,MAAM,IAAI,GAAG,IAAI,CAAC,WAAW,CAAC;IAC9B,IAAI,IAAI,EAAE,CAAC;QACT,KAAK,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAC3B,IAAI,IAAI,CAAC,aAAa,KAAK,SAAS;YAAE,KAAK,CAAC,IAAI,CAAC,WAAW,IAAI,CAAC,aAAa,IAAI,CAAC,CAAC;QACpF,IAAI,IAAI,CAAC,aAAa,KAAK,SAAS,EAAE,CAAC;YACrC,MAAM,KAAK,GAAG,IAAI,CAAC,kBAAkB,KAAK,SAAS,CAAC,CAAC,CAAC,WAAW,IAAI,CAAC,kBAAkB,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;YACjG,KAAK,CAAC,IAAI,CAAC,mBAAmB,IAAI,CAAC,aAAa,GAAG,KAAK,EAAE,CAAC,CAAC;QAC9D,CAAC;QACD,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS;YAAE,KAAK,CAAC,IAAI,CAAC,SAAS,IAAI,CAAC,MAAM,IAAI,CAAC,CAAC;IACtE,CAAC;IAED,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;QACtB,KAAK,CAAC,IAAI,CAAC,SAAS,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC;IAC3C,CAAC;IAED,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC1B,CAAC","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"]}
@@ -0,0 +1,44 @@
1
+ import type { BoundingBox } from "../types.js";
2
+ /**
3
+ * Resize a PNG for delivery to the MCP client: fit within `maxWidth`,
4
+ * never enlarge, keep aspect ratio, re-encode as PNG.
5
+ *
6
+ * `quality` puts sharp into palette mode (libimagequant), which is what keeps
7
+ * screenshots small — dropping it quadruples the payload on noisy frames. Its
8
+ * default effort (7) is far too slow for a capture, though: a single noisy
9
+ * 1280x720 frame costs ~600ms and one capture encodes up to 60 images.
10
+ * `effort: 1` is ~6x faster for ~15% more bytes.
11
+ */
12
+ export declare function resizeForOutput(png: Buffer, maxWidth?: number): Promise<Buffer>;
13
+ /** Read the pixel dimensions of an encoded image buffer. */
14
+ export declare function getDimensions(image: Buffer): Promise<{
15
+ width: number;
16
+ height: number;
17
+ }>;
18
+ /** Base64-encode a binary buffer (MCP image content blocks expect base64 data). */
19
+ export declare function toBase64(buffer: Buffer): string;
20
+ /**
21
+ * Resize + grayscale a PNG to DIFF_WIDTH x DIFF_HEIGHT (fit: "fill") and
22
+ * return raw 1-channel pixels (length DIFF_WIDTH * DIFF_HEIGHT). This is the
23
+ * low-res representation the smart diff engine compares frames with.
24
+ */
25
+ export declare function toDiffBuffer(png: Buffer): Promise<Buffer>;
26
+ /** Full-resolution grayscale raw pixels (1 channel) plus dimensions. */
27
+ export declare function toGrayscale(png: Buffer): Promise<{
28
+ data: Buffer;
29
+ width: number;
30
+ height: number;
31
+ }>;
32
+ /** Crop a PNG to `bbox` (must already be clamped to the image) and re-encode as PNG. */
33
+ export declare function cropRegion(png: Buffer, bbox: BoundingBox): Promise<Buffer>;
34
+ /**
35
+ * Paint `mask` over `png` in OVERLAY_COLOUR and return the result as a PNG.
36
+ *
37
+ * This is the compare tool's diff overlay: the second page as it really looks,
38
+ * with every pixel that differs from the first tinted, so a reviewer can see
39
+ * *where* the two differ instead of hunting for it. The tint is translucent
40
+ * (OVERLAY_ALPHA) so what changed stays readable underneath it.
41
+ *
42
+ * `mask` must be one byte per pixel, row-major, matching `width` x `height`.
43
+ */
44
+ export declare function overlayMask(png: Buffer, mask: Uint8Array, width: number, height: number): Promise<Buffer>;
@@ -0,0 +1,81 @@
1
+ import sharp from "sharp";
2
+ import { DIFF_HEIGHT, DIFF_WIDTH, OUTPUT_MAX_WIDTH, OVERLAY_ALPHA, OVERLAY_COLOUR } from "../constants.js";
3
+ /**
4
+ * Resize a PNG for delivery to the MCP client: fit within `maxWidth`,
5
+ * never enlarge, keep aspect ratio, re-encode as PNG.
6
+ *
7
+ * `quality` puts sharp into palette mode (libimagequant), which is what keeps
8
+ * screenshots small — dropping it quadruples the payload on noisy frames. Its
9
+ * default effort (7) is far too slow for a capture, though: a single noisy
10
+ * 1280x720 frame costs ~600ms and one capture encodes up to 60 images.
11
+ * `effort: 1` is ~6x faster for ~15% more bytes.
12
+ */
13
+ export async function resizeForOutput(png, maxWidth = OUTPUT_MAX_WIDTH) {
14
+ return sharp(png)
15
+ .resize(maxWidth, null, { fit: "inside", withoutEnlargement: true })
16
+ .png({ quality: 80, effort: 1, compressionLevel: 6 })
17
+ .toBuffer();
18
+ }
19
+ /** Read the pixel dimensions of an encoded image buffer. */
20
+ export async function getDimensions(image) {
21
+ const { width, height } = await sharp(image).metadata();
22
+ if (width === undefined || height === undefined) {
23
+ throw new Error("Could not read image dimensions");
24
+ }
25
+ return { width, height };
26
+ }
27
+ /** Base64-encode a binary buffer (MCP image content blocks expect base64 data). */
28
+ export function toBase64(buffer) {
29
+ return buffer.toString("base64");
30
+ }
31
+ /**
32
+ * Resize + grayscale a PNG to DIFF_WIDTH x DIFF_HEIGHT (fit: "fill") and
33
+ * return raw 1-channel pixels (length DIFF_WIDTH * DIFF_HEIGHT). This is the
34
+ * low-res representation the smart diff engine compares frames with.
35
+ */
36
+ export async function toDiffBuffer(png) {
37
+ return sharp(png).resize(DIFF_WIDTH, DIFF_HEIGHT, { fit: "fill" }).grayscale().raw().toBuffer();
38
+ }
39
+ /** Full-resolution grayscale raw pixels (1 channel) plus dimensions. */
40
+ export async function toGrayscale(png) {
41
+ const { data, info } = await sharp(png).grayscale().raw().toBuffer({ resolveWithObject: true });
42
+ return { data, width: info.width, height: info.height };
43
+ }
44
+ /** Crop a PNG to `bbox` (must already be clamped to the image) and re-encode as PNG. */
45
+ export async function cropRegion(png, bbox) {
46
+ return sharp(png)
47
+ .extract({ left: bbox.x, top: bbox.y, width: bbox.width, height: bbox.height })
48
+ .png()
49
+ .toBuffer();
50
+ }
51
+ /**
52
+ * Paint `mask` over `png` in OVERLAY_COLOUR and return the result as a PNG.
53
+ *
54
+ * This is the compare tool's diff overlay: the second page as it really looks,
55
+ * with every pixel that differs from the first tinted, so a reviewer can see
56
+ * *where* the two differ instead of hunting for it. The tint is translucent
57
+ * (OVERLAY_ALPHA) so what changed stays readable underneath it.
58
+ *
59
+ * `mask` must be one byte per pixel, row-major, matching `width` x `height`.
60
+ */
61
+ export async function overlayMask(png, mask, width, height) {
62
+ const pixels = width * height;
63
+ if (mask.length !== pixels) {
64
+ throw new Error(`overlayMask: mask length mismatch — expected ${pixels} (${width}x${height}), got ${mask.length}`);
65
+ }
66
+ const rgba = Buffer.alloc(pixels * 4);
67
+ for (let i = 0; i < pixels; i++) {
68
+ if (mask[i] === 0)
69
+ continue;
70
+ const at = i * 4;
71
+ rgba[at] = OVERLAY_COLOUR.r;
72
+ rgba[at + 1] = OVERLAY_COLOUR.g;
73
+ rgba[at + 2] = OVERLAY_COLOUR.b;
74
+ rgba[at + 3] = OVERLAY_ALPHA;
75
+ }
76
+ return sharp(png)
77
+ .composite([{ input: rgba, raw: { width, height, channels: 4 }, blend: "over" }])
78
+ .png()
79
+ .toBuffer();
80
+ }
81
+ //# sourceMappingURL=image.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image.js","sourceRoot":"","sources":["../../src/utils/image.ts"],"names":[],"mappings":"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","sourcesContent":["import sharp from \"sharp\";\nimport { DIFF_HEIGHT, DIFF_WIDTH, OUTPUT_MAX_WIDTH, OVERLAY_ALPHA, OVERLAY_COLOUR } from \"../constants.js\";\nimport type { BoundingBox } from \"../types.js\";\n\n/**\n * Resize a PNG for delivery to the MCP client: fit within `maxWidth`,\n * never enlarge, keep aspect ratio, re-encode as PNG.\n *\n * `quality` puts sharp into palette mode (libimagequant), which is what keeps\n * screenshots small — dropping it quadruples the payload on noisy frames. Its\n * default effort (7) is far too slow for a capture, though: a single noisy\n * 1280x720 frame costs ~600ms and one capture encodes up to 60 images.\n * `effort: 1` is ~6x faster for ~15% more bytes.\n */\nexport async function resizeForOutput(png: Buffer, maxWidth: number = OUTPUT_MAX_WIDTH): Promise<Buffer> {\n return sharp(png)\n .resize(maxWidth, null, { fit: \"inside\", withoutEnlargement: true })\n .png({ quality: 80, effort: 1, compressionLevel: 6 })\n .toBuffer();\n}\n\n/** Read the pixel dimensions of an encoded image buffer. */\nexport async function getDimensions(image: Buffer): Promise<{ width: number; height: number }> {\n const { width, height } = await sharp(image).metadata();\n if (width === undefined || height === undefined) {\n throw new Error(\"Could not read image dimensions\");\n }\n return { width, height };\n}\n\n/** Base64-encode a binary buffer (MCP image content blocks expect base64 data). */\nexport function toBase64(buffer: Buffer): string {\n return buffer.toString(\"base64\");\n}\n\n/**\n * Resize + grayscale a PNG to DIFF_WIDTH x DIFF_HEIGHT (fit: \"fill\") and\n * return raw 1-channel pixels (length DIFF_WIDTH * DIFF_HEIGHT). This is the\n * low-res representation the smart diff engine compares frames with.\n */\nexport async function toDiffBuffer(png: Buffer): Promise<Buffer> {\n return sharp(png).resize(DIFF_WIDTH, DIFF_HEIGHT, { fit: \"fill\" }).grayscale().raw().toBuffer();\n}\n\n/** Full-resolution grayscale raw pixels (1 channel) plus dimensions. */\nexport async function toGrayscale(png: Buffer): Promise<{ data: Buffer; width: number; height: number }> {\n const { data, info } = await sharp(png).grayscale().raw().toBuffer({ resolveWithObject: true });\n return { data, width: info.width, height: info.height };\n}\n\n/** Crop a PNG to `bbox` (must already be clamped to the image) and re-encode as PNG. */\nexport async function cropRegion(png: Buffer, bbox: BoundingBox): Promise<Buffer> {\n return sharp(png)\n .extract({ left: bbox.x, top: bbox.y, width: bbox.width, height: bbox.height })\n .png()\n .toBuffer();\n}\n\n/**\n * Paint `mask` over `png` in OVERLAY_COLOUR and return the result as a PNG.\n *\n * This is the compare tool's diff overlay: the second page as it really looks,\n * with every pixel that differs from the first tinted, so a reviewer can see\n * *where* the two differ instead of hunting for it. The tint is translucent\n * (OVERLAY_ALPHA) so what changed stays readable underneath it.\n *\n * `mask` must be one byte per pixel, row-major, matching `width` x `height`.\n */\nexport async function overlayMask(png: Buffer, mask: Uint8Array, width: number, height: number): Promise<Buffer> {\n const pixels = width * height;\n if (mask.length !== pixels) {\n throw new Error(`overlayMask: mask length mismatch — expected ${pixels} (${width}x${height}), got ${mask.length}`);\n }\n\n const rgba = Buffer.alloc(pixels * 4);\n for (let i = 0; i < pixels; i++) {\n if (mask[i] === 0) continue;\n const at = i * 4;\n rgba[at] = OVERLAY_COLOUR.r;\n rgba[at + 1] = OVERLAY_COLOUR.g;\n rgba[at + 2] = OVERLAY_COLOUR.b;\n rgba[at + 3] = OVERLAY_ALPHA;\n }\n\n return sharp(png)\n .composite([{ input: rgba, raw: { width, height, channels: 4 }, blend: \"over\" }])\n .png()\n .toBuffer();\n}\n"]}
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Dev server process manager.
3
+ *
4
+ * FrameWatch can start the app it is about to look at. One server runs at a
5
+ * time — the tools that use it take no server argument, so a second would have
6
+ * no way of being addressed — and it is owned for as long as the MCP server
7
+ * lives, then stopped on shutdown. Nothing here is allowed to outlive the
8
+ * process that spawned it.
9
+ *
10
+ * **Readiness is the port, not the log line.** `ready_pattern` is matched and
11
+ * reported (dev servers print the URL they actually bound to, which is worth
12
+ * repeating back), but what makes a server "running" is that something answers
13
+ * on its port: that is the condition the next tool call depends on, and a
14
+ * pattern that fires early — or a regex that never matches a server that is
15
+ * working perfectly — would report the wrong thing in both directions.
16
+ */
17
+ /** A server that is up, with everything worth telling the caller about it. */
18
+ export interface RunningServer {
19
+ command: string;
20
+ port: number;
21
+ pid: number;
22
+ cwd: string;
23
+ url: string;
24
+ /** How long the port took to answer. */
25
+ ready_ms: number;
26
+ /** The output line that matched `ready_pattern`, if one did. */
27
+ ready_line?: string;
28
+ }
29
+ /** What a stopped server left behind. */
30
+ export interface StoppedServer {
31
+ command: string;
32
+ port: number;
33
+ pid: number;
34
+ uptime_ms: number;
35
+ /** How it went: exit code, or the signal that ended it. */
36
+ exit_code: number | null;
37
+ exit_signal: string | null;
38
+ /** True when SIGTERM was ignored and the process had to be killed outright. */
39
+ forced: boolean;
40
+ }
41
+ export interface StartDevServerOptions {
42
+ command: string;
43
+ port: number;
44
+ ready_pattern: string;
45
+ cwd?: string;
46
+ env?: Record<string, string>;
47
+ timeout_ms: number;
48
+ }
49
+ /** A start that failed, carrying the output that explains why. */
50
+ export declare class DevServerError extends Error {
51
+ readonly output: string[];
52
+ constructor(message: string, output?: string[]);
53
+ }
54
+ /** The running server, or null. A process that has since died counts as null. */
55
+ export declare function getDevServer(): RunningServer | null;
56
+ /** The last `count` output lines of the running server, oldest first. */
57
+ export declare function devServerOutput(count: number): string[];
58
+ /**
59
+ * Spawn a dev server and wait until its port answers.
60
+ *
61
+ * @throws DevServerError when a server is already running, when the port is
62
+ * already taken by something else, when the process exits before the port
63
+ * opens, or when it never opens at all. Each of those carries the output the
64
+ * server produced, because that is where the reason actually is.
65
+ */
66
+ export declare function startDevServer(options: StartDevServerOptions): Promise<RunningServer>;
67
+ /**
68
+ * Stop the running server, or return null if there is none.
69
+ *
70
+ * SIGTERM to the whole process group first — dev servers use it to clean up
71
+ * their own children and their sockets — then SIGKILL if it is still there
72
+ * after SERVER_STOP_GRACE_MS.
73
+ */
74
+ export declare function stopDevServer(): Promise<StoppedServer | null>;
75
+ /** Stop the server on the way out. Never throws — shutdown must not be blocked by it. */
76
+ export declare function shutdownDevServer(): Promise<void>;
77
+ /**
78
+ * True when something accepts a TCP connection on `port`.
79
+ *
80
+ * Both loopback addresses are tried: a server bound only to `::1` (Node's
81
+ * default when the host resolves to IPv6 first) does not answer on 127.0.0.1,
82
+ * and reporting that as "never started" would be wrong.
83
+ */
84
+ export declare function isPortOpen(port: number, timeoutMs?: number): Promise<boolean>;