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,282 @@
1
+ import { MAX_DOM_LINES_PER_CARD, MAX_DOM_RECORDS } from "../../constants.js";
2
+ import { installProbe, probeConfig } from "./probe.js";
3
+ /**
4
+ * DOM layer.
5
+ *
6
+ * A frame tells you *that* something changed; this tells you *what*. A
7
+ * MutationObserver installed at document start records every structural
8
+ * change, and the records that fall between two diff cards are grouped into a
9
+ * few lines — "a div.modal appeared inside #app", "#app's style attribute
10
+ * changed 24 times" — which is usually enough to name the element behind a
11
+ * visual change without reading the app's source.
12
+ *
13
+ * Deliberately not recorded:
14
+ * - anything inside `<head>`. Stylesheet and meta churn is constant in modern
15
+ * apps (every CSS-in-JS library injects rules continuously) and its effect
16
+ * is already visible in the frames.
17
+ * - script/link/meta/title/template elements, comments and doctypes: not
18
+ * visual.
19
+ * - whitespace-only text nodes: markup indentation, not content.
20
+ * Subframes are skipped too — the probe only runs in the main frame, so an ad
21
+ * iframe cannot drown out the page under test.
22
+ */
23
+ const BINDING = "__framewatch_dom";
24
+ /**
25
+ * The page-side probe.
26
+ *
27
+ * Written against `globalThis` rather than the DOM globals because this file
28
+ * is compiled with the Node lib only — and because everything it touches has
29
+ * to be defensive anyway: it runs at document start in someone else's app,
30
+ * where any of it may be missing, patched or about to be torn down.
31
+ */
32
+ const DOM_PROBE = (config) => {
33
+ const g = globalThis;
34
+ // Main frame only.
35
+ if (g.top && g.top !== g)
36
+ return;
37
+ // The probe can be asked for twice on one document (installed on a page that
38
+ // is already open, then re-run by the init script); a second MutationObserver
39
+ // would report every mutation twice.
40
+ const installed = config.binding + "_observing";
41
+ if (g[installed])
42
+ return;
43
+ g[installed] = true;
44
+ const send = g[config.binding];
45
+ const doc = g.document;
46
+ if (typeof send !== "function" || !doc || typeof g.MutationObserver !== "function")
47
+ return;
48
+ const SKIP_TAGS = ["script", "link", "meta", "title", "base", "noscript", "template", "head"];
49
+ const MAX_DESC = 48;
50
+ let queue = [];
51
+ let scheduled = false;
52
+ let budget = config.max_records;
53
+ const flush = () => {
54
+ scheduled = false;
55
+ if (queue.length === 0)
56
+ return;
57
+ const batch = queue;
58
+ queue = [];
59
+ try {
60
+ const result = send(batch);
61
+ if (result && typeof result.catch === "function")
62
+ result.catch(() => { });
63
+ }
64
+ catch {
65
+ // The page is being torn down; there is nowhere left to push to.
66
+ }
67
+ };
68
+ const push = (record) => {
69
+ if (budget <= 0)
70
+ return;
71
+ budget--;
72
+ if (queue.length >= config.max_batch)
73
+ flush();
74
+ queue.push(record);
75
+ if (!scheduled) {
76
+ scheduled = true;
77
+ g.setTimeout(flush, config.flush_ms);
78
+ }
79
+ };
80
+ try {
81
+ g.addEventListener("pagehide", flush, true);
82
+ }
83
+ catch {
84
+ // Not fatal: records wait for the timer instead.
85
+ }
86
+ const desc = (node) => {
87
+ if (!node)
88
+ return "?";
89
+ const type = node.nodeType;
90
+ if (type === 9)
91
+ return "#document";
92
+ if (type === 11)
93
+ return "#fragment";
94
+ if (type === 3)
95
+ return "#text";
96
+ if (type !== 1)
97
+ return "#node";
98
+ let out = String(node.tagName || "?").toLowerCase();
99
+ if (node.id)
100
+ out += "#" + String(node.id);
101
+ else if (node.classList && node.classList.length)
102
+ out += "." + String(node.classList[0]);
103
+ return out.length > MAX_DESC ? out.slice(0, MAX_DESC) + "…" : out;
104
+ };
105
+ /** Head content is noise (see the file comment); so is anything inside it. */
106
+ const inHead = (node) => {
107
+ const head = doc.head;
108
+ if (!head || !node)
109
+ return false;
110
+ if (node === head)
111
+ return true;
112
+ try {
113
+ return head.contains(node) === true;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ };
119
+ /** Nodes whose appearance or removal says nothing about what the page looks like. */
120
+ const ignored = (node) => {
121
+ if (!node)
122
+ return true;
123
+ const type = node.nodeType;
124
+ if (type === 3)
125
+ return String(node.nodeValue || "").trim() === "";
126
+ if (type !== 1)
127
+ return true;
128
+ return SKIP_TAGS.indexOf(String(node.tagName || "").toLowerCase()) !== -1;
129
+ };
130
+ /**
131
+ * Containers whose *contents* are not worth reporting either. Without this,
132
+ * parsing an inline <script> reports the arrival of its source text as a
133
+ * change to the page.
134
+ */
135
+ const ignoredContainer = (node) => {
136
+ if (!node || node.nodeType !== 1)
137
+ return false;
138
+ return SKIP_TAGS.indexOf(String(node.tagName || "").toLowerCase()) !== -1;
139
+ };
140
+ const record = (op, target, extra) => {
141
+ push({ t: Date.now(), op, target, ...extra });
142
+ };
143
+ /** A text node changing is really its parent element's text changing. */
144
+ const noteChildren = (nodes, op, parent) => {
145
+ const count = nodes ? nodes.length : 0;
146
+ for (let i = 0; i < count; i++) {
147
+ const node = nodes[i];
148
+ if (ignored(node))
149
+ continue;
150
+ if (node.nodeType === 3)
151
+ record("t", desc(parent));
152
+ else
153
+ record(op, desc(node), { parent: desc(parent) });
154
+ }
155
+ };
156
+ try {
157
+ new g.MutationObserver((records) => {
158
+ for (const mutation of records) {
159
+ const target = mutation.target;
160
+ if (inHead(target) || ignoredContainer(target))
161
+ continue;
162
+ if (mutation.type === "childList") {
163
+ noteChildren(mutation.addedNodes, "+", target);
164
+ noteChildren(mutation.removedNodes, "-", target);
165
+ }
166
+ else if (mutation.type === "attributes") {
167
+ record("~", desc(target), { detail: String(mutation.attributeName || "") });
168
+ }
169
+ else {
170
+ // characterData: the text of the node's parent element changed.
171
+ // Whitespace edits are the parser reflowing indentation, not content.
172
+ const parent = target ? target.parentNode : null;
173
+ if (String(target && target.nodeValue ? target.nodeValue : "").trim() === "")
174
+ continue;
175
+ if (ignoredContainer(parent) || inHead(parent))
176
+ continue;
177
+ record("t", desc(parent));
178
+ }
179
+ }
180
+ }).observe(doc, { subtree: true, childList: true, attributes: true, characterData: true });
181
+ }
182
+ catch {
183
+ // No observer, no DOM layer. The capture is still worth having.
184
+ }
185
+ };
186
+ export class DomCollector {
187
+ #page;
188
+ #limit;
189
+ #records = [];
190
+ #dropped = 0;
191
+ constructor(page, limit = MAX_DOM_RECORDS) {
192
+ this.#page = page;
193
+ this.#limit = Math.max(1, limit);
194
+ }
195
+ /**
196
+ * Install the probe. Call before the page navigates, or pass
197
+ * `{ runNow: true }` to also watch the document that is already loaded.
198
+ */
199
+ async attach(options = {}) {
200
+ const config = probeConfig(BINDING);
201
+ await installProbe(this.#page, config, DOM_PROBE, (batch) => this.#ingest(batch), options);
202
+ return this;
203
+ }
204
+ /** Mutations the cap refused. */
205
+ get dropped() {
206
+ return this.#dropped;
207
+ }
208
+ /** Forget everything collected so far. See `BoundedLog.clear`. */
209
+ clear() {
210
+ this.#records.length = 0;
211
+ this.#dropped = 0;
212
+ }
213
+ /**
214
+ * Collected mutations in time order, rebased onto the recording clock
215
+ * (`origin` is the recording's start in epoch ms). Mutations from before the
216
+ * recording started keep their negative timestamp — they are the page
217
+ * building itself, and belong on the first card.
218
+ */
219
+ records(origin) {
220
+ return this.#records.map((record) => ({
221
+ timestamp_ms: Math.round(record.t - origin),
222
+ op: record.op,
223
+ target: record.target,
224
+ ...(record.parent !== undefined ? { parent: record.parent } : {}),
225
+ ...(record.detail !== undefined ? { detail: record.detail } : {}),
226
+ }));
227
+ }
228
+ #ingest(batch) {
229
+ for (const record of batch) {
230
+ // The page pushes this, so nothing about it is trusted.
231
+ if (!record || typeof record.t !== "number" || typeof record.target !== "string")
232
+ continue;
233
+ if (this.#records.length >= this.#limit) {
234
+ this.#dropped++;
235
+ continue;
236
+ }
237
+ this.#records.push(record);
238
+ }
239
+ }
240
+ }
241
+ /**
242
+ * Turn one card's mutations into a few readable lines.
243
+ *
244
+ * Identical mutations are collapsed with a count, in first-seen order: an
245
+ * animation driven by an inline style produces one `~ #logo [style] ×24` line
246
+ * rather than 24 identical ones, and the order still tells the story of what
247
+ * happened first. Returns undefined when there is nothing to say.
248
+ */
249
+ export function renderDomChanges(records, maxLines = MAX_DOM_LINES_PER_CARD) {
250
+ if (records.length === 0)
251
+ return undefined;
252
+ const groups = new Map();
253
+ for (const record of records) {
254
+ const line = describeRecord(record);
255
+ const existing = groups.get(line);
256
+ if (existing)
257
+ existing.count++;
258
+ else
259
+ groups.set(line, { line, count: 1 });
260
+ }
261
+ const all = [...groups.values()];
262
+ const shown = all.slice(0, Math.max(1, maxLines));
263
+ const lines = shown.map((group) => (group.count > 1 ? ` ${group.line} ×${group.count}` : ` ${group.line}`));
264
+ if (all.length > shown.length) {
265
+ const hidden = all.slice(shown.length).reduce((sum, group) => sum + group.count, 0);
266
+ lines.push(` … and ${hidden} more change${hidden === 1 ? "" : "s"} across ${all.length - shown.length} elements`);
267
+ }
268
+ return lines.join("\n");
269
+ }
270
+ function describeRecord(record) {
271
+ switch (record.op) {
272
+ case "+":
273
+ return record.parent ? `+ ${record.target} in ${record.parent}` : `+ ${record.target}`;
274
+ case "-":
275
+ return record.parent ? `- ${record.target} from ${record.parent}` : `- ${record.target}`;
276
+ case "~":
277
+ return `~ ${record.target} [${record.detail ?? "?"}]`;
278
+ default:
279
+ return `~ text in ${record.target}`;
280
+ }
281
+ }
282
+ //# sourceMappingURL=dom.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dom.js","sourceRoot":"","sources":["../../../src/engine/layers/dom.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Page } from \"playwright\";\nimport { MAX_DOM_LINES_PER_CARD, MAX_DOM_RECORDS } from \"../../constants.js\";\nimport { installProbe, probeConfig, type InstallOptions, type ProbeConfig } from \"./probe.js\";\n\n/**\n * DOM layer.\n *\n * A frame tells you *that* something changed; this tells you *what*. A\n * MutationObserver installed at document start records every structural\n * change, and the records that fall between two diff cards are grouped into a\n * few lines — \"a div.modal appeared inside #app\", \"#app's style attribute\n * changed 24 times\" — which is usually enough to name the element behind a\n * visual change without reading the app's source.\n *\n * Deliberately not recorded:\n * - anything inside `<head>`. Stylesheet and meta churn is constant in modern\n * apps (every CSS-in-JS library injects rules continuously) and its effect\n * is already visible in the frames.\n * - script/link/meta/title/template elements, comments and doctypes: not\n * visual.\n * - whitespace-only text nodes: markup indentation, not content.\n * Subframes are skipped too — the probe only runs in the main frame, so an ad\n * iframe cannot drown out the page under test.\n */\n\nconst BINDING = \"__framewatch_dom\";\n\n/** What happened to a node. */\nexport type DomOp = \"+\" | \"-\" | \"~\" | \"t\";\n\n/** One mutation, as pushed by the page. Timestamps are absolute (epoch ms). */\nexport interface RawDomRecord {\n /** Absolute time the mutation was observed. */\n t: number;\n op: DomOp;\n /** Short descriptor of the node — `div#id`, `span.class`, `p`. */\n target: string;\n /** Descriptor of the parent, for adds and removes. */\n parent?: string;\n /** Attribute name, for `~`. */\n detail?: string;\n}\n\n/** One mutation, rebased onto the recording clock. */\nexport interface DomRecord {\n timestamp_ms: number;\n op: DomOp;\n target: string;\n parent?: string;\n detail?: string;\n}\n\n/**\n * The page-side probe.\n *\n * Written against `globalThis` rather than the DOM globals because this file\n * is compiled with the Node lib only — and because everything it touches has\n * to be defensive anyway: it runs at document start in someone else's app,\n * where any of it may be missing, patched or about to be torn down.\n */\nconst DOM_PROBE = (config: ProbeConfig): void => {\n const g = globalThis as any;\n // Main frame only.\n if (g.top && g.top !== g) return;\n // The probe can be asked for twice on one document (installed on a page that\n // is already open, then re-run by the init script); a second MutationObserver\n // would report every mutation twice.\n const installed = config.binding + \"_observing\";\n if (g[installed]) return;\n g[installed] = true;\n const send = g[config.binding];\n const doc = g.document;\n if (typeof send !== \"function\" || !doc || typeof g.MutationObserver !== \"function\") return;\n\n const SKIP_TAGS = [\"script\", \"link\", \"meta\", \"title\", \"base\", \"noscript\", \"template\", \"head\"];\n const MAX_DESC = 48;\n\n let queue: unknown[] = [];\n let scheduled = false;\n let budget = config.max_records;\n\n const flush = (): void => {\n scheduled = false;\n if (queue.length === 0) return;\n const batch = queue;\n queue = [];\n try {\n const result = send(batch);\n if (result && typeof result.catch === \"function\") result.catch(() => {});\n } catch {\n // The page is being torn down; there is nowhere left to push to.\n }\n };\n\n const push = (record: unknown): void => {\n if (budget <= 0) return;\n budget--;\n if (queue.length >= config.max_batch) flush();\n queue.push(record);\n if (!scheduled) {\n scheduled = true;\n g.setTimeout(flush, config.flush_ms);\n }\n };\n\n try {\n g.addEventListener(\"pagehide\", flush, true);\n } catch {\n // Not fatal: records wait for the timer instead.\n }\n\n const desc = (node: any): string => {\n if (!node) return \"?\";\n const type = node.nodeType;\n if (type === 9) return \"#document\";\n if (type === 11) return \"#fragment\";\n if (type === 3) return \"#text\";\n if (type !== 1) return \"#node\";\n let out = String(node.tagName || \"?\").toLowerCase();\n if (node.id) out += \"#\" + String(node.id);\n else if (node.classList && node.classList.length) out += \".\" + String(node.classList[0]);\n return out.length > MAX_DESC ? out.slice(0, MAX_DESC) + \"…\" : out;\n };\n\n /** Head content is noise (see the file comment); so is anything inside it. */\n const inHead = (node: any): boolean => {\n const head = doc.head;\n if (!head || !node) return false;\n if (node === head) return true;\n try {\n return head.contains(node) === true;\n } catch {\n return false;\n }\n };\n\n /** Nodes whose appearance or removal says nothing about what the page looks like. */\n const ignored = (node: any): boolean => {\n if (!node) return true;\n const type = node.nodeType;\n if (type === 3) return String(node.nodeValue || \"\").trim() === \"\";\n if (type !== 1) return true;\n return SKIP_TAGS.indexOf(String(node.tagName || \"\").toLowerCase()) !== -1;\n };\n\n /**\n * Containers whose *contents* are not worth reporting either. Without this,\n * parsing an inline <script> reports the arrival of its source text as a\n * change to the page.\n */\n const ignoredContainer = (node: any): boolean => {\n if (!node || node.nodeType !== 1) return false;\n return SKIP_TAGS.indexOf(String(node.tagName || \"\").toLowerCase()) !== -1;\n };\n\n const record = (op: string, target: string, extra?: Record<string, unknown>): void => {\n push({ t: Date.now(), op, target, ...extra });\n };\n\n /** A text node changing is really its parent element's text changing. */\n const noteChildren = (nodes: any, op: string, parent: any): void => {\n const count = nodes ? nodes.length : 0;\n for (let i = 0; i < count; i++) {\n const node = nodes[i];\n if (ignored(node)) continue;\n if (node.nodeType === 3) record(\"t\", desc(parent));\n else record(op, desc(node), { parent: desc(parent) });\n }\n };\n\n try {\n new g.MutationObserver((records: any[]) => {\n for (const mutation of records) {\n const target = mutation.target;\n if (inHead(target) || ignoredContainer(target)) continue;\n if (mutation.type === \"childList\") {\n noteChildren(mutation.addedNodes, \"+\", target);\n noteChildren(mutation.removedNodes, \"-\", target);\n } else if (mutation.type === \"attributes\") {\n record(\"~\", desc(target), { detail: String(mutation.attributeName || \"\") });\n } else {\n // characterData: the text of the node's parent element changed.\n // Whitespace edits are the parser reflowing indentation, not content.\n const parent = target ? target.parentNode : null;\n if (String(target && target.nodeValue ? target.nodeValue : \"\").trim() === \"\") continue;\n if (ignoredContainer(parent) || inHead(parent)) continue;\n record(\"t\", desc(parent));\n }\n }\n }).observe(doc, { subtree: true, childList: true, attributes: true, characterData: true });\n } catch {\n // No observer, no DOM layer. The capture is still worth having.\n }\n};\n\nexport class DomCollector {\n readonly #page: Page;\n readonly #limit: number;\n readonly #records: RawDomRecord[] = [];\n #dropped = 0;\n\n constructor(page: Page, limit: number = MAX_DOM_RECORDS) {\n this.#page = page;\n this.#limit = Math.max(1, limit);\n }\n\n /**\n * Install the probe. Call before the page navigates, or pass\n * `{ runNow: true }` to also watch the document that is already loaded.\n */\n async attach(options: InstallOptions = {}): Promise<this> {\n const config = probeConfig(BINDING);\n await installProbe<RawDomRecord>(this.#page, config, DOM_PROBE, (batch) => this.#ingest(batch), options);\n return this;\n }\n\n /** Mutations the cap refused. */\n get dropped(): number {\n return this.#dropped;\n }\n\n /** Forget everything collected so far. See `BoundedLog.clear`. */\n clear(): void {\n this.#records.length = 0;\n this.#dropped = 0;\n }\n\n /**\n * Collected mutations in time order, rebased onto the recording clock\n * (`origin` is the recording's start in epoch ms). Mutations from before the\n * recording started keep their negative timestamp — they are the page\n * building itself, and belong on the first card.\n */\n records(origin: number): DomRecord[] {\n return this.#records.map((record) => ({\n timestamp_ms: Math.round(record.t - origin),\n op: record.op,\n target: record.target,\n ...(record.parent !== undefined ? { parent: record.parent } : {}),\n ...(record.detail !== undefined ? { detail: record.detail } : {}),\n }));\n }\n\n #ingest(batch: RawDomRecord[]): void {\n for (const record of batch) {\n // The page pushes this, so nothing about it is trusted.\n if (!record || typeof record.t !== \"number\" || typeof record.target !== \"string\") continue;\n if (this.#records.length >= this.#limit) {\n this.#dropped++;\n continue;\n }\n this.#records.push(record);\n }\n }\n}\n\n/**\n * Turn one card's mutations into a few readable lines.\n *\n * Identical mutations are collapsed with a count, in first-seen order: an\n * animation driven by an inline style produces one `~ #logo [style] ×24` line\n * rather than 24 identical ones, and the order still tells the story of what\n * happened first. Returns undefined when there is nothing to say.\n */\nexport function renderDomChanges(records: DomRecord[], maxLines: number = MAX_DOM_LINES_PER_CARD): string | undefined {\n if (records.length === 0) return undefined;\n\n const groups = new Map<string, { line: string; count: number }>();\n for (const record of records) {\n const line = describeRecord(record);\n const existing = groups.get(line);\n if (existing) existing.count++;\n else groups.set(line, { line, count: 1 });\n }\n\n const all = [...groups.values()];\n const shown = all.slice(0, Math.max(1, maxLines));\n const lines = shown.map((group) => (group.count > 1 ? ` ${group.line} ×${group.count}` : ` ${group.line}`));\n if (all.length > shown.length) {\n const hidden = all.slice(shown.length).reduce((sum, group) => sum + group.count, 0);\n lines.push(` … and ${hidden} more change${hidden === 1 ? \"\" : \"s\"} across ${all.length - shown.length} elements`);\n }\n return lines.join(\"\\n\");\n}\n\nfunction describeRecord(record: DomRecord): string {\n switch (record.op) {\n case \"+\":\n return record.parent ? `+ ${record.target} in ${record.parent}` : `+ ${record.target}`;\n case \"-\":\n return record.parent ? `- ${record.target} from ${record.parent}` : `- ${record.target}`;\n case \"~\":\n return `~ ${record.target} [${record.detail ?? \"?\"}]`;\n default:\n return `~ text in ${record.target}`;\n }\n}\n"]}
@@ -0,0 +1,95 @@
1
+ import type { Page } from "playwright";
2
+ import type { ConsoleEntry, DiffCard, NetworkEvent } from "../../types.js";
3
+ import { type DomRecord } from "./dom.js";
4
+ import { type PerfSample } from "./performance.js";
5
+ export { ConsoleCollector, describePageError, normaliseText, toConsoleLevel } from "./console.js";
6
+ export { SessionLayers, layersFor } from "./session.js";
7
+ export { NetworkCollector, shortenUrl } from "./network.js";
8
+ export { DomCollector, renderDomChanges } from "./dom.js";
9
+ export { PerformanceCollector, summarisePerformance } from "./performance.js";
10
+ export type { ConsoleRecord } from "./console.js";
11
+ export type { NetworkRecord } from "./network.js";
12
+ export type { DomOp, DomRecord, RawDomRecord } from "./dom.js";
13
+ export type { PerfKind, PerfSample, RawPerfRecord } from "./performance.js";
14
+ /**
15
+ * Context layers.
16
+ *
17
+ * Four collectors watch a page while it is recorded — what it logged, what it
18
+ * fetched, what it changed in the DOM, and when it painted — and their output
19
+ * is then split across the diff cards by time, so each card carries the
20
+ * context for the interval that produced it.
21
+ *
22
+ * All four are attached before the page navigates, because the most valuable
23
+ * things they see (a script that throws on load, the request that never comes
24
+ * back, first paint) happen before frame 0 exists. Records from that window
25
+ * carry negative timestamps and land on the first card.
26
+ *
27
+ * Nothing here can fail a capture. A layer that cannot be installed is
28
+ * reported as a note and the recording goes ahead without it: frames are the
29
+ * point, and context is the bonus.
30
+ */
31
+ /** Which layers to collect. Mirrors the `include_*` inputs of framewatch_capture. */
32
+ export interface LayerFlags {
33
+ console: boolean;
34
+ network: boolean;
35
+ dom: boolean;
36
+ performance: boolean;
37
+ }
38
+ /** Everything the layers gathered, rebased onto the recording clock. */
39
+ export interface CapturedContext {
40
+ console?: ConsoleEntry[];
41
+ network?: NetworkEvent[];
42
+ dom?: DomRecord[];
43
+ performance?: PerfSample[];
44
+ /** Anything the user should know about the collection itself (caps hit, layers unavailable). */
45
+ notes: string[];
46
+ }
47
+ /** Layers attached to a live page, waiting to be drained. */
48
+ export interface AttachedLayers {
49
+ /**
50
+ * Take everything collected so far and rebase it onto the recording clock.
51
+ * `origin` is the recording's start in epoch ms. Purely synchronous and
52
+ * never touches the page, so it still works after the page has frozen,
53
+ * navigated away, crashed or been closed.
54
+ */
55
+ collect(origin: number): CapturedContext;
56
+ /** Stop listening. Safe to call more than once. */
57
+ detach(): void;
58
+ }
59
+ /**
60
+ * Attach the requested layers to `page`. Call before navigating.
61
+ *
62
+ * The two injected layers (DOM, performance) can genuinely fail to install —
63
+ * a page can be closed underneath us, and `exposeBinding` refuses a name that
64
+ * is already taken — so each is attached independently and a failure becomes a
65
+ * note rather than an exception.
66
+ */
67
+ export declare function attachLayers(page: Page, flags: LayerFlags): Promise<AttachedLayers>;
68
+ /**
69
+ * Hang the collected context off the cards it belongs to.
70
+ *
71
+ * Each card owns the half-open interval ending at its own timestamp: card N
72
+ * gets everything after card N-1 and up to and including card N — "since the
73
+ * last frame", which is what the context is for. The first card also absorbs
74
+ * everything from before the recording started (page load), and the last card
75
+ * absorbs anything that arrived after the final frame, so nothing collected is
76
+ * silently thrown away.
77
+ *
78
+ * Mutates `cards` in place.
79
+ */
80
+ export declare function applyContext(cards: DiffCard[], context: CapturedContext): void;
81
+ /**
82
+ * Lines about the collection itself, for a tool's summary.
83
+ *
84
+ * The first is a tally of every layer that ran, including the ones that saw
85
+ * nothing: silence after `include_network: true` is otherwise ambiguous — it
86
+ * could mean the page made no requests or that the layer never installed — and
87
+ * that difference is exactly what someone reading the result needs to know.
88
+ * Anything a layer had to drop follows on its own line.
89
+ *
90
+ * `cardCount` is how many cards the context could be hung off. Zero means the
91
+ * page produced no frames at all, and that is precisely when the console is
92
+ * worth reading: a page that died before its first screenshot usually said
93
+ * why, so its errors are carried up into the summary.
94
+ */
95
+ export declare function summariseContext(context: CapturedContext, cardCount: number): string[] | undefined;
@@ -0,0 +1,184 @@
1
+ import { MAX_DOM_LINES_PER_CARD } from "../../constants.js";
2
+ import { ConsoleCollector } from "./console.js";
3
+ import { DomCollector, renderDomChanges } from "./dom.js";
4
+ import { NetworkCollector } from "./network.js";
5
+ import { PerformanceCollector, summarisePerformance } from "./performance.js";
6
+ export { ConsoleCollector, describePageError, normaliseText, toConsoleLevel } from "./console.js";
7
+ export { SessionLayers, layersFor } from "./session.js";
8
+ export { NetworkCollector, shortenUrl } from "./network.js";
9
+ export { DomCollector, renderDomChanges } from "./dom.js";
10
+ export { PerformanceCollector, summarisePerformance } from "./performance.js";
11
+ const EMPTY = { collect: () => ({ notes: [] }), detach: () => { } };
12
+ /**
13
+ * Attach the requested layers to `page`. Call before navigating.
14
+ *
15
+ * The two injected layers (DOM, performance) can genuinely fail to install —
16
+ * a page can be closed underneath us, and `exposeBinding` refuses a name that
17
+ * is already taken — so each is attached independently and a failure becomes a
18
+ * note rather than an exception.
19
+ */
20
+ export async function attachLayers(page, flags) {
21
+ if (!flags.console && !flags.network && !flags.dom && !flags.performance)
22
+ return EMPTY;
23
+ const notes = [];
24
+ const consoleCollector = flags.console ? new ConsoleCollector(page).attach() : null;
25
+ const networkCollector = flags.network ? new NetworkCollector(page).attach() : null;
26
+ const domCollector = flags.dom ? await attachOrNote(new DomCollector(page), "DOM", notes) : null;
27
+ const perfCollector = flags.performance ? await attachOrNote(new PerformanceCollector(page), "performance", notes) : null;
28
+ return {
29
+ collect(origin) {
30
+ const context = { notes: [...notes] };
31
+ if (consoleCollector) {
32
+ context.console = consoleCollector.entries(origin);
33
+ if (consoleCollector.dropped > 0) {
34
+ context.notes.push(`Console output was capped — ${consoleCollector.dropped} entries dropped (errors kept first).`);
35
+ }
36
+ }
37
+ if (networkCollector) {
38
+ // Requests still in flight are events in their own right (see
39
+ // `NetworkCollector.events`), so the caller can count them from the
40
+ // list; there is no separate note for them.
41
+ context.network = networkCollector.events(origin);
42
+ if (networkCollector.dropped > 0) {
43
+ context.notes.push(`Network log was capped — ${networkCollector.dropped} events dropped (failures kept first).`);
44
+ }
45
+ }
46
+ if (domCollector) {
47
+ context.dom = domCollector.records(origin);
48
+ if (domCollector.dropped > 0) {
49
+ context.notes.push(`DOM log was capped — ${domCollector.dropped} mutations dropped.`);
50
+ }
51
+ }
52
+ if (perfCollector) {
53
+ context.performance = perfCollector.samples(origin);
54
+ if (perfCollector.dropped > 0) {
55
+ context.notes.push(`Performance log was capped — ${perfCollector.dropped} entries dropped.`);
56
+ }
57
+ }
58
+ return context;
59
+ },
60
+ detach() {
61
+ consoleCollector?.detach();
62
+ networkCollector?.detach();
63
+ },
64
+ };
65
+ }
66
+ async function attachOrNote(collector, name, notes) {
67
+ try {
68
+ return await collector.attach();
69
+ }
70
+ catch (error) {
71
+ const reason = error instanceof Error ? error.message.split("\n")[0] : String(error);
72
+ notes.push(`The ${name} layer could not be installed: ${reason}`);
73
+ return null;
74
+ }
75
+ }
76
+ /**
77
+ * Hang the collected context off the cards it belongs to.
78
+ *
79
+ * Each card owns the half-open interval ending at its own timestamp: card N
80
+ * gets everything after card N-1 and up to and including card N — "since the
81
+ * last frame", which is what the context is for. The first card also absorbs
82
+ * everything from before the recording started (page load), and the last card
83
+ * absorbs anything that arrived after the final frame, so nothing collected is
84
+ * silently thrown away.
85
+ *
86
+ * Mutates `cards` in place.
87
+ */
88
+ export function applyContext(cards, context) {
89
+ if (cards.length === 0)
90
+ return;
91
+ if (context.console) {
92
+ assign(cards, context.console, (card, entries) => {
93
+ if (entries.length > 0)
94
+ card.console_entries = entries;
95
+ });
96
+ }
97
+ if (context.network) {
98
+ assign(cards, context.network, (card, events) => {
99
+ if (events.length > 0)
100
+ card.network_events = events;
101
+ });
102
+ }
103
+ if (context.dom) {
104
+ assign(cards, context.dom, (card, records) => {
105
+ const snapshot = renderDomChanges(records, MAX_DOM_LINES_PER_CARD);
106
+ if (snapshot !== undefined)
107
+ card.dom_snapshot = snapshot;
108
+ });
109
+ }
110
+ if (context.performance) {
111
+ assign(cards, context.performance, (card, samples) => {
112
+ const info = summarisePerformance(samples);
113
+ if (info !== undefined)
114
+ card.performance = info;
115
+ });
116
+ }
117
+ }
118
+ /** Bucket `items` by card (see `applyContext`) and hand each card its own. */
119
+ function assign(cards, items, attach) {
120
+ const buckets = cards.map(() => []);
121
+ const last = cards.length - 1;
122
+ for (const item of items) {
123
+ // Items are already in time order, but a card boundary search is cheap and
124
+ // does not depend on that holding for every layer.
125
+ let index = cards.findIndex((card) => item.timestamp_ms <= card.timestamp_ms);
126
+ if (index === -1)
127
+ index = last;
128
+ buckets[index].push(item);
129
+ }
130
+ for (let i = 0; i < cards.length; i++) {
131
+ attach(cards[i], buckets[i]);
132
+ }
133
+ }
134
+ /**
135
+ * Lines about the collection itself, for a tool's summary.
136
+ *
137
+ * The first is a tally of every layer that ran, including the ones that saw
138
+ * nothing: silence after `include_network: true` is otherwise ambiguous — it
139
+ * could mean the page made no requests or that the layer never installed — and
140
+ * that difference is exactly what someone reading the result needs to know.
141
+ * Anything a layer had to drop follows on its own line.
142
+ *
143
+ * `cardCount` is how many cards the context could be hung off. Zero means the
144
+ * page produced no frames at all, and that is precisely when the console is
145
+ * worth reading: a page that died before its first screenshot usually said
146
+ * why, so its errors are carried up into the summary.
147
+ */
148
+ export function summariseContext(context, cardCount) {
149
+ const parts = [];
150
+ if (context.console) {
151
+ parts.push(context.console.length > 0 ? `console: ${count(context.console.length, "entry", "entries")}` : "console: silent");
152
+ }
153
+ if (context.network) {
154
+ const pending = context.network.filter((event) => event.error === "pending").length;
155
+ const settled = context.network.length - pending;
156
+ const text = settled > 0 ? `network: ${count(settled, "request", "requests")}` : "network: no requests";
157
+ parts.push(pending > 0 ? `${text} (${pending} still pending)` : text);
158
+ }
159
+ if (context.dom) {
160
+ parts.push(context.dom.length > 0 ? `DOM: ${count(context.dom.length, "mutation", "mutations")}` : "DOM: no mutations");
161
+ }
162
+ if (context.performance) {
163
+ parts.push(context.performance.length > 0
164
+ ? `performance: ${count(context.performance.length, "entry", "entries")}`
165
+ : "performance: nothing measured");
166
+ }
167
+ const notes = parts.length > 0 ? [`Context — ${parts.join("; ")}`, ...context.notes] : [...context.notes];
168
+ if (cardCount === 0) {
169
+ const errors = (context.console ?? []).filter((entry) => entry.level === "error");
170
+ for (const entry of errors.slice(0, MAX_ORPHANED_ERRORS)) {
171
+ notes.push(` [error] ${entry.text}`);
172
+ }
173
+ if (errors.length > MAX_ORPHANED_ERRORS) {
174
+ notes.push(` … and ${errors.length - MAX_ORPHANED_ERRORS} more errors`);
175
+ }
176
+ }
177
+ return notes.length > 0 ? notes : undefined;
178
+ }
179
+ /** Console errors reported in the summary when a capture produced no frames at all. */
180
+ const MAX_ORPHANED_ERRORS = 5;
181
+ function count(n, singular, plural) {
182
+ return `${n} ${n === 1 ? singular : plural}`;
183
+ }
184
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/engine/layers/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Page } from \"playwright\";\nimport { MAX_DOM_LINES_PER_CARD } from \"../../constants.js\";\nimport type { ConsoleEntry, DiffCard, NetworkEvent } from \"../../types.js\";\nimport { ConsoleCollector } from \"./console.js\";\nimport { DomCollector, renderDomChanges, type DomRecord } from \"./dom.js\";\nimport { NetworkCollector } from \"./network.js\";\nimport { PerformanceCollector, summarisePerformance, type PerfSample } from \"./performance.js\";\n\nexport { ConsoleCollector, describePageError, normaliseText, toConsoleLevel } from \"./console.js\";\nexport { SessionLayers, layersFor } from \"./session.js\";\nexport { NetworkCollector, shortenUrl } from \"./network.js\";\nexport { DomCollector, renderDomChanges } from \"./dom.js\";\nexport { PerformanceCollector, summarisePerformance } from \"./performance.js\";\nexport type { ConsoleRecord } from \"./console.js\";\nexport type { NetworkRecord } from \"./network.js\";\nexport type { DomOp, DomRecord, RawDomRecord } from \"./dom.js\";\nexport type { PerfKind, PerfSample, RawPerfRecord } from \"./performance.js\";\n\n/**\n * Context layers.\n *\n * Four collectors watch a page while it is recorded — what it logged, what it\n * fetched, what it changed in the DOM, and when it painted — and their output\n * is then split across the diff cards by time, so each card carries the\n * context for the interval that produced it.\n *\n * All four are attached before the page navigates, because the most valuable\n * things they see (a script that throws on load, the request that never comes\n * back, first paint) happen before frame 0 exists. Records from that window\n * carry negative timestamps and land on the first card.\n *\n * Nothing here can fail a capture. A layer that cannot be installed is\n * reported as a note and the recording goes ahead without it: frames are the\n * point, and context is the bonus.\n */\n\n/** Which layers to collect. Mirrors the `include_*` inputs of framewatch_capture. */\nexport interface LayerFlags {\n console: boolean;\n network: boolean;\n dom: boolean;\n performance: boolean;\n}\n\n/** Everything the layers gathered, rebased onto the recording clock. */\nexport interface CapturedContext {\n console?: ConsoleEntry[];\n network?: NetworkEvent[];\n dom?: DomRecord[];\n performance?: PerfSample[];\n /** Anything the user should know about the collection itself (caps hit, layers unavailable). */\n notes: string[];\n}\n\n/** Layers attached to a live page, waiting to be drained. */\nexport interface AttachedLayers {\n /**\n * Take everything collected so far and rebase it onto the recording clock.\n * `origin` is the recording's start in epoch ms. Purely synchronous and\n * never touches the page, so it still works after the page has frozen,\n * navigated away, crashed or been closed.\n */\n collect(origin: number): CapturedContext;\n /** Stop listening. Safe to call more than once. */\n detach(): void;\n}\n\nconst EMPTY: AttachedLayers = { collect: () => ({ notes: [] }), detach: () => {} };\n\n/**\n * Attach the requested layers to `page`. Call before navigating.\n *\n * The two injected layers (DOM, performance) can genuinely fail to install —\n * a page can be closed underneath us, and `exposeBinding` refuses a name that\n * is already taken — so each is attached independently and a failure becomes a\n * note rather than an exception.\n */\nexport async function attachLayers(page: Page, flags: LayerFlags): Promise<AttachedLayers> {\n if (!flags.console && !flags.network && !flags.dom && !flags.performance) return EMPTY;\n\n const notes: string[] = [];\n const consoleCollector = flags.console ? new ConsoleCollector(page).attach() : null;\n const networkCollector = flags.network ? new NetworkCollector(page).attach() : null;\n\n const domCollector = flags.dom ? await attachOrNote(new DomCollector(page), \"DOM\", notes) : null;\n const perfCollector = flags.performance ? await attachOrNote(new PerformanceCollector(page), \"performance\", notes) : null;\n\n return {\n collect(origin: number): CapturedContext {\n const context: CapturedContext = { notes: [...notes] };\n\n if (consoleCollector) {\n context.console = consoleCollector.entries(origin);\n if (consoleCollector.dropped > 0) {\n context.notes.push(`Console output was capped — ${consoleCollector.dropped} entries dropped (errors kept first).`);\n }\n }\n if (networkCollector) {\n // Requests still in flight are events in their own right (see\n // `NetworkCollector.events`), so the caller can count them from the\n // list; there is no separate note for them.\n context.network = networkCollector.events(origin);\n if (networkCollector.dropped > 0) {\n context.notes.push(`Network log was capped — ${networkCollector.dropped} events dropped (failures kept first).`);\n }\n }\n if (domCollector) {\n context.dom = domCollector.records(origin);\n if (domCollector.dropped > 0) {\n context.notes.push(`DOM log was capped — ${domCollector.dropped} mutations dropped.`);\n }\n }\n if (perfCollector) {\n context.performance = perfCollector.samples(origin);\n if (perfCollector.dropped > 0) {\n context.notes.push(`Performance log was capped — ${perfCollector.dropped} entries dropped.`);\n }\n }\n return context;\n },\n detach(): void {\n consoleCollector?.detach();\n networkCollector?.detach();\n },\n };\n}\n\nasync function attachOrNote<T extends { attach(): Promise<T> }>(collector: T, name: string, notes: string[]): Promise<T | null> {\n try {\n return await collector.attach();\n } catch (error) {\n const reason = error instanceof Error ? error.message.split(\"\\n\")[0] : String(error);\n notes.push(`The ${name} layer could not be installed: ${reason}`);\n return null;\n }\n}\n\n/**\n * Hang the collected context off the cards it belongs to.\n *\n * Each card owns the half-open interval ending at its own timestamp: card N\n * gets everything after card N-1 and up to and including card N — \"since the\n * last frame\", which is what the context is for. The first card also absorbs\n * everything from before the recording started (page load), and the last card\n * absorbs anything that arrived after the final frame, so nothing collected is\n * silently thrown away.\n *\n * Mutates `cards` in place.\n */\nexport function applyContext(cards: DiffCard[], context: CapturedContext): void {\n if (cards.length === 0) return;\n\n if (context.console) {\n assign(cards, context.console, (card, entries) => {\n if (entries.length > 0) card.console_entries = entries;\n });\n }\n if (context.network) {\n assign(cards, context.network, (card, events) => {\n if (events.length > 0) card.network_events = events;\n });\n }\n if (context.dom) {\n assign(cards, context.dom, (card, records) => {\n const snapshot = renderDomChanges(records, MAX_DOM_LINES_PER_CARD);\n if (snapshot !== undefined) card.dom_snapshot = snapshot;\n });\n }\n if (context.performance) {\n assign(cards, context.performance, (card, samples) => {\n const info = summarisePerformance(samples);\n if (info !== undefined) card.performance = info;\n });\n }\n}\n\n/** Bucket `items` by card (see `applyContext`) and hand each card its own. */\nfunction assign<T extends { timestamp_ms: number }>(\n cards: DiffCard[],\n items: T[],\n attach: (card: DiffCard, items: T[]) => void,\n): void {\n const buckets: T[][] = cards.map(() => []);\n const last = cards.length - 1;\n\n for (const item of items) {\n // Items are already in time order, but a card boundary search is cheap and\n // does not depend on that holding for every layer.\n let index = cards.findIndex((card) => item.timestamp_ms <= card.timestamp_ms);\n if (index === -1) index = last;\n buckets[index].push(item);\n }\n\n for (let i = 0; i < cards.length; i++) {\n attach(cards[i], buckets[i]);\n }\n}\n\n/**\n * Lines about the collection itself, for a tool's summary.\n *\n * The first is a tally of every layer that ran, including the ones that saw\n * nothing: silence after `include_network: true` is otherwise ambiguous — it\n * could mean the page made no requests or that the layer never installed — and\n * that difference is exactly what someone reading the result needs to know.\n * Anything a layer had to drop follows on its own line.\n *\n * `cardCount` is how many cards the context could be hung off. Zero means the\n * page produced no frames at all, and that is precisely when the console is\n * worth reading: a page that died before its first screenshot usually said\n * why, so its errors are carried up into the summary.\n */\nexport function summariseContext(context: CapturedContext, cardCount: number): string[] | undefined {\n const parts: string[] = [];\n if (context.console) {\n parts.push(context.console.length > 0 ? `console: ${count(context.console.length, \"entry\", \"entries\")}` : \"console: silent\");\n }\n if (context.network) {\n const pending = context.network.filter((event) => event.error === \"pending\").length;\n const settled = context.network.length - pending;\n const text = settled > 0 ? `network: ${count(settled, \"request\", \"requests\")}` : \"network: no requests\";\n parts.push(pending > 0 ? `${text} (${pending} still pending)` : text);\n }\n if (context.dom) {\n parts.push(context.dom.length > 0 ? `DOM: ${count(context.dom.length, \"mutation\", \"mutations\")}` : \"DOM: no mutations\");\n }\n if (context.performance) {\n parts.push(\n context.performance.length > 0\n ? `performance: ${count(context.performance.length, \"entry\", \"entries\")}`\n : \"performance: nothing measured\",\n );\n }\n\n const notes = parts.length > 0 ? [`Context — ${parts.join(\"; \")}`, ...context.notes] : [...context.notes];\n\n if (cardCount === 0) {\n const errors = (context.console ?? []).filter((entry) => entry.level === \"error\");\n for (const entry of errors.slice(0, MAX_ORPHANED_ERRORS)) {\n notes.push(` [error] ${entry.text}`);\n }\n if (errors.length > MAX_ORPHANED_ERRORS) {\n notes.push(` … and ${errors.length - MAX_ORPHANED_ERRORS} more errors`);\n }\n }\n\n return notes.length > 0 ? notes : undefined;\n}\n\n/** Console errors reported in the summary when a capture produced no frames at all. */\nconst MAX_ORPHANED_ERRORS = 5;\n\nfunction count(n: number, singular: string, plural: string): string {\n return `${n} ${n === 1 ? singular : plural}`;\n}\n"]}