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,62 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { NetworkEvent } from "../../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Network layer.
|
|
5
|
+
*
|
|
6
|
+
* Records one event per request that settles during a capture: method, url,
|
|
7
|
+
* status, how long it took, and when it landed. A request is timestamped at
|
|
8
|
+
* the moment it *settled*, not when it was sent, because that is the instant
|
|
9
|
+
* that can explain the frame next to it — a spinner disappears when the
|
|
10
|
+
* response arrives, not when the fetch was issued.
|
|
11
|
+
*
|
|
12
|
+
* Durations come from Chromium's own `request.timing()` rather than from wall
|
|
13
|
+
* clock in the event handler, so they measure the request and not Node's event
|
|
14
|
+
* loop. Failed requests have no timing, and fall back to wall clock.
|
|
15
|
+
*
|
|
16
|
+
* Requests still in flight when the recording ends are reported too, as
|
|
17
|
+
* `status: 0, error: "pending"` — a request that never comes back is a finding,
|
|
18
|
+
* and silently dropping it would hide exactly the bug a developer is chasing.
|
|
19
|
+
*/
|
|
20
|
+
/** One settled request, stamped with absolute (epoch) time and an already-shortened url. */
|
|
21
|
+
export interface NetworkRecord {
|
|
22
|
+
at: number;
|
|
23
|
+
method: string;
|
|
24
|
+
url: string;
|
|
25
|
+
status: number;
|
|
26
|
+
duration_ms: number;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Shorten a URL for display, keeping both ends: the path says what was asked
|
|
31
|
+
* for and the tail usually carries the identifying part of a query string.
|
|
32
|
+
* data: URIs are collapsed outright — they are content, not an address.
|
|
33
|
+
*/
|
|
34
|
+
export declare function shortenUrl(url: string, maxLength?: number): string;
|
|
35
|
+
export declare class NetworkCollector {
|
|
36
|
+
#private;
|
|
37
|
+
constructor(page: Page, limit?: number);
|
|
38
|
+
/** Start listening. Idempotent. */
|
|
39
|
+
attach(): this;
|
|
40
|
+
/** Stop listening. Collected events are kept. */
|
|
41
|
+
detach(): void;
|
|
42
|
+
/** Events refused or evicted by the cap, settled and pending alike. */
|
|
43
|
+
get dropped(): number;
|
|
44
|
+
/** Requests that never settled before the recording ended. */
|
|
45
|
+
get pending(): number;
|
|
46
|
+
/**
|
|
47
|
+
* Forget everything collected so far, in-flight requests included. See
|
|
48
|
+
* `BoundedLog.clear`.
|
|
49
|
+
*
|
|
50
|
+
* Dropping the in-flight map is deliberate: a request that was already
|
|
51
|
+
* running before this window started did not begin here, and reporting it
|
|
52
|
+
* as this action's pending request would be a lie about cause.
|
|
53
|
+
*/
|
|
54
|
+
clear(): void;
|
|
55
|
+
/**
|
|
56
|
+
* Collected events in settle order, with timestamps rebased onto the
|
|
57
|
+
* recording clock (`origin` is the recording's start in epoch ms). Requests
|
|
58
|
+
* still in flight are appended as `pending`, timestamped when they started,
|
|
59
|
+
* so they sort next to the frame that issued them.
|
|
60
|
+
*/
|
|
61
|
+
events(origin: number): NetworkEvent[];
|
|
62
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { MAX_NETWORK_EVENTS, MAX_NETWORK_URL_LENGTH } from "../../constants.js";
|
|
2
|
+
import { BoundedLog } from "../../utils/bounded-log.js";
|
|
3
|
+
/**
|
|
4
|
+
* Shorten a URL for display, keeping both ends: the path says what was asked
|
|
5
|
+
* for and the tail usually carries the identifying part of a query string.
|
|
6
|
+
* data: URIs are collapsed outright — they are content, not an address.
|
|
7
|
+
*/
|
|
8
|
+
export function shortenUrl(url, maxLength = MAX_NETWORK_URL_LENGTH) {
|
|
9
|
+
if (url.startsWith("data:")) {
|
|
10
|
+
const comma = url.indexOf(",");
|
|
11
|
+
const head = comma === -1 ? url.slice(0, 40) : url.slice(0, Math.min(comma, 40));
|
|
12
|
+
return `${head},…(${url.length} chars)`;
|
|
13
|
+
}
|
|
14
|
+
if (url.length <= maxLength)
|
|
15
|
+
return url;
|
|
16
|
+
const keepEnd = Math.floor((maxLength - 1) / 3);
|
|
17
|
+
const keepStart = maxLength - 1 - keepEnd;
|
|
18
|
+
return `${url.slice(0, keepStart)}…${url.slice(url.length - keepEnd)}`;
|
|
19
|
+
}
|
|
20
|
+
/** A response worth keeping when the cap is full: anything that failed or errored. */
|
|
21
|
+
function isNotable(record) {
|
|
22
|
+
return record.error !== undefined || record.status === 0 || record.status >= 400;
|
|
23
|
+
}
|
|
24
|
+
export class NetworkCollector {
|
|
25
|
+
#page;
|
|
26
|
+
#limit;
|
|
27
|
+
#log;
|
|
28
|
+
/** Requests seen start but not yet settled. Keyed by Playwright's Request identity. */
|
|
29
|
+
#inFlight = new Map();
|
|
30
|
+
#attached = false;
|
|
31
|
+
#onRequest = (request) => {
|
|
32
|
+
this.#inFlight.set(request, { started: Date.now(), status: 0 });
|
|
33
|
+
};
|
|
34
|
+
#onResponse = (response) => {
|
|
35
|
+
// Read the status synchronously here; at `requestfinished` it would cost an
|
|
36
|
+
// await, and an async handler would reorder events against the recording.
|
|
37
|
+
const pending = this.#inFlight.get(response.request());
|
|
38
|
+
if (pending)
|
|
39
|
+
pending.status = response.status();
|
|
40
|
+
};
|
|
41
|
+
#onFinished = (request) => {
|
|
42
|
+
this.#settle(request);
|
|
43
|
+
};
|
|
44
|
+
#onFailed = (request) => {
|
|
45
|
+
this.#settle(request, request.failure()?.errorText ?? "request failed");
|
|
46
|
+
};
|
|
47
|
+
constructor(page, limit = MAX_NETWORK_EVENTS) {
|
|
48
|
+
this.#page = page;
|
|
49
|
+
this.#limit = Math.max(1, Math.floor(limit));
|
|
50
|
+
this.#log = new BoundedLog(limit, isNotable);
|
|
51
|
+
}
|
|
52
|
+
/** Start listening. Idempotent. */
|
|
53
|
+
attach() {
|
|
54
|
+
if (this.#attached)
|
|
55
|
+
return this;
|
|
56
|
+
this.#attached = true;
|
|
57
|
+
this.#page.on("request", this.#onRequest);
|
|
58
|
+
this.#page.on("response", this.#onResponse);
|
|
59
|
+
this.#page.on("requestfinished", this.#onFinished);
|
|
60
|
+
this.#page.on("requestfailed", this.#onFailed);
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
/** Stop listening. Collected events are kept. */
|
|
64
|
+
detach() {
|
|
65
|
+
if (!this.#attached)
|
|
66
|
+
return;
|
|
67
|
+
this.#attached = false;
|
|
68
|
+
this.#page.off("request", this.#onRequest);
|
|
69
|
+
this.#page.off("response", this.#onResponse);
|
|
70
|
+
this.#page.off("requestfinished", this.#onFinished);
|
|
71
|
+
this.#page.off("requestfailed", this.#onFailed);
|
|
72
|
+
}
|
|
73
|
+
/** Events refused or evicted by the cap, settled and pending alike. */
|
|
74
|
+
get dropped() {
|
|
75
|
+
return this.#log.dropped + this.#pendingOverflow();
|
|
76
|
+
}
|
|
77
|
+
/** Requests that never settled before the recording ended. */
|
|
78
|
+
get pending() {
|
|
79
|
+
return this.#inFlight.size;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Pending requests bypass the log (they are synthesised at read time), so
|
|
83
|
+
* they need their own bound — a page holding open hundreds of long-poll
|
|
84
|
+
* connections would otherwise put every one of them in the response.
|
|
85
|
+
*/
|
|
86
|
+
#pendingOverflow() {
|
|
87
|
+
return Math.max(0, this.#inFlight.size - this.#limit);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Forget everything collected so far, in-flight requests included. See
|
|
91
|
+
* `BoundedLog.clear`.
|
|
92
|
+
*
|
|
93
|
+
* Dropping the in-flight map is deliberate: a request that was already
|
|
94
|
+
* running before this window started did not begin here, and reporting it
|
|
95
|
+
* as this action's pending request would be a lie about cause.
|
|
96
|
+
*/
|
|
97
|
+
clear() {
|
|
98
|
+
this.#log.clear();
|
|
99
|
+
this.#inFlight.clear();
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Collected events in settle order, with timestamps rebased onto the
|
|
103
|
+
* recording clock (`origin` is the recording's start in epoch ms). Requests
|
|
104
|
+
* still in flight are appended as `pending`, timestamped when they started,
|
|
105
|
+
* so they sort next to the frame that issued them.
|
|
106
|
+
*/
|
|
107
|
+
events(origin) {
|
|
108
|
+
const settled = this.#log.items.map((record) => ({
|
|
109
|
+
method: record.method,
|
|
110
|
+
url: record.url,
|
|
111
|
+
status: record.status,
|
|
112
|
+
duration_ms: record.duration_ms,
|
|
113
|
+
timestamp_ms: Math.round(record.at - origin),
|
|
114
|
+
...(record.error !== undefined ? { error: record.error } : {}),
|
|
115
|
+
}));
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
let room = this.#limit;
|
|
118
|
+
for (const [request, info] of this.#inFlight) {
|
|
119
|
+
if (room-- <= 0)
|
|
120
|
+
break;
|
|
121
|
+
settled.push({
|
|
122
|
+
method: request.method(),
|
|
123
|
+
url: shortenUrl(request.url()),
|
|
124
|
+
status: 0,
|
|
125
|
+
duration_ms: Math.max(0, Math.round(now - info.started)),
|
|
126
|
+
timestamp_ms: Math.round(info.started - origin),
|
|
127
|
+
error: "pending",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return settled.sort((a, b) => a.timestamp_ms - b.timestamp_ms);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Move a request out of flight and into the log. Chromium's timing gives an
|
|
134
|
+
* absolute `startTime` and offsets from it; `responseEnd` is -1 for a request
|
|
135
|
+
* that never got that far, so both the duration and the settle time fall back
|
|
136
|
+
* to wall clock.
|
|
137
|
+
*/
|
|
138
|
+
#settle(request, error) {
|
|
139
|
+
const pending = this.#inFlight.get(request);
|
|
140
|
+
if (!pending)
|
|
141
|
+
return;
|
|
142
|
+
this.#inFlight.delete(request);
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
let duration = now - pending.started;
|
|
145
|
+
let at = now;
|
|
146
|
+
try {
|
|
147
|
+
const timing = request.timing();
|
|
148
|
+
if (timing.startTime > 0 && timing.responseEnd >= 0) {
|
|
149
|
+
duration = timing.responseEnd;
|
|
150
|
+
at = timing.startTime + timing.responseEnd;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Timing is unavailable once the page is gone; wall clock still is.
|
|
155
|
+
}
|
|
156
|
+
this.#log.add({
|
|
157
|
+
at,
|
|
158
|
+
method: request.method(),
|
|
159
|
+
// Shortened here rather than on the way out: a page can request a
|
|
160
|
+
// multi-megabyte data: URI, and holding a hundred of those until the
|
|
161
|
+
// recording ends is a lot of memory for text that will be elided anyway.
|
|
162
|
+
url: shortenUrl(request.url()),
|
|
163
|
+
status: pending.status,
|
|
164
|
+
duration_ms: Math.max(0, Math.round(duration)),
|
|
165
|
+
...(error !== undefined ? { error } : {}),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=network.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"network.js","sourceRoot":"","sources":["../../../src/engine/layers/network.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Page, Request, Response } from \"playwright\";\nimport { MAX_NETWORK_EVENTS, MAX_NETWORK_URL_LENGTH } from \"../../constants.js\";\nimport type { NetworkEvent } from \"../../types.js\";\nimport { BoundedLog } from \"../../utils/bounded-log.js\";\n\n/**\n * Network layer.\n *\n * Records one event per request that settles during a capture: method, url,\n * status, how long it took, and when it landed. A request is timestamped at\n * the moment it *settled*, not when it was sent, because that is the instant\n * that can explain the frame next to it — a spinner disappears when the\n * response arrives, not when the fetch was issued.\n *\n * Durations come from Chromium's own `request.timing()` rather than from wall\n * clock in the event handler, so they measure the request and not Node's event\n * loop. Failed requests have no timing, and fall back to wall clock.\n *\n * Requests still in flight when the recording ends are reported too, as\n * `status: 0, error: \"pending\"` — a request that never comes back is a finding,\n * and silently dropping it would hide exactly the bug a developer is chasing.\n */\n\n/** One settled request, stamped with absolute (epoch) time and an already-shortened url. */\nexport interface NetworkRecord {\n at: number;\n method: string;\n url: string;\n status: number;\n duration_ms: number;\n error?: string;\n}\n\n/** In-flight bookkeeping for a request we have seen start but not settle. */\ninterface InFlight {\n started: number;\n status: number;\n}\n\n/**\n * Shorten a URL for display, keeping both ends: the path says what was asked\n * for and the tail usually carries the identifying part of a query string.\n * data: URIs are collapsed outright — they are content, not an address.\n */\nexport function shortenUrl(url: string, maxLength: number = MAX_NETWORK_URL_LENGTH): string {\n if (url.startsWith(\"data:\")) {\n const comma = url.indexOf(\",\");\n const head = comma === -1 ? url.slice(0, 40) : url.slice(0, Math.min(comma, 40));\n return `${head},…(${url.length} chars)`;\n }\n if (url.length <= maxLength) return url;\n const keepEnd = Math.floor((maxLength - 1) / 3);\n const keepStart = maxLength - 1 - keepEnd;\n return `${url.slice(0, keepStart)}…${url.slice(url.length - keepEnd)}`;\n}\n\n/** A response worth keeping when the cap is full: anything that failed or errored. */\nfunction isNotable(record: NetworkRecord): boolean {\n return record.error !== undefined || record.status === 0 || record.status >= 400;\n}\n\nexport class NetworkCollector {\n readonly #page: Page;\n readonly #limit: number;\n readonly #log: BoundedLog<NetworkRecord>;\n /** Requests seen start but not yet settled. Keyed by Playwright's Request identity. */\n readonly #inFlight = new Map<Request, InFlight>();\n #attached = false;\n\n readonly #onRequest = (request: Request): void => {\n this.#inFlight.set(request, { started: Date.now(), status: 0 });\n };\n\n readonly #onResponse = (response: Response): void => {\n // Read the status synchronously here; at `requestfinished` it would cost an\n // await, and an async handler would reorder events against the recording.\n const pending = this.#inFlight.get(response.request());\n if (pending) pending.status = response.status();\n };\n\n readonly #onFinished = (request: Request): void => {\n this.#settle(request);\n };\n\n readonly #onFailed = (request: Request): void => {\n this.#settle(request, request.failure()?.errorText ?? \"request failed\");\n };\n\n constructor(page: Page, limit: number = MAX_NETWORK_EVENTS) {\n this.#page = page;\n this.#limit = Math.max(1, Math.floor(limit));\n this.#log = new BoundedLog<NetworkRecord>(limit, isNotable);\n }\n\n /** Start listening. Idempotent. */\n attach(): this {\n if (this.#attached) return this;\n this.#attached = true;\n this.#page.on(\"request\", this.#onRequest);\n this.#page.on(\"response\", this.#onResponse);\n this.#page.on(\"requestfinished\", this.#onFinished);\n this.#page.on(\"requestfailed\", this.#onFailed);\n return this;\n }\n\n /** Stop listening. Collected events are kept. */\n detach(): void {\n if (!this.#attached) return;\n this.#attached = false;\n this.#page.off(\"request\", this.#onRequest);\n this.#page.off(\"response\", this.#onResponse);\n this.#page.off(\"requestfinished\", this.#onFinished);\n this.#page.off(\"requestfailed\", this.#onFailed);\n }\n\n /** Events refused or evicted by the cap, settled and pending alike. */\n get dropped(): number {\n return this.#log.dropped + this.#pendingOverflow();\n }\n\n /** Requests that never settled before the recording ended. */\n get pending(): number {\n return this.#inFlight.size;\n }\n\n /**\n * Pending requests bypass the log (they are synthesised at read time), so\n * they need their own bound — a page holding open hundreds of long-poll\n * connections would otherwise put every one of them in the response.\n */\n #pendingOverflow(): number {\n return Math.max(0, this.#inFlight.size - this.#limit);\n }\n\n /**\n * Forget everything collected so far, in-flight requests included. See\n * `BoundedLog.clear`.\n *\n * Dropping the in-flight map is deliberate: a request that was already\n * running before this window started did not begin here, and reporting it\n * as this action's pending request would be a lie about cause.\n */\n clear(): void {\n this.#log.clear();\n this.#inFlight.clear();\n }\n\n /**\n * Collected events in settle order, with timestamps rebased onto the\n * recording clock (`origin` is the recording's start in epoch ms). Requests\n * still in flight are appended as `pending`, timestamped when they started,\n * so they sort next to the frame that issued them.\n */\n events(origin: number): NetworkEvent[] {\n const settled: NetworkEvent[] = this.#log.items.map((record) => ({\n method: record.method,\n url: record.url,\n status: record.status,\n duration_ms: record.duration_ms,\n timestamp_ms: Math.round(record.at - origin),\n ...(record.error !== undefined ? { error: record.error } : {}),\n }));\n\n const now = Date.now();\n let room = this.#limit;\n for (const [request, info] of this.#inFlight) {\n if (room-- <= 0) break;\n settled.push({\n method: request.method(),\n url: shortenUrl(request.url()),\n status: 0,\n duration_ms: Math.max(0, Math.round(now - info.started)),\n timestamp_ms: Math.round(info.started - origin),\n error: \"pending\",\n });\n }\n return settled.sort((a, b) => a.timestamp_ms - b.timestamp_ms);\n }\n\n /**\n * Move a request out of flight and into the log. Chromium's timing gives an\n * absolute `startTime` and offsets from it; `responseEnd` is -1 for a request\n * that never got that far, so both the duration and the settle time fall back\n * to wall clock.\n */\n #settle(request: Request, error?: string): void {\n const pending = this.#inFlight.get(request);\n if (!pending) return;\n this.#inFlight.delete(request);\n\n const now = Date.now();\n let duration = now - pending.started;\n let at = now;\n try {\n const timing = request.timing();\n if (timing.startTime > 0 && timing.responseEnd >= 0) {\n duration = timing.responseEnd;\n at = timing.startTime + timing.responseEnd;\n }\n } catch {\n // Timing is unavailable once the page is gone; wall clock still is.\n }\n\n this.#log.add({\n at,\n method: request.method(),\n // Shortened here rather than on the way out: a page can request a\n // multi-megabyte data: URI, and holding a hundred of those until the\n // recording ends is a lot of memory for text that will be elided anyway.\n url: shortenUrl(request.url()),\n status: pending.status,\n duration_ms: Math.max(0, Math.round(duration)),\n ...(error !== undefined ? { error } : {}),\n });\n }\n}\n"]}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
import type { PerformanceInfo } from "../../types.js";
|
|
3
|
+
import { type InstallOptions } from "./probe.js";
|
|
4
|
+
export type PerfKind = "paint" | "lcp" | "shift";
|
|
5
|
+
/** One performance entry, as pushed by the page. Timestamps are absolute (epoch ms). */
|
|
6
|
+
export interface RawPerfRecord {
|
|
7
|
+
/** Absolute time the browser recorded the entry (not when the observer saw it). */
|
|
8
|
+
t: number;
|
|
9
|
+
kind: PerfKind;
|
|
10
|
+
/** `entry.startTime` — ms since this document's navigation start. */
|
|
11
|
+
start: number;
|
|
12
|
+
/** Paint entry name: "first-contentful-paint" or "first-paint". */
|
|
13
|
+
name?: string;
|
|
14
|
+
/** Layout shift score. */
|
|
15
|
+
value?: number;
|
|
16
|
+
}
|
|
17
|
+
/** One performance entry, rebased onto the recording clock. */
|
|
18
|
+
export interface PerfSample {
|
|
19
|
+
timestamp_ms: number;
|
|
20
|
+
kind: PerfKind;
|
|
21
|
+
start_ms: number;
|
|
22
|
+
name?: string;
|
|
23
|
+
value?: number;
|
|
24
|
+
}
|
|
25
|
+
export declare class PerformanceCollector {
|
|
26
|
+
#private;
|
|
27
|
+
constructor(page: Page, limit?: number);
|
|
28
|
+
/**
|
|
29
|
+
* Install the probe. Call before the page navigates, or pass
|
|
30
|
+
* `{ runNow: true }` to also measure the document that is already loaded —
|
|
31
|
+
* `buffered: true` means the observers still receive what it recorded
|
|
32
|
+
* earlier, so a page that painted before this ran is not lost.
|
|
33
|
+
*/
|
|
34
|
+
attach(options?: InstallOptions): Promise<this>;
|
|
35
|
+
/** Entries the cap refused. */
|
|
36
|
+
get dropped(): number;
|
|
37
|
+
/** Forget everything collected so far. See `BoundedLog.clear`. */
|
|
38
|
+
clear(): void;
|
|
39
|
+
/**
|
|
40
|
+
* Collected entries in time order, rebased onto the recording clock
|
|
41
|
+
* (`origin` is the recording's start in epoch ms). Entries from before the
|
|
42
|
+
* recording started keep their negative timestamp: first paint routinely
|
|
43
|
+
* happens before frame 0, and belongs on the first card.
|
|
44
|
+
*/
|
|
45
|
+
samples(origin: number): PerfSample[];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Reduce one card's entries to the numbers worth printing beside it.
|
|
49
|
+
*
|
|
50
|
+
* Only what actually happened in this window is reported — LCP is not repeated
|
|
51
|
+
* on every card after it was measured, because a number that never changes on
|
|
52
|
+
* twenty cards is noise, not context. Returns undefined when the window is
|
|
53
|
+
* empty, so cards with nothing to report carry no Performance section at all.
|
|
54
|
+
*/
|
|
55
|
+
export declare function summarisePerformance(samples: PerfSample[]): PerformanceInfo | undefined;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { MAX_PERF_SAMPLES } from "../../constants.js";
|
|
2
|
+
import { installProbe, probeConfig } from "./probe.js";
|
|
3
|
+
/**
|
|
4
|
+
* Performance layer.
|
|
5
|
+
*
|
|
6
|
+
* Three PerformanceObservers installed at document start report when the
|
|
7
|
+
* browser first painted, when it settled on the largest contentful element,
|
|
8
|
+
* and every time the layout jumped. Each entry carries two clocks:
|
|
9
|
+
* `entry.startTime` (the metric itself — ms since *this document's*
|
|
10
|
+
* navigation start, so an LCP of 800ms means the same thing here as in
|
|
11
|
+
* Lighthouse) and, derived from it and `performance.timeOrigin`, the
|
|
12
|
+
* wall-clock instant it happened, which decides the diff card it lands on.
|
|
13
|
+
*
|
|
14
|
+
* `buffered: true` means the observers also receive entries recorded before
|
|
15
|
+
* they were registered, so nothing is lost to the gap between document start
|
|
16
|
+
* and the first observer callback. After a navigation the timers restart with
|
|
17
|
+
* the new document, which is correct: the new page's LCP is not a continuation
|
|
18
|
+
* of the old page's.
|
|
19
|
+
*
|
|
20
|
+
* Layout shifts are counted whether or not they followed user input, unlike
|
|
21
|
+
* Chrome's CLS. FrameWatch is watching what the page *looks* like, and a jump
|
|
22
|
+
* that happens right after a click is often exactly the one being hunted.
|
|
23
|
+
*/
|
|
24
|
+
const BINDING = "__framewatch_perf";
|
|
25
|
+
/**
|
|
26
|
+
* The page-side probe. Written against `globalThis` for the same reasons as
|
|
27
|
+
* the DOM probe: no DOM lib at compile time, and everything it touches may be
|
|
28
|
+
* missing in the page it lands in (PerformanceObserver entry types vary by
|
|
29
|
+
* browser and are gated behind flags in some embedded contexts).
|
|
30
|
+
*/
|
|
31
|
+
const PERF_PROBE = (config) => {
|
|
32
|
+
const g = globalThis;
|
|
33
|
+
// Main frame only: LCP, FCP and layout shift are whole-page metrics.
|
|
34
|
+
if (g.top && g.top !== g)
|
|
35
|
+
return;
|
|
36
|
+
// The probe can be asked for twice on one document (installed on a page that
|
|
37
|
+
// is already open, then re-run by the init script); a second set of observers
|
|
38
|
+
// would report every entry twice.
|
|
39
|
+
const installed = config.binding + "_observing";
|
|
40
|
+
if (g[installed])
|
|
41
|
+
return;
|
|
42
|
+
g[installed] = true;
|
|
43
|
+
const send = g[config.binding];
|
|
44
|
+
if (typeof send !== "function" || typeof g.PerformanceObserver !== "function")
|
|
45
|
+
return;
|
|
46
|
+
let queue = [];
|
|
47
|
+
let scheduled = false;
|
|
48
|
+
let budget = config.max_records;
|
|
49
|
+
const flush = () => {
|
|
50
|
+
scheduled = false;
|
|
51
|
+
if (queue.length === 0)
|
|
52
|
+
return;
|
|
53
|
+
const batch = queue;
|
|
54
|
+
queue = [];
|
|
55
|
+
try {
|
|
56
|
+
const result = send(batch);
|
|
57
|
+
if (result && typeof result.catch === "function")
|
|
58
|
+
result.catch(() => { });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// The page is being torn down; there is nowhere left to push to.
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const push = (record) => {
|
|
65
|
+
if (budget <= 0)
|
|
66
|
+
return;
|
|
67
|
+
budget--;
|
|
68
|
+
if (queue.length >= config.max_batch)
|
|
69
|
+
flush();
|
|
70
|
+
queue.push(record);
|
|
71
|
+
if (!scheduled) {
|
|
72
|
+
scheduled = true;
|
|
73
|
+
g.setTimeout(flush, config.flush_ms);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
try {
|
|
77
|
+
g.addEventListener("pagehide", flush, true);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Not fatal: records wait for the timer instead.
|
|
81
|
+
}
|
|
82
|
+
// `entry.startTime` is measured from this document's navigation start, and
|
|
83
|
+
// `performance.timeOrigin` is that instant in epoch ms — so the two together
|
|
84
|
+
// give the wall-clock moment the browser actually painted or shifted. That is
|
|
85
|
+
// what a record has to be stamped with: an observer callback can run long
|
|
86
|
+
// after the entry it reports (LCP in particular), and stamping with Date.now()
|
|
87
|
+
// would file a paint on whichever frame happened to be next.
|
|
88
|
+
const origin = typeof g.performance?.timeOrigin === "number" ? g.performance.timeOrigin : null;
|
|
89
|
+
const observe = (type, kind) => {
|
|
90
|
+
try {
|
|
91
|
+
new g.PerformanceObserver((list) => {
|
|
92
|
+
for (const entry of list.getEntries()) {
|
|
93
|
+
const start = typeof entry.startTime === "number" ? entry.startTime : 0;
|
|
94
|
+
push({
|
|
95
|
+
t: origin === null ? Date.now() : origin + start,
|
|
96
|
+
kind,
|
|
97
|
+
start,
|
|
98
|
+
...(kind === "paint" ? { name: String(entry.name || "") } : {}),
|
|
99
|
+
...(kind === "shift" ? { value: typeof entry.value === "number" ? entry.value : 0 } : {}),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}).observe({ type, buffered: true });
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// This entry type is unsupported here; the others still report.
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
observe("paint", "paint");
|
|
109
|
+
observe("largest-contentful-paint", "lcp");
|
|
110
|
+
observe("layout-shift", "shift");
|
|
111
|
+
};
|
|
112
|
+
export class PerformanceCollector {
|
|
113
|
+
#page;
|
|
114
|
+
#limit;
|
|
115
|
+
#records = [];
|
|
116
|
+
#dropped = 0;
|
|
117
|
+
constructor(page, limit = MAX_PERF_SAMPLES) {
|
|
118
|
+
this.#page = page;
|
|
119
|
+
this.#limit = Math.max(1, limit);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Install the probe. Call before the page navigates, or pass
|
|
123
|
+
* `{ runNow: true }` to also measure the document that is already loaded —
|
|
124
|
+
* `buffered: true` means the observers still receive what it recorded
|
|
125
|
+
* earlier, so a page that painted before this ran is not lost.
|
|
126
|
+
*/
|
|
127
|
+
async attach(options = {}) {
|
|
128
|
+
const config = probeConfig(BINDING);
|
|
129
|
+
await installProbe(this.#page, config, PERF_PROBE, (batch) => this.#ingest(batch), options);
|
|
130
|
+
return this;
|
|
131
|
+
}
|
|
132
|
+
/** Entries the cap refused. */
|
|
133
|
+
get dropped() {
|
|
134
|
+
return this.#dropped;
|
|
135
|
+
}
|
|
136
|
+
/** Forget everything collected so far. See `BoundedLog.clear`. */
|
|
137
|
+
clear() {
|
|
138
|
+
this.#records.length = 0;
|
|
139
|
+
this.#dropped = 0;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Collected entries in time order, rebased onto the recording clock
|
|
143
|
+
* (`origin` is the recording's start in epoch ms). Entries from before the
|
|
144
|
+
* recording started keep their negative timestamp: first paint routinely
|
|
145
|
+
* happens before frame 0, and belongs on the first card.
|
|
146
|
+
*/
|
|
147
|
+
samples(origin) {
|
|
148
|
+
return this.#records.map((record) => ({
|
|
149
|
+
timestamp_ms: Math.round(record.t - origin),
|
|
150
|
+
kind: record.kind,
|
|
151
|
+
start_ms: Math.round(record.start),
|
|
152
|
+
...(record.name !== undefined ? { name: record.name } : {}),
|
|
153
|
+
...(record.value !== undefined ? { value: record.value } : {}),
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
#ingest(batch) {
|
|
157
|
+
for (const record of batch) {
|
|
158
|
+
// The page pushes this, so nothing about it is trusted.
|
|
159
|
+
if (!record || typeof record.t !== "number" || typeof record.start !== "number")
|
|
160
|
+
continue;
|
|
161
|
+
if (record.kind !== "paint" && record.kind !== "lcp" && record.kind !== "shift")
|
|
162
|
+
continue;
|
|
163
|
+
if (this.#records.length >= this.#limit) {
|
|
164
|
+
this.#dropped++;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
this.#records.push(record);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Reduce one card's entries to the numbers worth printing beside it.
|
|
173
|
+
*
|
|
174
|
+
* Only what actually happened in this window is reported — LCP is not repeated
|
|
175
|
+
* on every card after it was measured, because a number that never changes on
|
|
176
|
+
* twenty cards is noise, not context. Returns undefined when the window is
|
|
177
|
+
* empty, so cards with nothing to report carry no Performance section at all.
|
|
178
|
+
*/
|
|
179
|
+
export function summarisePerformance(samples) {
|
|
180
|
+
if (samples.length === 0)
|
|
181
|
+
return undefined;
|
|
182
|
+
let paint;
|
|
183
|
+
let lcp;
|
|
184
|
+
let shifts = 0;
|
|
185
|
+
let score = 0;
|
|
186
|
+
for (const sample of samples) {
|
|
187
|
+
if (sample.kind === "paint") {
|
|
188
|
+
// Prefer first-contentful-paint; first-paint is the fallback when the
|
|
189
|
+
// browser reported only that (both arrive together on a normal load).
|
|
190
|
+
if (!paint || (paint.name !== "first-contentful-paint" && sample.name === "first-contentful-paint")) {
|
|
191
|
+
paint = sample;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else if (sample.kind === "lcp") {
|
|
195
|
+
// LCP grows as the browser finds larger elements: the last one wins.
|
|
196
|
+
if (!lcp || sample.start_ms >= lcp.start_ms)
|
|
197
|
+
lcp = sample;
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
shifts++;
|
|
201
|
+
score += sample.value ?? 0;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const info = {};
|
|
205
|
+
if (paint)
|
|
206
|
+
info.paint_time_ms = paint.start_ms;
|
|
207
|
+
if (shifts > 0) {
|
|
208
|
+
info.layout_shifts = shifts;
|
|
209
|
+
info.layout_shift_score = Math.round(score * 10_000) / 10_000;
|
|
210
|
+
}
|
|
211
|
+
if (lcp)
|
|
212
|
+
info.lcp_ms = lcp.start_ms;
|
|
213
|
+
return Object.keys(info).length > 0 ? info : undefined;
|
|
214
|
+
}
|
|
215
|
+
//# sourceMappingURL=performance.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"performance.js","sourceRoot":"","sources":["../../../src/engine/layers/performance.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Page } from \"playwright\";\nimport { MAX_PERF_SAMPLES } from \"../../constants.js\";\nimport type { PerformanceInfo } from \"../../types.js\";\nimport { installProbe, probeConfig, type InstallOptions, type ProbeConfig } from \"./probe.js\";\n\n/**\n * Performance layer.\n *\n * Three PerformanceObservers installed at document start report when the\n * browser first painted, when it settled on the largest contentful element,\n * and every time the layout jumped. Each entry carries two clocks:\n * `entry.startTime` (the metric itself — ms since *this document's*\n * navigation start, so an LCP of 800ms means the same thing here as in\n * Lighthouse) and, derived from it and `performance.timeOrigin`, the\n * wall-clock instant it happened, which decides the diff card it lands on.\n *\n * `buffered: true` means the observers also receive entries recorded before\n * they were registered, so nothing is lost to the gap between document start\n * and the first observer callback. After a navigation the timers restart with\n * the new document, which is correct: the new page's LCP is not a continuation\n * of the old page's.\n *\n * Layout shifts are counted whether or not they followed user input, unlike\n * Chrome's CLS. FrameWatch is watching what the page *looks* like, and a jump\n * that happens right after a click is often exactly the one being hunted.\n */\n\nconst BINDING = \"__framewatch_perf\";\n\nexport type PerfKind = \"paint\" | \"lcp\" | \"shift\";\n\n/** One performance entry, as pushed by the page. Timestamps are absolute (epoch ms). */\nexport interface RawPerfRecord {\n /** Absolute time the browser recorded the entry (not when the observer saw it). */\n t: number;\n kind: PerfKind;\n /** `entry.startTime` — ms since this document's navigation start. */\n start: number;\n /** Paint entry name: \"first-contentful-paint\" or \"first-paint\". */\n name?: string;\n /** Layout shift score. */\n value?: number;\n}\n\n/** One performance entry, rebased onto the recording clock. */\nexport interface PerfSample {\n timestamp_ms: number;\n kind: PerfKind;\n start_ms: number;\n name?: string;\n value?: number;\n}\n\n/**\n * The page-side probe. Written against `globalThis` for the same reasons as\n * the DOM probe: no DOM lib at compile time, and everything it touches may be\n * missing in the page it lands in (PerformanceObserver entry types vary by\n * browser and are gated behind flags in some embedded contexts).\n */\nconst PERF_PROBE = (config: ProbeConfig): void => {\n const g = globalThis as any;\n // Main frame only: LCP, FCP and layout shift are whole-page metrics.\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 set of observers\n // would report every entry twice.\n const installed = config.binding + \"_observing\";\n if (g[installed]) return;\n g[installed] = true;\n const send = g[config.binding];\n if (typeof send !== \"function\" || typeof g.PerformanceObserver !== \"function\") return;\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 // `entry.startTime` is measured from this document's navigation start, and\n // `performance.timeOrigin` is that instant in epoch ms — so the two together\n // give the wall-clock moment the browser actually painted or shifted. That is\n // what a record has to be stamped with: an observer callback can run long\n // after the entry it reports (LCP in particular), and stamping with Date.now()\n // would file a paint on whichever frame happened to be next.\n const origin = typeof g.performance?.timeOrigin === \"number\" ? g.performance.timeOrigin : null;\n\n const observe = (type: string, kind: string): void => {\n try {\n new g.PerformanceObserver((list: any) => {\n for (const entry of list.getEntries()) {\n const start = typeof entry.startTime === \"number\" ? entry.startTime : 0;\n push({\n t: origin === null ? Date.now() : origin + start,\n kind,\n start,\n ...(kind === \"paint\" ? { name: String(entry.name || \"\") } : {}),\n ...(kind === \"shift\" ? { value: typeof entry.value === \"number\" ? entry.value : 0 } : {}),\n });\n }\n }).observe({ type, buffered: true });\n } catch {\n // This entry type is unsupported here; the others still report.\n }\n };\n\n observe(\"paint\", \"paint\");\n observe(\"largest-contentful-paint\", \"lcp\");\n observe(\"layout-shift\", \"shift\");\n};\n\nexport class PerformanceCollector {\n readonly #page: Page;\n readonly #limit: number;\n readonly #records: RawPerfRecord[] = [];\n #dropped = 0;\n\n constructor(page: Page, limit: number = MAX_PERF_SAMPLES) {\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 measure the document that is already loaded —\n * `buffered: true` means the observers still receive what it recorded\n * earlier, so a page that painted before this ran is not lost.\n */\n async attach(options: InstallOptions = {}): Promise<this> {\n const config = probeConfig(BINDING);\n await installProbe<RawPerfRecord>(this.#page, config, PERF_PROBE, (batch) => this.#ingest(batch), options);\n return this;\n }\n\n /** Entries 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 entries in time order, rebased onto the recording clock\n * (`origin` is the recording's start in epoch ms). Entries from before the\n * recording started keep their negative timestamp: first paint routinely\n * happens before frame 0, and belongs on the first card.\n */\n samples(origin: number): PerfSample[] {\n return this.#records.map((record) => ({\n timestamp_ms: Math.round(record.t - origin),\n kind: record.kind,\n start_ms: Math.round(record.start),\n ...(record.name !== undefined ? { name: record.name } : {}),\n ...(record.value !== undefined ? { value: record.value } : {}),\n }));\n }\n\n #ingest(batch: RawPerfRecord[]): 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.start !== \"number\") continue;\n if (record.kind !== \"paint\" && record.kind !== \"lcp\" && record.kind !== \"shift\") 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 * Reduce one card's entries to the numbers worth printing beside it.\n *\n * Only what actually happened in this window is reported — LCP is not repeated\n * on every card after it was measured, because a number that never changes on\n * twenty cards is noise, not context. Returns undefined when the window is\n * empty, so cards with nothing to report carry no Performance section at all.\n */\nexport function summarisePerformance(samples: PerfSample[]): PerformanceInfo | undefined {\n if (samples.length === 0) return undefined;\n\n let paint: PerfSample | undefined;\n let lcp: PerfSample | undefined;\n let shifts = 0;\n let score = 0;\n\n for (const sample of samples) {\n if (sample.kind === \"paint\") {\n // Prefer first-contentful-paint; first-paint is the fallback when the\n // browser reported only that (both arrive together on a normal load).\n if (!paint || (paint.name !== \"first-contentful-paint\" && sample.name === \"first-contentful-paint\")) {\n paint = sample;\n }\n } else if (sample.kind === \"lcp\") {\n // LCP grows as the browser finds larger elements: the last one wins.\n if (!lcp || sample.start_ms >= lcp.start_ms) lcp = sample;\n } else {\n shifts++;\n score += sample.value ?? 0;\n }\n }\n\n const info: PerformanceInfo = {};\n if (paint) info.paint_time_ms = paint.start_ms;\n if (shifts > 0) {\n info.layout_shifts = shifts;\n info.layout_shift_score = Math.round(score * 10_000) / 10_000;\n }\n if (lcp) info.lcp_ms = lcp.start_ms;\n return Object.keys(info).length > 0 ? info : undefined;\n}\n"]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
/**
|
|
3
|
+
* In-page probe plumbing, shared by the DOM and performance layers.
|
|
4
|
+
*
|
|
5
|
+
* Console and network are Playwright events, but "what changed in the DOM" and
|
|
6
|
+
* "when did the browser paint" only exist inside the page, so those two layers
|
|
7
|
+
* inject an observer and push what it sees back to Node.
|
|
8
|
+
*
|
|
9
|
+
* The push is an `exposeBinding` function rather than one `page.evaluate` at
|
|
10
|
+
* the end of the recording, for one reason: a navigation destroys the document
|
|
11
|
+
* and everything buffered in it. Bindings and init scripts are reinstalled on
|
|
12
|
+
* every new document, so a capture that navigates keeps the records from both
|
|
13
|
+
* pages — and a capture whose page freezes or dies keeps whatever it pushed
|
|
14
|
+
* before it went.
|
|
15
|
+
*
|
|
16
|
+
* Each probe inlines its own batching queue (page scripts cannot import, and
|
|
17
|
+
* `eval`ing a shared one would break on any page with a strict CSP). Records
|
|
18
|
+
* are stamped when they are *made*, never when their batch is flushed, so
|
|
19
|
+
* batching cannot move a record onto the wrong diff card.
|
|
20
|
+
*/
|
|
21
|
+
/** Config handed to the page-side script. Must stay JSON-serialisable. */
|
|
22
|
+
export interface ProbeConfig {
|
|
23
|
+
/** Name of the `window` function the page pushes batches through. */
|
|
24
|
+
binding: string;
|
|
25
|
+
/** How long to coalesce records before pushing a batch. */
|
|
26
|
+
flush_ms: number;
|
|
27
|
+
/** Records one batch may carry. */
|
|
28
|
+
max_batch: number;
|
|
29
|
+
/** Records the probe may push over the lifetime of one document. */
|
|
30
|
+
max_records: number;
|
|
31
|
+
}
|
|
32
|
+
export declare function probeConfig(binding: string): ProbeConfig;
|
|
33
|
+
/**
|
|
34
|
+
* Expose `config.binding` on the page and arrange for `script` to run at the
|
|
35
|
+
* start of every document (including after a navigation).
|
|
36
|
+
*
|
|
37
|
+
* `onBatch` must never throw: it runs as the resolution of a promise the page
|
|
38
|
+
* is holding, so a throw here surfaces inside the page under test as an
|
|
39
|
+
* unhandled rejection — which the console layer would then dutifully report as
|
|
40
|
+
* a bug in the user's app. It is wrapped here so callers cannot get that wrong.
|
|
41
|
+
*/
|
|
42
|
+
export declare function installProbe<T>(page: Page, config: ProbeConfig, script: (config: ProbeConfig) => void, onBatch: (records: T[]) => void, options?: InstallOptions): Promise<void>;
|
|
43
|
+
export interface InstallOptions {
|
|
44
|
+
/**
|
|
45
|
+
* Also run the probe against the document the page already has. Needed for
|
|
46
|
+
* `framewatch_interact`, which acts on a page that is open before the layer
|
|
47
|
+
* is asked for; captures attach before navigating and do not need it.
|
|
48
|
+
*/
|
|
49
|
+
runNow?: boolean;
|
|
50
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { LAYER_FLUSH_MS, MAX_LAYER_BATCH, MAX_LAYER_RECORDS_PER_DOCUMENT } from "../../constants.js";
|
|
2
|
+
export function probeConfig(binding) {
|
|
3
|
+
return {
|
|
4
|
+
binding,
|
|
5
|
+
flush_ms: LAYER_FLUSH_MS,
|
|
6
|
+
max_batch: MAX_LAYER_BATCH,
|
|
7
|
+
max_records: MAX_LAYER_RECORDS_PER_DOCUMENT,
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Expose `config.binding` on the page and arrange for `script` to run at the
|
|
12
|
+
* start of every document (including after a navigation).
|
|
13
|
+
*
|
|
14
|
+
* `onBatch` must never throw: it runs as the resolution of a promise the page
|
|
15
|
+
* is holding, so a throw here surfaces inside the page under test as an
|
|
16
|
+
* unhandled rejection — which the console layer would then dutifully report as
|
|
17
|
+
* a bug in the user's app. It is wrapped here so callers cannot get that wrong.
|
|
18
|
+
*/
|
|
19
|
+
export async function installProbe(page, config, script, onBatch, options = {}) {
|
|
20
|
+
await page.exposeBinding(config.binding, (_source, batch) => {
|
|
21
|
+
try {
|
|
22
|
+
if (Array.isArray(batch))
|
|
23
|
+
onBatch(batch);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// A collector must never break the page it is watching.
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
await page.addInitScript(script, config);
|
|
30
|
+
if (options.runNow === true) {
|
|
31
|
+
// Init scripts only reach *new* documents, so a probe installed on a page
|
|
32
|
+
// that is already loaded would watch nothing until the next navigation.
|
|
33
|
+
// Running it once by hand covers the document that is already there; the
|
|
34
|
+
// probes guard against being installed twice, so the init script running
|
|
35
|
+
// later on the same document is harmless.
|
|
36
|
+
await page.evaluate(script, config).catch(() => { });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=probe.js.map
|