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.
- package/LICENSE +21 -0
- package/README.md +537 -0
- package/dist/constants.d.ts +172 -0
- package/dist/constants.js +168 -0
- package/dist/constants.js.map +1 -0
- package/dist/engine/browser.d.ts +56 -0
- package/dist/engine/browser.js +142 -0
- package/dist/engine/browser.js.map +1 -0
- package/dist/engine/differ.d.ts +88 -0
- package/dist/engine/differ.js +373 -0
- package/dist/engine/differ.js.map +1 -0
- package/dist/engine/interaction.d.ts +76 -0
- package/dist/engine/interaction.js +254 -0
- package/dist/engine/interaction.js.map +1 -0
- package/dist/engine/layers/console.d.ts +63 -0
- package/dist/engine/layers/console.js +118 -0
- package/dist/engine/layers/console.js.map +1 -0
- package/dist/engine/layers/dom.d.ts +53 -0
- package/dist/engine/layers/dom.js +282 -0
- package/dist/engine/layers/dom.js.map +1 -0
- package/dist/engine/layers/index.d.ts +95 -0
- package/dist/engine/layers/index.js +184 -0
- package/dist/engine/layers/index.js.map +1 -0
- package/dist/engine/layers/network.d.ts +62 -0
- package/dist/engine/layers/network.js +169 -0
- package/dist/engine/layers/network.js.map +1 -0
- package/dist/engine/layers/performance.d.ts +55 -0
- package/dist/engine/layers/performance.js +215 -0
- package/dist/engine/layers/performance.js.map +1 -0
- package/dist/engine/layers/probe.d.ts +50 -0
- package/dist/engine/layers/probe.js +39 -0
- package/dist/engine/layers/probe.js.map +1 -0
- package/dist/engine/layers/session.d.ts +46 -0
- package/dist/engine/layers/session.js +131 -0
- package/dist/engine/layers/session.js.map +1 -0
- package/dist/engine/recorder.d.ts +61 -0
- package/dist/engine/recorder.js +256 -0
- package/dist/engine/recorder.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +125 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/accessibility.d.ts +140 -0
- package/dist/tools/accessibility.js +357 -0
- package/dist/tools/accessibility.js.map +1 -0
- package/dist/tools/capture.d.ts +279 -0
- package/dist/tools/capture.js +275 -0
- package/dist/tools/capture.js.map +1 -0
- package/dist/tools/compare.d.ts +86 -0
- package/dist/tools/compare.js +247 -0
- package/dist/tools/compare.js.map +1 -0
- package/dist/tools/index.d.ts +10 -0
- package/dist/tools/index.js +25 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/interact.d.ts +160 -0
- package/dist/tools/interact.js +203 -0
- package/dist/tools/interact.js.map +1 -0
- package/dist/tools/responsive.d.ts +89 -0
- package/dist/tools/responsive.js +197 -0
- package/dist/tools/responsive.js.map +1 -0
- package/dist/tools/screenshot.d.ts +76 -0
- package/dist/tools/screenshot.js +117 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/server.d.ts +89 -0
- package/dist/tools/server.js +201 -0
- package/dist/tools/server.js.map +1 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/bounded-log.d.ts +41 -0
- package/dist/utils/bounded-log.js +78 -0
- package/dist/utils/bounded-log.js.map +1 -0
- package/dist/utils/format.d.ts +56 -0
- package/dist/utils/format.js +130 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/image.d.ts +44 -0
- package/dist/utils/image.js +81 -0
- package/dist/utils/image.js.map +1 -0
- package/dist/utils/server-process.d.ts +84 -0
- package/dist/utils/server-process.js +251 -0
- package/dist/utils/server-process.js.map +1 -0
- package/package.json +74 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"probe.js","sourceRoot":"","sources":["../../../src/engine/layers/probe.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,8BAA8B,EAAE,MAAM,oBAAoB,CAAC;AAkCrG,MAAM,UAAU,WAAW,CAAC,OAAe;IACzC,OAAO;QACL,OAAO;QACP,QAAQ,EAAE,cAAc;QACxB,SAAS,EAAE,eAAe;QAC1B,WAAW,EAAE,8BAA8B;KAC5C,CAAC;AACJ,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,IAAU,EACV,MAAmB,EACnB,MAAqC,EACrC,OAA+B,EAC/B,UAA0B,EAAE;IAE5B,MAAM,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,KAAc,EAAE,EAAE;QACnE,IAAI,CAAC;YACH,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;gBAAE,OAAO,CAAC,KAAY,CAAC,CAAC;QAClD,CAAC;QAAC,MAAM,CAAC;YACP,wDAAwD;QAC1D,CAAC;IACH,CAAC,CAAC,CAAC;IACH,MAAM,IAAI,CAAC,aAAa,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAEzC,IAAI,OAAO,CAAC,MAAM,KAAK,IAAI,EAAE,CAAC;QAC5B,0EAA0E;QAC1E,wEAAwE;QACxE,yEAAyE;QACzE,yEAAyE;QACzE,0CAA0C;QAC1C,MAAM,IAAI,CAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACtD,CAAC;AACH,CAAC","sourcesContent":["import type { Page } from \"playwright\";\nimport { LAYER_FLUSH_MS, MAX_LAYER_BATCH, MAX_LAYER_RECORDS_PER_DOCUMENT } from \"../../constants.js\";\n\n/**\n * In-page probe plumbing, shared by the DOM and performance layers.\n *\n * Console and network are Playwright events, but \"what changed in the DOM\" and\n * \"when did the browser paint\" only exist inside the page, so those two layers\n * inject an observer and push what it sees back to Node.\n *\n * The push is an `exposeBinding` function rather than one `page.evaluate` at\n * the end of the recording, for one reason: a navigation destroys the document\n * and everything buffered in it. Bindings and init scripts are reinstalled on\n * every new document, so a capture that navigates keeps the records from both\n * pages — and a capture whose page freezes or dies keeps whatever it pushed\n * before it went.\n *\n * Each probe inlines its own batching queue (page scripts cannot import, and\n * `eval`ing a shared one would break on any page with a strict CSP). Records\n * are stamped when they are *made*, never when their batch is flushed, so\n * batching cannot move a record onto the wrong diff card.\n */\n\n/** Config handed to the page-side script. Must stay JSON-serialisable. */\nexport interface ProbeConfig {\n /** Name of the `window` function the page pushes batches through. */\n binding: string;\n /** How long to coalesce records before pushing a batch. */\n flush_ms: number;\n /** Records one batch may carry. */\n max_batch: number;\n /** Records the probe may push over the lifetime of one document. */\n max_records: number;\n}\n\nexport function probeConfig(binding: string): ProbeConfig {\n return {\n binding,\n flush_ms: LAYER_FLUSH_MS,\n max_batch: MAX_LAYER_BATCH,\n max_records: MAX_LAYER_RECORDS_PER_DOCUMENT,\n };\n}\n\n/**\n * Expose `config.binding` on the page and arrange for `script` to run at the\n * start of every document (including after a navigation).\n *\n * `onBatch` must never throw: it runs as the resolution of a promise the page\n * is holding, so a throw here surfaces inside the page under test as an\n * unhandled rejection — which the console layer would then dutifully report as\n * a bug in the user's app. It is wrapped here so callers cannot get that wrong.\n */\nexport async function installProbe<T>(\n page: Page,\n config: ProbeConfig,\n script: (config: ProbeConfig) => void,\n onBatch: (records: T[]) => void,\n options: InstallOptions = {},\n): Promise<void> {\n await page.exposeBinding(config.binding, (_source, batch: unknown) => {\n try {\n if (Array.isArray(batch)) onBatch(batch as T[]);\n } catch {\n // A collector must never break the page it is watching.\n }\n });\n await page.addInitScript(script, config);\n\n if (options.runNow === true) {\n // Init scripts only reach *new* documents, so a probe installed on a page\n // that is already loaded would watch nothing until the next navigation.\n // Running it once by hand covers the document that is already there; the\n // probes guard against being installed twice, so the init script running\n // later on the same document is harmless.\n await page.evaluate(script, config).catch(() => {});\n }\n}\n\nexport interface InstallOptions {\n /**\n * Also run the probe against the document the page already has. Needed for\n * `framewatch_interact`, which acts on a page that is open before the layer\n * is asked for; captures attach before navigating and do not need it.\n */\n runNow?: boolean;\n}\n"]}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { CapturedContext, LayerFlags } from "./index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Context layers for a page that outlives one tool call.
|
|
5
|
+
*
|
|
6
|
+
* `framewatch_capture` gets a fresh page every time, so it can attach layers,
|
|
7
|
+
* record, and throw the whole thing away. `framewatch_interact` cannot: its
|
|
8
|
+
* page stays open between calls — that is what makes click → look → type →
|
|
9
|
+
* look possible — and two of the four layers cannot be attached twice to the
|
|
10
|
+
* same page at all. `page.exposeBinding` refuses a name that is already taken,
|
|
11
|
+
* and an init script, once added, can never be removed.
|
|
12
|
+
*
|
|
13
|
+
* So the layers live as long as the page does, and each call *drains* them
|
|
14
|
+
* instead of attaching its own: `ensure` installs whatever this call asked for
|
|
15
|
+
* and is not there yet, `clear` empties the collectors so the call reports
|
|
16
|
+
* only what its own action caused, and `collect` reads them afterwards.
|
|
17
|
+
*
|
|
18
|
+
* A layer that was installed for an earlier call keeps collecting even when
|
|
19
|
+
* this call did not ask for it — nothing can uninstall it — but it is cleared
|
|
20
|
+
* with the rest and simply not reported. The cost is a MutationObserver that
|
|
21
|
+
* nobody reads; the alternative is a page that has to be thrown away and
|
|
22
|
+
* rebuilt whenever the flags change, which would defeat the tool.
|
|
23
|
+
*/
|
|
24
|
+
export declare class SessionLayers {
|
|
25
|
+
#private;
|
|
26
|
+
constructor(page: Page);
|
|
27
|
+
/**
|
|
28
|
+
* Install every requested layer that is not there yet.
|
|
29
|
+
*
|
|
30
|
+
* Failures become notes rather than exceptions, for the same reason as in
|
|
31
|
+
* `attachLayers`: the interaction is the point and the context is the bonus,
|
|
32
|
+
* so a layer that will not install must not take the tool call down with it.
|
|
33
|
+
*/
|
|
34
|
+
ensure(flags: LayerFlags): Promise<void>;
|
|
35
|
+
/** Empty every installed collector, so the next window starts from nothing. */
|
|
36
|
+
clear(): void;
|
|
37
|
+
/**
|
|
38
|
+
* What the requested layers saw, rebased onto `origin` (epoch ms).
|
|
39
|
+
*
|
|
40
|
+
* Only the layers this call asked for are reported, whatever else happens to
|
|
41
|
+
* be installed. Purely synchronous and never touches the page, so it still
|
|
42
|
+
* works after the page has navigated, frozen or died.
|
|
43
|
+
*/
|
|
44
|
+
collect(origin: number, flags: LayerFlags): CapturedContext;
|
|
45
|
+
}
|
|
46
|
+
export declare function layersFor(page: Page): SessionLayers;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { ConsoleCollector } from "./console.js";
|
|
2
|
+
import { DomCollector } from "./dom.js";
|
|
3
|
+
import { NetworkCollector } from "./network.js";
|
|
4
|
+
import { PerformanceCollector } from "./performance.js";
|
|
5
|
+
/**
|
|
6
|
+
* Context layers for a page that outlives one tool call.
|
|
7
|
+
*
|
|
8
|
+
* `framewatch_capture` gets a fresh page every time, so it can attach layers,
|
|
9
|
+
* record, and throw the whole thing away. `framewatch_interact` cannot: its
|
|
10
|
+
* page stays open between calls — that is what makes click → look → type →
|
|
11
|
+
* look possible — and two of the four layers cannot be attached twice to the
|
|
12
|
+
* same page at all. `page.exposeBinding` refuses a name that is already taken,
|
|
13
|
+
* and an init script, once added, can never be removed.
|
|
14
|
+
*
|
|
15
|
+
* So the layers live as long as the page does, and each call *drains* them
|
|
16
|
+
* instead of attaching its own: `ensure` installs whatever this call asked for
|
|
17
|
+
* and is not there yet, `clear` empties the collectors so the call reports
|
|
18
|
+
* only what its own action caused, and `collect` reads them afterwards.
|
|
19
|
+
*
|
|
20
|
+
* A layer that was installed for an earlier call keeps collecting even when
|
|
21
|
+
* this call did not ask for it — nothing can uninstall it — but it is cleared
|
|
22
|
+
* with the rest and simply not reported. The cost is a MutationObserver that
|
|
23
|
+
* nobody reads; the alternative is a page that has to be thrown away and
|
|
24
|
+
* rebuilt whenever the flags change, which would defeat the tool.
|
|
25
|
+
*/
|
|
26
|
+
export class SessionLayers {
|
|
27
|
+
#page;
|
|
28
|
+
#console = null;
|
|
29
|
+
#network = null;
|
|
30
|
+
#dom = null;
|
|
31
|
+
#performance = null;
|
|
32
|
+
/** Layers that could not be installed on this page, one note each. */
|
|
33
|
+
#notes = [];
|
|
34
|
+
constructor(page) {
|
|
35
|
+
this.#page = page;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Install every requested layer that is not there yet.
|
|
39
|
+
*
|
|
40
|
+
* Failures become notes rather than exceptions, for the same reason as in
|
|
41
|
+
* `attachLayers`: the interaction is the point and the context is the bonus,
|
|
42
|
+
* so a layer that will not install must not take the tool call down with it.
|
|
43
|
+
*/
|
|
44
|
+
async ensure(flags) {
|
|
45
|
+
this.#notes = [];
|
|
46
|
+
if (flags.console && !this.#console) {
|
|
47
|
+
this.#console = new ConsoleCollector(this.#page).attach();
|
|
48
|
+
}
|
|
49
|
+
if (flags.network && !this.#network) {
|
|
50
|
+
this.#network = new NetworkCollector(this.#page).attach();
|
|
51
|
+
}
|
|
52
|
+
// The injected probes go in with `runNow`: the page is already loaded by
|
|
53
|
+
// the time this runs, and an init script alone would not reach it until
|
|
54
|
+
// the next navigation.
|
|
55
|
+
if (flags.dom && !this.#dom) {
|
|
56
|
+
this.#dom = await this.#install(new DomCollector(this.#page), "DOM");
|
|
57
|
+
}
|
|
58
|
+
if (flags.performance && !this.#performance) {
|
|
59
|
+
this.#performance = await this.#install(new PerformanceCollector(this.#page), "performance");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Empty every installed collector, so the next window starts from nothing. */
|
|
63
|
+
clear() {
|
|
64
|
+
this.#console?.clear();
|
|
65
|
+
this.#network?.clear();
|
|
66
|
+
this.#dom?.clear();
|
|
67
|
+
this.#performance?.clear();
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* What the requested layers saw, rebased onto `origin` (epoch ms).
|
|
71
|
+
*
|
|
72
|
+
* Only the layers this call asked for are reported, whatever else happens to
|
|
73
|
+
* be installed. Purely synchronous and never touches the page, so it still
|
|
74
|
+
* works after the page has navigated, frozen or died.
|
|
75
|
+
*/
|
|
76
|
+
collect(origin, flags) {
|
|
77
|
+
const context = { notes: [...this.#notes] };
|
|
78
|
+
if (flags.console && this.#console) {
|
|
79
|
+
context.console = this.#console.entries(origin);
|
|
80
|
+
if (this.#console.dropped > 0) {
|
|
81
|
+
context.notes.push(`Console output was capped — ${this.#console.dropped} entries dropped (errors kept first).`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (flags.network && this.#network) {
|
|
85
|
+
context.network = this.#network.events(origin);
|
|
86
|
+
if (this.#network.dropped > 0) {
|
|
87
|
+
context.notes.push(`Network log was capped — ${this.#network.dropped} events dropped (failures kept first).`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (flags.dom && this.#dom) {
|
|
91
|
+
context.dom = this.#dom.records(origin);
|
|
92
|
+
if (this.#dom.dropped > 0) {
|
|
93
|
+
context.notes.push(`DOM log was capped — ${this.#dom.dropped} mutations dropped.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (flags.performance && this.#performance) {
|
|
97
|
+
context.performance = this.#performance.samples(origin);
|
|
98
|
+
if (this.#performance.dropped > 0) {
|
|
99
|
+
context.notes.push(`Performance log was capped — ${this.#performance.dropped} entries dropped.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return context;
|
|
103
|
+
}
|
|
104
|
+
async #install(collector, name) {
|
|
105
|
+
try {
|
|
106
|
+
return await collector.attach({ runNow: true });
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
const reason = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
110
|
+
this.#notes.push(`The ${name} layer could not be installed: ${reason}`);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The layers belonging to `page`, created on first use.
|
|
117
|
+
*
|
|
118
|
+
* Keyed weakly by page, so a session that is reopened (a viewport change that
|
|
119
|
+
* needed touch, a crashed tab) starts with fresh collectors and the old ones
|
|
120
|
+
* are collected along with the page they watched.
|
|
121
|
+
*/
|
|
122
|
+
const byPage = new WeakMap();
|
|
123
|
+
export function layersFor(page) {
|
|
124
|
+
const existing = byPage.get(page);
|
|
125
|
+
if (existing)
|
|
126
|
+
return existing;
|
|
127
|
+
const created = new SessionLayers(page);
|
|
128
|
+
byPage.set(page, created);
|
|
129
|
+
return created;
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../../src/engine/layers/session.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Page } from \"playwright\";\nimport { ConsoleCollector } from \"./console.js\";\nimport { DomCollector } from \"./dom.js\";\nimport { NetworkCollector } from \"./network.js\";\nimport { PerformanceCollector } from \"./performance.js\";\nimport type { CapturedContext, LayerFlags } from \"./index.js\";\n\n/**\n * Context layers for a page that outlives one tool call.\n *\n * `framewatch_capture` gets a fresh page every time, so it can attach layers,\n * record, and throw the whole thing away. `framewatch_interact` cannot: its\n * page stays open between calls — that is what makes click → look → type →\n * look possible — and two of the four layers cannot be attached twice to the\n * same page at all. `page.exposeBinding` refuses a name that is already taken,\n * and an init script, once added, can never be removed.\n *\n * So the layers live as long as the page does, and each call *drains* them\n * instead of attaching its own: `ensure` installs whatever this call asked for\n * and is not there yet, `clear` empties the collectors so the call reports\n * only what its own action caused, and `collect` reads them afterwards.\n *\n * A layer that was installed for an earlier call keeps collecting even when\n * this call did not ask for it — nothing can uninstall it — but it is cleared\n * with the rest and simply not reported. The cost is a MutationObserver that\n * nobody reads; the alternative is a page that has to be thrown away and\n * rebuilt whenever the flags change, which would defeat the tool.\n */\nexport class SessionLayers {\n readonly #page: Page;\n #console: ConsoleCollector | null = null;\n #network: NetworkCollector | null = null;\n #dom: DomCollector | null = null;\n #performance: PerformanceCollector | null = null;\n /** Layers that could not be installed on this page, one note each. */\n #notes: string[] = [];\n\n constructor(page: Page) {\n this.#page = page;\n }\n\n /**\n * Install every requested layer that is not there yet.\n *\n * Failures become notes rather than exceptions, for the same reason as in\n * `attachLayers`: the interaction is the point and the context is the bonus,\n * so a layer that will not install must not take the tool call down with it.\n */\n async ensure(flags: LayerFlags): Promise<void> {\n this.#notes = [];\n\n if (flags.console && !this.#console) {\n this.#console = new ConsoleCollector(this.#page).attach();\n }\n if (flags.network && !this.#network) {\n this.#network = new NetworkCollector(this.#page).attach();\n }\n // The injected probes go in with `runNow`: the page is already loaded by\n // the time this runs, and an init script alone would not reach it until\n // the next navigation.\n if (flags.dom && !this.#dom) {\n this.#dom = await this.#install(new DomCollector(this.#page), \"DOM\");\n }\n if (flags.performance && !this.#performance) {\n this.#performance = await this.#install(new PerformanceCollector(this.#page), \"performance\");\n }\n }\n\n /** Empty every installed collector, so the next window starts from nothing. */\n clear(): void {\n this.#console?.clear();\n this.#network?.clear();\n this.#dom?.clear();\n this.#performance?.clear();\n }\n\n /**\n * What the requested layers saw, rebased onto `origin` (epoch ms).\n *\n * Only the layers this call asked for are reported, whatever else happens to\n * be installed. Purely synchronous and never touches the page, so it still\n * works after the page has navigated, frozen or died.\n */\n collect(origin: number, flags: LayerFlags): CapturedContext {\n const context: CapturedContext = { notes: [...this.#notes] };\n\n if (flags.console && this.#console) {\n context.console = this.#console.entries(origin);\n if (this.#console.dropped > 0) {\n context.notes.push(`Console output was capped — ${this.#console.dropped} entries dropped (errors kept first).`);\n }\n }\n if (flags.network && this.#network) {\n context.network = this.#network.events(origin);\n if (this.#network.dropped > 0) {\n context.notes.push(`Network log was capped — ${this.#network.dropped} events dropped (failures kept first).`);\n }\n }\n if (flags.dom && this.#dom) {\n context.dom = this.#dom.records(origin);\n if (this.#dom.dropped > 0) {\n context.notes.push(`DOM log was capped — ${this.#dom.dropped} mutations dropped.`);\n }\n }\n if (flags.performance && this.#performance) {\n context.performance = this.#performance.samples(origin);\n if (this.#performance.dropped > 0) {\n context.notes.push(`Performance log was capped — ${this.#performance.dropped} entries dropped.`);\n }\n }\n\n return context;\n }\n\n async #install<T extends { attach(options: { runNow: boolean }): Promise<T> }>(\n collector: T,\n name: string,\n ): Promise<T | null> {\n try {\n return await collector.attach({ runNow: true });\n } catch (error) {\n const reason = error instanceof Error ? error.message.split(\"\\n\")[0] : String(error);\n this.#notes.push(`The ${name} layer could not be installed: ${reason}`);\n return null;\n }\n }\n}\n\n/**\n * The layers belonging to `page`, created on first use.\n *\n * Keyed weakly by page, so a session that is reopened (a viewport change that\n * needed touch, a crashed tab) starts with fresh collectors and the old ones\n * are collected along with the page they watched.\n */\nconst byPage = new WeakMap<Page, SessionLayers>();\n\nexport function layersFor(page: Page): SessionLayers {\n const existing = byPage.get(page);\n if (existing) return existing;\n const created = new SessionLayers(page);\n byPage.set(page, created);\n return created;\n}\n"]}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { FrameTrigger, RawFrame } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Frame recorder.
|
|
5
|
+
*
|
|
6
|
+
* Captures raw PNG screenshots of a page at a fixed interval using a
|
|
7
|
+
* self-scheduling `setTimeout` chain aligned to `start + n * interval`, so two
|
|
8
|
+
* screenshots are never in flight at the same time. If one screenshot takes
|
|
9
|
+
* longer than the interval, the missed ticks are skipped rather than bunched.
|
|
10
|
+
*
|
|
11
|
+
* Every screenshot is bounded by an explicit timeout: Chromium blocks
|
|
12
|
+
* screenshots while a cross-document navigation is pending or the main thread
|
|
13
|
+
* is busy, and Playwright's 30s default would otherwise stall the recording
|
|
14
|
+
* (and stop(), which waits for the in-flight shot) for minutes.
|
|
15
|
+
*/
|
|
16
|
+
export interface RecorderOptions {
|
|
17
|
+
/** Interval between interval frames. Default CAPTURE_INTERVAL_MS. */
|
|
18
|
+
interval_ms?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface RecordingResult {
|
|
21
|
+
/** All captured frames in ascending timestamp_ms order (forced frames interleaved in time order). */
|
|
22
|
+
frames: RawFrame[];
|
|
23
|
+
/** Actual wall-clock recording length in ms. */
|
|
24
|
+
duration_ms: number;
|
|
25
|
+
/** Interval ticks whose screenshot failed (e.g. mid-navigation) and were skipped. */
|
|
26
|
+
dropped: number;
|
|
27
|
+
/**
|
|
28
|
+
* Epoch ms of timestamp 0. The context layers start collecting before the
|
|
29
|
+
* navigation, so they stamp absolute time and need this to rebase onto the
|
|
30
|
+
* same clock the frames use.
|
|
31
|
+
*/
|
|
32
|
+
started_at: number;
|
|
33
|
+
}
|
|
34
|
+
export declare class FrameRecorder {
|
|
35
|
+
#private;
|
|
36
|
+
constructor(page: Page, options?: RecorderOptions);
|
|
37
|
+
/** Frames captured so far (live view, in capture order). */
|
|
38
|
+
get frames(): readonly RawFrame[];
|
|
39
|
+
/**
|
|
40
|
+
* Start the interval loop; timestamp 0 is now. The first frame is captured
|
|
41
|
+
* immediately. Idempotent, and a no-op once the recorder has been stopped.
|
|
42
|
+
*/
|
|
43
|
+
start(): void;
|
|
44
|
+
/**
|
|
45
|
+
* Capture one frame right now (outside the interval), tagged with `trigger`
|
|
46
|
+
* (is_interaction = trigger === "interaction"). Waits for any in-flight
|
|
47
|
+
* screenshot first. Resolves to the frame, or null if the screenshot
|
|
48
|
+
* failed. Never throws.
|
|
49
|
+
*/
|
|
50
|
+
captureNow(trigger: FrameTrigger): Promise<RawFrame | null>;
|
|
51
|
+
/** Stop the loop, wait for any in-flight screenshot, capture one final frame, and return the result. */
|
|
52
|
+
stop(): Promise<RecordingResult>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Convenience: start → (run `during(recorder)` concurrently if given) → wait
|
|
56
|
+
* `duration_ms` from start → stop. If `during` rejects, the recorder is
|
|
57
|
+
* stopped first and the error is rethrown.
|
|
58
|
+
*/
|
|
59
|
+
export declare function recordFrames(page: Page, options: RecorderOptions & {
|
|
60
|
+
duration_ms: number;
|
|
61
|
+
}, during?: (recorder: FrameRecorder) => Promise<void>): Promise<RecordingResult>;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { CAPTURE_INTERVAL_MS, SCREENSHOT_FINAL_TIMEOUT_MS, SCREENSHOT_RETRY_ATTEMPTS, SCREENSHOT_RETRY_DELAY_MS, SCREENSHOT_TIMEOUT_MS, } from "../constants.js";
|
|
2
|
+
export class FrameRecorder {
|
|
3
|
+
#page;
|
|
4
|
+
#interval;
|
|
5
|
+
#frames = [];
|
|
6
|
+
#startedAt = 0;
|
|
7
|
+
#running = false;
|
|
8
|
+
#timer = null;
|
|
9
|
+
/** The currently running interval tick (screenshot), if any. */
|
|
10
|
+
#inFlight = null;
|
|
11
|
+
/** Serialises every screenshot (interval + out-of-band) so no two are ever in flight together. */
|
|
12
|
+
#chain = Promise.resolve();
|
|
13
|
+
#dropped = 0;
|
|
14
|
+
/** Per-screenshot timeout — see the class doc. */
|
|
15
|
+
#screenshotTimeout;
|
|
16
|
+
/** True while the last screenshot attempt timed out; stop() then skips the final frame. */
|
|
17
|
+
#unresponsive = false;
|
|
18
|
+
/** Main-frame URL (without fragment) of the last navigation we reacted to. */
|
|
19
|
+
#lastNavUrl = null;
|
|
20
|
+
/** Set by a navigation; the next frame that lands is tagged "navigation". */
|
|
21
|
+
#pendingNavTag = false;
|
|
22
|
+
/** Set by the first stop(); later calls return the same result instead of capturing again. */
|
|
23
|
+
#result = null;
|
|
24
|
+
/**
|
|
25
|
+
* Main-frame navigation → the next frame that lands is tagged "navigation"
|
|
26
|
+
* (and so is always kept by the differ). Attached in start(), removed in
|
|
27
|
+
* stop().
|
|
28
|
+
*
|
|
29
|
+
* Tagging rather than taking a dedicated screenshot matters twice over.
|
|
30
|
+
* Playwright emits this event for same-document navigations too, so the
|
|
31
|
+
* common scroll-spy / router pattern (a `history.replaceState` on every
|
|
32
|
+
* animation frame) fires it ~60 times a second — a screenshot per event
|
|
33
|
+
* would starve the recorder. And a screenshot requested at commit time is
|
|
34
|
+
* the one most likely to block: Chromium has not painted the new document
|
|
35
|
+
* yet, so the request hangs until it does, holds up every capture queued
|
|
36
|
+
* behind it, and can be lost entirely if the recording ends first. The next
|
|
37
|
+
* frame the loop takes shows the same navigation and always arrives.
|
|
38
|
+
*
|
|
39
|
+
* Fragment-only changes are ignored outright: `#a` → `#b` navigates nothing.
|
|
40
|
+
*/
|
|
41
|
+
#onFrameNavigated = (frame) => {
|
|
42
|
+
if (frame !== this.#page.mainFrame())
|
|
43
|
+
return;
|
|
44
|
+
const url = stripFragment(frame.url());
|
|
45
|
+
if (url === this.#lastNavUrl)
|
|
46
|
+
return;
|
|
47
|
+
this.#lastNavUrl = url;
|
|
48
|
+
this.#pendingNavTag = true;
|
|
49
|
+
};
|
|
50
|
+
constructor(page, options = {}) {
|
|
51
|
+
this.#page = page;
|
|
52
|
+
const interval = options.interval_ms;
|
|
53
|
+
// A zero/negative/NaN interval would turn the tick chain into a tight
|
|
54
|
+
// screenshot loop (setTimeout coerces NaN and Infinity to ~1ms).
|
|
55
|
+
this.#interval = typeof interval === "number" && Number.isFinite(interval) && interval > 0 ? interval : CAPTURE_INTERVAL_MS;
|
|
56
|
+
this.#screenshotTimeout = Math.max(SCREENSHOT_TIMEOUT_MS, this.#interval * 2);
|
|
57
|
+
}
|
|
58
|
+
/** Frames captured so far (live view, in capture order). */
|
|
59
|
+
get frames() {
|
|
60
|
+
return this.#frames;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Start the interval loop; timestamp 0 is now. The first frame is captured
|
|
64
|
+
* immediately. Idempotent, and a no-op once the recorder has been stopped.
|
|
65
|
+
*/
|
|
66
|
+
start() {
|
|
67
|
+
if (this.#running || this.#result)
|
|
68
|
+
return;
|
|
69
|
+
this.#running = true;
|
|
70
|
+
this.#startedAt = Date.now();
|
|
71
|
+
this.#lastNavUrl = stripFragment(this.#page.url());
|
|
72
|
+
this.#page.on("framenavigated", this.#onFrameNavigated);
|
|
73
|
+
this.#tick(0);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Capture one frame right now (outside the interval), tagged with `trigger`
|
|
77
|
+
* (is_interaction = trigger === "interaction"). Waits for any in-flight
|
|
78
|
+
* screenshot first. Resolves to the frame, or null if the screenshot
|
|
79
|
+
* failed. Never throws.
|
|
80
|
+
*/
|
|
81
|
+
captureNow(trigger) {
|
|
82
|
+
return this.#capture(trigger);
|
|
83
|
+
}
|
|
84
|
+
/** Stop the loop, wait for any in-flight screenshot, capture one final frame, and return the result. */
|
|
85
|
+
async stop() {
|
|
86
|
+
if (!this.#result) {
|
|
87
|
+
this.#result = this.#doStop();
|
|
88
|
+
}
|
|
89
|
+
return this.#result;
|
|
90
|
+
}
|
|
91
|
+
async #doStop() {
|
|
92
|
+
this.#running = false;
|
|
93
|
+
this.#page.off("framenavigated", this.#onFrameNavigated);
|
|
94
|
+
if (this.#timer) {
|
|
95
|
+
clearTimeout(this.#timer);
|
|
96
|
+
this.#timer = null;
|
|
97
|
+
}
|
|
98
|
+
await this.#inFlight;
|
|
99
|
+
// Always try for a final frame — it is the settled end state, and it may
|
|
100
|
+
// still be carrying a pending navigation tag. If the last attempt timed
|
|
101
|
+
// out, bound this one tightly so a wedged page cannot stall shutdown while
|
|
102
|
+
// a page that has since recovered still gets captured.
|
|
103
|
+
if (!this.#page.isClosed()) {
|
|
104
|
+
await this.#capture(undefined, this.#unresponsive ? SCREENSHOT_FINAL_TIMEOUT_MS : undefined);
|
|
105
|
+
}
|
|
106
|
+
const duration_ms = this.#elapsed();
|
|
107
|
+
const frames = [...this.#frames].sort((a, b) => a.timestamp_ms - b.timestamp_ms);
|
|
108
|
+
return { frames, duration_ms, dropped: this.#dropped, started_at: this.#startedAt };
|
|
109
|
+
}
|
|
110
|
+
#elapsed() {
|
|
111
|
+
return Date.now() - this.#startedAt;
|
|
112
|
+
}
|
|
113
|
+
/** Run interval tick `n`, then schedule the next tick that is still in the future. */
|
|
114
|
+
#tick(n) {
|
|
115
|
+
if (!this.#running)
|
|
116
|
+
return;
|
|
117
|
+
this.#timer = null;
|
|
118
|
+
if (this.#page.isClosed()) {
|
|
119
|
+
// Nothing left to record — stop silently rather than counting every remaining tick as dropped.
|
|
120
|
+
this.#running = false;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.#inFlight = this.#capture(undefined)
|
|
124
|
+
.then((frame) => {
|
|
125
|
+
if (frame === null && !this.#page.isClosed())
|
|
126
|
+
this.#dropped++;
|
|
127
|
+
})
|
|
128
|
+
.finally(() => {
|
|
129
|
+
this.#inFlight = null;
|
|
130
|
+
if (!this.#running || this.#page.isClosed())
|
|
131
|
+
return;
|
|
132
|
+
// Next tick strictly in the future: skip any ticks already missed so screenshots never bunch up.
|
|
133
|
+
const elapsed = this.#elapsed();
|
|
134
|
+
const next = Math.max(n + 1, Math.floor(elapsed / this.#interval) + 1);
|
|
135
|
+
this.#timer = setTimeout(() => this.#tick(next), next * this.#interval - elapsed);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Take one screenshot (after any in-flight one has finished) and append it.
|
|
140
|
+
*
|
|
141
|
+
* The frame is stamped when the screenshot *resolves*, not when it was
|
|
142
|
+
* requested: a screenshot that waits on a pending navigation returns the new
|
|
143
|
+
* document, and stamping it with the request time would report the change as
|
|
144
|
+
* having happened far earlier than it did.
|
|
145
|
+
*
|
|
146
|
+
* Resolves to the frame, or null if the screenshot failed. Never throws.
|
|
147
|
+
*/
|
|
148
|
+
#capture(trigger, timeoutMs) {
|
|
149
|
+
const run = async () => {
|
|
150
|
+
const buffer = await this.#screenshot(timeoutMs ?? this.#screenshotTimeout);
|
|
151
|
+
if (buffer === null)
|
|
152
|
+
return null;
|
|
153
|
+
// A navigation since the last frame? This one shows it.
|
|
154
|
+
let effective = trigger;
|
|
155
|
+
if (this.#pendingNavTag) {
|
|
156
|
+
this.#pendingNavTag = false;
|
|
157
|
+
effective ??= "navigation";
|
|
158
|
+
}
|
|
159
|
+
const frame = {
|
|
160
|
+
buffer,
|
|
161
|
+
timestamp_ms: this.#elapsed(),
|
|
162
|
+
is_interaction: effective === "interaction",
|
|
163
|
+
...(effective !== undefined ? { trigger: effective } : {}),
|
|
164
|
+
};
|
|
165
|
+
this.#frames.push(frame);
|
|
166
|
+
return frame;
|
|
167
|
+
};
|
|
168
|
+
const result = this.#chain.then(run, run);
|
|
169
|
+
this.#chain = result;
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* One bounded screenshot. Returns null on failure (never throws) and records
|
|
174
|
+
* whether the page stopped responding. Chromium refuses a screenshot until
|
|
175
|
+
* it has produced its first frame — right after a navigation commits that
|
|
176
|
+
* happens about half the time — so that specific error is retried briefly.
|
|
177
|
+
*/
|
|
178
|
+
async #screenshot(timeoutMs) {
|
|
179
|
+
for (let attempt = 0; attempt < SCREENSHOT_RETRY_ATTEMPTS; attempt++) {
|
|
180
|
+
try {
|
|
181
|
+
const buffer = await this.#page.screenshot({ type: "png", timeout: timeoutMs });
|
|
182
|
+
this.#unresponsive = false;
|
|
183
|
+
return buffer;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
187
|
+
if (/Timeout .*exceeded/i.test(message)) {
|
|
188
|
+
// The page is blocked; retrying only stalls the recording further.
|
|
189
|
+
this.#unresponsive = true;
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
if (!/Unable to capture screenshot/i.test(message) || this.#page.isClosed()) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
await delay(SCREENSHOT_RETRY_DELAY_MS);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Compare navigation URLs without their fragment — `#a` → `#b` navigates nothing. */
|
|
202
|
+
function stripFragment(url) {
|
|
203
|
+
const hash = url.indexOf("#");
|
|
204
|
+
return hash === -1 ? url : url.slice(0, hash);
|
|
205
|
+
}
|
|
206
|
+
function delay(ms) {
|
|
207
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Convenience: start → (run `during(recorder)` concurrently if given) → wait
|
|
211
|
+
* `duration_ms` from start → stop. If `during` rejects, the recorder is
|
|
212
|
+
* stopped first and the error is rethrown.
|
|
213
|
+
*/
|
|
214
|
+
export async function recordFrames(page, options, during) {
|
|
215
|
+
const recorder = new FrameRecorder(page, options);
|
|
216
|
+
recorder.start();
|
|
217
|
+
let timer;
|
|
218
|
+
const duration = Number.isFinite(options.duration_ms) ? Math.max(0, options.duration_ms) : 0;
|
|
219
|
+
const wait = new Promise((resolve) => {
|
|
220
|
+
timer = setTimeout(resolve, duration);
|
|
221
|
+
});
|
|
222
|
+
// There is nothing left to record once the page is gone: end the recording
|
|
223
|
+
// immediately instead of idling out the rest of the duration.
|
|
224
|
+
let onGone;
|
|
225
|
+
const pageGone = new Promise((resolve) => {
|
|
226
|
+
onGone = () => resolve();
|
|
227
|
+
page.once("close", onGone);
|
|
228
|
+
page.once("crash", onGone);
|
|
229
|
+
});
|
|
230
|
+
try {
|
|
231
|
+
const elapse = Promise.race([wait, pageGone]);
|
|
232
|
+
if (during) {
|
|
233
|
+
// Let a failing `during` short-circuit the wait; a successful one still waits out the duration.
|
|
234
|
+
await Promise.all([elapse, during(recorder)]);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
await elapse;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
clearTimeout(timer);
|
|
242
|
+
if (onGone) {
|
|
243
|
+
page.off("close", onGone);
|
|
244
|
+
page.off("crash", onGone);
|
|
245
|
+
}
|
|
246
|
+
await recorder.stop();
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
if (onGone) {
|
|
251
|
+
page.off("close", onGone);
|
|
252
|
+
page.off("crash", onGone);
|
|
253
|
+
}
|
|
254
|
+
return recorder.stop();
|
|
255
|
+
}
|
|
256
|
+
//# sourceMappingURL=recorder.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recorder.js","sourceRoot":"","sources":["../../src/engine/recorder.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Frame, Page } from \"playwright\";\nimport {\n CAPTURE_INTERVAL_MS,\n SCREENSHOT_FINAL_TIMEOUT_MS,\n SCREENSHOT_RETRY_ATTEMPTS,\n SCREENSHOT_RETRY_DELAY_MS,\n SCREENSHOT_TIMEOUT_MS,\n} from \"../constants.js\";\nimport type { FrameTrigger, RawFrame } from \"../types.js\";\n\n/**\n * Frame recorder.\n *\n * Captures raw PNG screenshots of a page at a fixed interval using a\n * self-scheduling `setTimeout` chain aligned to `start + n * interval`, so two\n * screenshots are never in flight at the same time. If one screenshot takes\n * longer than the interval, the missed ticks are skipped rather than bunched.\n *\n * Every screenshot is bounded by an explicit timeout: Chromium blocks\n * screenshots while a cross-document navigation is pending or the main thread\n * is busy, and Playwright's 30s default would otherwise stall the recording\n * (and stop(), which waits for the in-flight shot) for minutes.\n */\n\nexport interface RecorderOptions {\n /** Interval between interval frames. Default CAPTURE_INTERVAL_MS. */\n interval_ms?: number;\n}\n\nexport interface RecordingResult {\n /** All captured frames in ascending timestamp_ms order (forced frames interleaved in time order). */\n frames: RawFrame[];\n /** Actual wall-clock recording length in ms. */\n duration_ms: number;\n /** Interval ticks whose screenshot failed (e.g. mid-navigation) and were skipped. */\n dropped: number;\n /**\n * Epoch ms of timestamp 0. The context layers start collecting before the\n * navigation, so they stamp absolute time and need this to rebase onto the\n * same clock the frames use.\n */\n started_at: number;\n}\n\nexport class FrameRecorder {\n readonly #page: Page;\n readonly #interval: number;\n readonly #frames: RawFrame[] = [];\n #startedAt = 0;\n #running = false;\n #timer: NodeJS.Timeout | null = null;\n /** The currently running interval tick (screenshot), if any. */\n #inFlight: Promise<void> | null = null;\n /** Serialises every screenshot (interval + out-of-band) so no two are ever in flight together. */\n #chain: Promise<unknown> = Promise.resolve();\n #dropped = 0;\n /** Per-screenshot timeout — see the class doc. */\n readonly #screenshotTimeout: number;\n /** True while the last screenshot attempt timed out; stop() then skips the final frame. */\n #unresponsive = false;\n /** Main-frame URL (without fragment) of the last navigation we reacted to. */\n #lastNavUrl: string | null = null;\n /** Set by a navigation; the next frame that lands is tagged \"navigation\". */\n #pendingNavTag = false;\n /** Set by the first stop(); later calls return the same result instead of capturing again. */\n #result: Promise<RecordingResult> | null = null;\n /**\n * Main-frame navigation → the next frame that lands is tagged \"navigation\"\n * (and so is always kept by the differ). Attached in start(), removed in\n * stop().\n *\n * Tagging rather than taking a dedicated screenshot matters twice over.\n * Playwright emits this event for same-document navigations too, so the\n * common scroll-spy / router pattern (a `history.replaceState` on every\n * animation frame) fires it ~60 times a second — a screenshot per event\n * would starve the recorder. And a screenshot requested at commit time is\n * the one most likely to block: Chromium has not painted the new document\n * yet, so the request hangs until it does, holds up every capture queued\n * behind it, and can be lost entirely if the recording ends first. The next\n * frame the loop takes shows the same navigation and always arrives.\n *\n * Fragment-only changes are ignored outright: `#a` → `#b` navigates nothing.\n */\n readonly #onFrameNavigated = (frame: Frame): void => {\n if (frame !== this.#page.mainFrame()) return;\n\n const url = stripFragment(frame.url());\n if (url === this.#lastNavUrl) return;\n this.#lastNavUrl = url;\n this.#pendingNavTag = true;\n };\n\n constructor(page: Page, options: RecorderOptions = {}) {\n this.#page = page;\n const interval = options.interval_ms;\n // A zero/negative/NaN interval would turn the tick chain into a tight\n // screenshot loop (setTimeout coerces NaN and Infinity to ~1ms).\n this.#interval = typeof interval === \"number\" && Number.isFinite(interval) && interval > 0 ? interval : CAPTURE_INTERVAL_MS;\n this.#screenshotTimeout = Math.max(SCREENSHOT_TIMEOUT_MS, this.#interval * 2);\n }\n\n /** Frames captured so far (live view, in capture order). */\n get frames(): readonly RawFrame[] {\n return this.#frames;\n }\n\n /**\n * Start the interval loop; timestamp 0 is now. The first frame is captured\n * immediately. Idempotent, and a no-op once the recorder has been stopped.\n */\n start(): void {\n if (this.#running || this.#result) return;\n this.#running = true;\n this.#startedAt = Date.now();\n this.#lastNavUrl = stripFragment(this.#page.url());\n this.#page.on(\"framenavigated\", this.#onFrameNavigated);\n this.#tick(0);\n }\n\n /**\n * Capture one frame right now (outside the interval), tagged with `trigger`\n * (is_interaction = trigger === \"interaction\"). Waits for any in-flight\n * screenshot first. Resolves to the frame, or null if the screenshot\n * failed. Never throws.\n */\n captureNow(trigger: FrameTrigger): Promise<RawFrame | null> {\n return this.#capture(trigger);\n }\n\n /** Stop the loop, wait for any in-flight screenshot, capture one final frame, and return the result. */\n async stop(): Promise<RecordingResult> {\n if (!this.#result) {\n this.#result = this.#doStop();\n }\n return this.#result;\n }\n\n async #doStop(): Promise<RecordingResult> {\n this.#running = false;\n this.#page.off(\"framenavigated\", this.#onFrameNavigated);\n if (this.#timer) {\n clearTimeout(this.#timer);\n this.#timer = null;\n }\n await this.#inFlight;\n // Always try for a final frame — it is the settled end state, and it may\n // still be carrying a pending navigation tag. If the last attempt timed\n // out, bound this one tightly so a wedged page cannot stall shutdown while\n // a page that has since recovered still gets captured.\n if (!this.#page.isClosed()) {\n await this.#capture(undefined, this.#unresponsive ? SCREENSHOT_FINAL_TIMEOUT_MS : undefined);\n }\n const duration_ms = this.#elapsed();\n const frames = [...this.#frames].sort((a, b) => a.timestamp_ms - b.timestamp_ms);\n return { frames, duration_ms, dropped: this.#dropped, started_at: this.#startedAt };\n }\n\n #elapsed(): number {\n return Date.now() - this.#startedAt;\n }\n\n /** Run interval tick `n`, then schedule the next tick that is still in the future. */\n #tick(n: number): void {\n if (!this.#running) return;\n this.#timer = null;\n if (this.#page.isClosed()) {\n // Nothing left to record — stop silently rather than counting every remaining tick as dropped.\n this.#running = false;\n return;\n }\n this.#inFlight = this.#capture(undefined)\n .then((frame) => {\n if (frame === null && !this.#page.isClosed()) this.#dropped++;\n })\n .finally(() => {\n this.#inFlight = null;\n if (!this.#running || this.#page.isClosed()) return;\n // Next tick strictly in the future: skip any ticks already missed so screenshots never bunch up.\n const elapsed = this.#elapsed();\n const next = Math.max(n + 1, Math.floor(elapsed / this.#interval) + 1);\n this.#timer = setTimeout(() => this.#tick(next), next * this.#interval - elapsed);\n });\n }\n\n /**\n * Take one screenshot (after any in-flight one has finished) and append it.\n *\n * The frame is stamped when the screenshot *resolves*, not when it was\n * requested: a screenshot that waits on a pending navigation returns the new\n * document, and stamping it with the request time would report the change as\n * having happened far earlier than it did.\n *\n * Resolves to the frame, or null if the screenshot failed. Never throws.\n */\n #capture(trigger: FrameTrigger | undefined, timeoutMs?: number): Promise<RawFrame | null> {\n const run = async (): Promise<RawFrame | null> => {\n const buffer = await this.#screenshot(timeoutMs ?? this.#screenshotTimeout);\n if (buffer === null) return null;\n // A navigation since the last frame? This one shows it.\n let effective = trigger;\n if (this.#pendingNavTag) {\n this.#pendingNavTag = false;\n effective ??= \"navigation\";\n }\n const frame: RawFrame = {\n buffer,\n timestamp_ms: this.#elapsed(),\n is_interaction: effective === \"interaction\",\n ...(effective !== undefined ? { trigger: effective } : {}),\n };\n this.#frames.push(frame);\n return frame;\n };\n const result = this.#chain.then(run, run);\n this.#chain = result;\n return result;\n }\n\n /**\n * One bounded screenshot. Returns null on failure (never throws) and records\n * whether the page stopped responding. Chromium refuses a screenshot until\n * it has produced its first frame — right after a navigation commits that\n * happens about half the time — so that specific error is retried briefly.\n */\n async #screenshot(timeoutMs: number): Promise<Buffer | null> {\n for (let attempt = 0; attempt < SCREENSHOT_RETRY_ATTEMPTS; attempt++) {\n try {\n const buffer = await this.#page.screenshot({ type: \"png\", timeout: timeoutMs });\n this.#unresponsive = false;\n return buffer;\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error);\n if (/Timeout .*exceeded/i.test(message)) {\n // The page is blocked; retrying only stalls the recording further.\n this.#unresponsive = true;\n return null;\n }\n if (!/Unable to capture screenshot/i.test(message) || this.#page.isClosed()) {\n return null;\n }\n await delay(SCREENSHOT_RETRY_DELAY_MS);\n }\n }\n return null;\n }\n}\n\n/** Compare navigation URLs without their fragment — `#a` → `#b` navigates nothing. */\nfunction stripFragment(url: string): string {\n const hash = url.indexOf(\"#\");\n return hash === -1 ? url : url.slice(0, hash);\n}\n\nfunction delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Convenience: start → (run `during(recorder)` concurrently if given) → wait\n * `duration_ms` from start → stop. If `during` rejects, the recorder is\n * stopped first and the error is rethrown.\n */\nexport async function recordFrames(\n page: Page,\n options: RecorderOptions & { duration_ms: number },\n during?: (recorder: FrameRecorder) => Promise<void>,\n): Promise<RecordingResult> {\n const recorder = new FrameRecorder(page, options);\n recorder.start();\n\n let timer: NodeJS.Timeout | undefined;\n const duration = Number.isFinite(options.duration_ms) ? Math.max(0, options.duration_ms) : 0;\n const wait = new Promise<void>((resolve) => {\n timer = setTimeout(resolve, duration);\n });\n\n // There is nothing left to record once the page is gone: end the recording\n // immediately instead of idling out the rest of the duration.\n let onGone: (() => void) | undefined;\n const pageGone = new Promise<void>((resolve) => {\n onGone = (): void => resolve();\n page.once(\"close\", onGone);\n page.once(\"crash\", onGone);\n });\n\n try {\n const elapse = Promise.race([wait, pageGone]);\n if (during) {\n // Let a failing `during` short-circuit the wait; a successful one still waits out the duration.\n await Promise.all([elapse, during(recorder)]);\n } else {\n await elapse;\n }\n } catch (error) {\n clearTimeout(timer);\n if (onGone) {\n page.off(\"close\", onGone);\n page.off(\"crash\", onGone);\n }\n await recorder.stop();\n throw error;\n }\n clearTimeout(timer);\n if (onGone) {\n page.off(\"close\", onGone);\n page.off(\"crash\", onGone);\n }\n return recorder.stop();\n}\n"]}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
export declare const SERVER_NAME = "framewatch";
|
|
4
|
+
export declare const SERVER_VERSION: string;
|
|
5
|
+
/**
|
|
6
|
+
* Build a fully configured FrameWatch McpServer (not yet connected to a
|
|
7
|
+
* transport). Exported so tests and embedders can wire their own transport;
|
|
8
|
+
* importing this module has no side effects — the stdio server only starts
|
|
9
|
+
* when this file is executed directly (`node dist/index.js` / the npm bin).
|
|
10
|
+
*/
|
|
11
|
+
export declare function createServer(): McpServer;
|
|
12
|
+
/** Run the server on stdio until the client disconnects or the process is signalled. */
|
|
13
|
+
export declare function main(): Promise<void>;
|