@lightpath-dev/browser 0.0.0-stage → 0.0.2
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 +4 -2
- package/dist/collector.d.ts +38 -0
- package/dist/collector.js +427 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/launch.d.ts +8 -0
- package/dist/launch.js +17 -0
- package/dist/rules.d.ts +29 -0
- package/dist/rules.js +57 -0
- package/dist/settle.d.ts +23 -0
- package/dist/settle.js +59 -0
- package/package.json +45 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lightpath contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lightpath-dev/browser
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The browser collector of [Lightpath](https://github.com/Unknown-333/Lightpath#readme). It launches Chrome or Edge, marks the start of each click, and collects the frontend code that click ran.
|
|
4
|
+
|
|
5
|
+
You don't need this package directly; [`@lightpath-dev/cli`](https://www.npmjs.com/package/@lightpath-dev/cli) uses it.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type IngestMessage } from '@lightpath-dev/protocol';
|
|
2
|
+
import type { Page } from 'playwright-core';
|
|
3
|
+
export interface CollectorOptions {
|
|
4
|
+
/** App URL to open, e.g. http://localhost:5173 */
|
|
5
|
+
url: string;
|
|
6
|
+
/** Origins whose fetch/XHR requests get the trace header. Defaults to the app origin. */
|
|
7
|
+
backendOrigins?: string[];
|
|
8
|
+
settleMs?: number;
|
|
9
|
+
maxSettleMs?: number;
|
|
10
|
+
headless?: boolean;
|
|
11
|
+
channel?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Coverage reads slower than this move the click boundary from pointer press to release
|
|
14
|
+
* (default 20 ms). Tests set 0 to force the release boundary.
|
|
15
|
+
*/
|
|
16
|
+
pressBudgetMs?: number;
|
|
17
|
+
/** Where ingest messages go; the CLI wires this to the daemon. */
|
|
18
|
+
send: (message: IngestMessage) => void;
|
|
19
|
+
log?: (msg: string) => void;
|
|
20
|
+
/** Messages the user should see even without --debug, like a boundary change. */
|
|
21
|
+
notice?: (msg: string) => void;
|
|
22
|
+
}
|
|
23
|
+
/** Script URL of the init script, so pauses inside it can be told apart from app pauses. */
|
|
24
|
+
export declare const HOOK_URL = "lightpath://hook.js";
|
|
25
|
+
/**
|
|
26
|
+
* Collects per-click frontend coverage from one Chromium page (docs/ARCHITECTURE.md section 5.1).
|
|
27
|
+
*
|
|
28
|
+
* While the page is paused in the hook: take coverage (background), open the trace, resume.
|
|
29
|
+
* After the settle window: take again; that delta is the click's frontend code.
|
|
30
|
+
*/
|
|
31
|
+
export declare class BrowserCollector {
|
|
32
|
+
#private;
|
|
33
|
+
constructor(opts: CollectorOptions);
|
|
34
|
+
get page(): Page | undefined;
|
|
35
|
+
start(): Promise<void>;
|
|
36
|
+
stop(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=collector.d.ts.map
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { newTraceId, webpackModulePath } from '@lightpath-dev/core';
|
|
2
|
+
import { TRACE_HEADER } from '@lightpath-dev/protocol';
|
|
3
|
+
import { launchBrowser } from './launch.js';
|
|
4
|
+
import { countsTowardSettle, isNoiseScript, labelFor, shouldTag } from './rules.js';
|
|
5
|
+
import { SettleTracker } from './settle.js';
|
|
6
|
+
/** Script URL of the init script, so pauses inside it can be told apart from app pauses. */
|
|
7
|
+
export const HOOK_URL = 'lightpath://hook.js';
|
|
8
|
+
const LAST = '__lightpathLast';
|
|
9
|
+
/** Function names of the hook's listeners, so the collector can tell which one paused. */
|
|
10
|
+
const HOOK_ACTION = 'lightpathAction';
|
|
11
|
+
const HOOK_LEAVE = 'lightpathLeave';
|
|
12
|
+
/** Set on window when the boundary moves to pointer release; read by the hook at event time. */
|
|
13
|
+
const RELEASE = '__lightpathRelease';
|
|
14
|
+
const PRESSED = '__lightpathPressed';
|
|
15
|
+
/**
|
|
16
|
+
* Longest coverage read the press boundary may cost. Chrome drops a mouse release that arrives
|
|
17
|
+
* while the page is paused at the press, so above this the boundary moves to the release, where a
|
|
18
|
+
* pause loses nothing (measured up to 200 ms). See bench/README.md.
|
|
19
|
+
*/
|
|
20
|
+
const PRESS_BUDGET_MS = 20;
|
|
21
|
+
/**
|
|
22
|
+
* Runs in every page before any app script (an init script):
|
|
23
|
+
* - registers the first capture `pointerdown` and `keydown` listeners on window, so they run before
|
|
24
|
+
* app handlers;
|
|
25
|
+
* - records a description of the target, then pauses with `debugger;` so the collector can take
|
|
26
|
+
* coverage before any app code for this action runs (ADR 0003).
|
|
27
|
+
*
|
|
28
|
+
* Keys: Enter on anything but a multi-line text field, and Space on buttons, links, checkboxes,
|
|
29
|
+
* radios, and ARIA widgets, which is what activates them. Space or Enter while typing is text, not
|
|
30
|
+
* an action. Held keys (`repeat`) and IME composition are ignored.
|
|
31
|
+
*
|
|
32
|
+
* One pause per action, in our own frame only. The pause must stay short: Chrome drops the
|
|
33
|
+
* pointerup, mouseup, and click events that arrive while the page is paused (measured). Key events
|
|
34
|
+
* aren't dropped: Enter and Space still activate every element after a 70 ms pause (measured).
|
|
35
|
+
*/
|
|
36
|
+
const HOOK_SOURCE = `(() => {
|
|
37
|
+
const describe = (el) => {
|
|
38
|
+
// The nearest control names the click; a data-testid only when no control encloses it. A
|
|
39
|
+
// test id inside a link (a balance span in Actual's sidebar) must not win over the link (D-098).
|
|
40
|
+
const hit = el && el.closest &&
|
|
41
|
+
(el.closest('button, a, [role], input, select, textarea, label, summary') || el.closest('[data-testid]'));
|
|
42
|
+
const t = hit || el;
|
|
43
|
+
if (!t || !t.tagName) return {};
|
|
44
|
+
// Outside any control, only a leaf element's own text is a fair label: a container's
|
|
45
|
+
// textContent includes every descendant, inline scripts too (seen on a real app, D-090).
|
|
46
|
+
const leaf = hit || (t.childElementCount === 0 && !/^(HTML|BODY|SCRIPT|STYLE)$/.test(t.tagName));
|
|
47
|
+
const text = leaf ? (t.textContent || '').trim().slice(0, 80) : '';
|
|
48
|
+
return {
|
|
49
|
+
tag: t.tagName.toLowerCase(),
|
|
50
|
+
role: t.getAttribute('role') || undefined,
|
|
51
|
+
name: t.getAttribute('aria-label') || t.getAttribute('name') || t.getAttribute('placeholder') || undefined,
|
|
52
|
+
testId: t.getAttribute('data-testid') || undefined,
|
|
53
|
+
text: text || undefined,
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
const TEXT_INPUTS = /^(text|search|email|url|tel|password|number|date|datetime-local|month|week|time|)$/;
|
|
57
|
+
const isTyping = (el) =>
|
|
58
|
+
!!el && (el.isContentEditable || el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && TEXT_INPUTS.test(el.type)));
|
|
59
|
+
const isAction = (event) => {
|
|
60
|
+
if (event.repeat || event.isComposing || event.altKey || event.ctrlKey || event.metaKey) return false;
|
|
61
|
+
const el = event.target;
|
|
62
|
+
if (event.key === 'Enter') return !(el && (el.isContentEditable || el.tagName === 'TEXTAREA'));
|
|
63
|
+
if (event.key === ' ') return !isTyping(el) && !!(el && el.closest && el.closest('button, a, input, summary, [role]'));
|
|
64
|
+
return false;
|
|
65
|
+
};
|
|
66
|
+
window.addEventListener('pointerdown', function ${HOOK_ACTION}(event) {
|
|
67
|
+
if (!event.isPrimary) return;
|
|
68
|
+
window.${LAST} = describe(event.target);
|
|
69
|
+
// Release boundary (large apps): note the press, pause at pointerup instead.
|
|
70
|
+
if (window.${RELEASE}) { window.${PRESSED} = true; return; }
|
|
71
|
+
debugger;
|
|
72
|
+
}, { capture: true });
|
|
73
|
+
window.addEventListener('pointerup', function ${HOOK_ACTION}(event) {
|
|
74
|
+
if (!event.isPrimary || !window.${RELEASE} || !window.${PRESSED}) return;
|
|
75
|
+
window.${PRESSED} = false;
|
|
76
|
+
debugger;
|
|
77
|
+
}, { capture: true });
|
|
78
|
+
window.addEventListener('keydown', function ${HOOK_ACTION}(event) {
|
|
79
|
+
if (!isAction(event)) return;
|
|
80
|
+
window.${LAST} = { ...describe(event.target), key: event.key === ' ' ? 'Space' : event.key };
|
|
81
|
+
debugger;
|
|
82
|
+
}, { capture: true });
|
|
83
|
+
// A full page load is about to replace this document: pause once more so its coverage can be
|
|
84
|
+
// read while it still exists. Never sets returnValue, so no "leave site?" prompt appears.
|
|
85
|
+
window.addEventListener('beforeunload', function ${HOOK_LEAVE}() {
|
|
86
|
+
debugger;
|
|
87
|
+
}, { capture: true });
|
|
88
|
+
})();
|
|
89
|
+
//# sourceURL=${HOOK_URL}`;
|
|
90
|
+
/**
|
|
91
|
+
* Collects per-click frontend coverage from one Chromium page (docs/ARCHITECTURE.md section 5.1).
|
|
92
|
+
*
|
|
93
|
+
* While the page is paused in the hook: take coverage (background), open the trace, resume.
|
|
94
|
+
* After the settle window: take again; that delta is the click's frontend code.
|
|
95
|
+
*/
|
|
96
|
+
export class BrowserCollector {
|
|
97
|
+
#opts;
|
|
98
|
+
#log;
|
|
99
|
+
#settle;
|
|
100
|
+
#scripts = new Map();
|
|
101
|
+
#sentHashes = new Set();
|
|
102
|
+
/** Script ids the daemon has been told about (one per parse; a reload makes new ones). */
|
|
103
|
+
#sentScripts = new Set();
|
|
104
|
+
#backendOrigins;
|
|
105
|
+
#browser;
|
|
106
|
+
#context;
|
|
107
|
+
#page;
|
|
108
|
+
#cdp;
|
|
109
|
+
#active;
|
|
110
|
+
#warnedNoWebpackMaps = false;
|
|
111
|
+
/** True from a `beforeunload` during an action until the next document's `load` (D-091). */
|
|
112
|
+
#navigating = false;
|
|
113
|
+
/** Logs a message and shows it to the user once (the CLI prints notices in yellow). */
|
|
114
|
+
#notify(message) {
|
|
115
|
+
this.#log(message);
|
|
116
|
+
this.#opts.notice?.(message);
|
|
117
|
+
}
|
|
118
|
+
#clickChain = Promise.resolve();
|
|
119
|
+
/** True once the boundary has moved to pointer release (see PRESS_BUDGET_MS). */
|
|
120
|
+
#release = false;
|
|
121
|
+
constructor(opts) {
|
|
122
|
+
this.#opts = opts;
|
|
123
|
+
this.#log = opts.log ?? (() => { });
|
|
124
|
+
this.#settle = new SettleTracker({
|
|
125
|
+
settleMs: opts.settleMs ?? 300,
|
|
126
|
+
maxSettleMs: opts.maxSettleMs ?? 5000,
|
|
127
|
+
});
|
|
128
|
+
this.#backendOrigins = opts.backendOrigins ?? [new URL(opts.url).origin];
|
|
129
|
+
}
|
|
130
|
+
get page() {
|
|
131
|
+
return this.#page;
|
|
132
|
+
}
|
|
133
|
+
async start() {
|
|
134
|
+
this.#browser = await launchBrowser({
|
|
135
|
+
...(this.#opts.headless !== undefined ? { headless: this.#opts.headless } : {}),
|
|
136
|
+
...(this.#opts.channel ? { channel: this.#opts.channel } : {}),
|
|
137
|
+
});
|
|
138
|
+
// Service workers are blocked (D-089): a worker that answers the app's fetches sends them from
|
|
139
|
+
// its own context, where page.route never sees them, so no API call would join the click.
|
|
140
|
+
this.#context = await this.#browser.newContext({ viewport: null, serviceWorkers: 'block' });
|
|
141
|
+
const page = await this.#context.newPage();
|
|
142
|
+
this.#page = page;
|
|
143
|
+
const cdp = await this.#context.newCDPSession(page);
|
|
144
|
+
this.#cdp = cdp;
|
|
145
|
+
cdp.on('Debugger.scriptParsed', (s) => {
|
|
146
|
+
this.#scripts.set(s.scriptId, {
|
|
147
|
+
url: s.url,
|
|
148
|
+
hash: s.hash,
|
|
149
|
+
length: s.length ?? 0,
|
|
150
|
+
sourceMapURL: s.sourceMapURL ?? '',
|
|
151
|
+
});
|
|
152
|
+
if (!this.#warnedNoWebpackMaps &&
|
|
153
|
+
!s.sourceMapURL &&
|
|
154
|
+
!isNoiseScript(s.url) &&
|
|
155
|
+
webpackModulePath(s.url)) {
|
|
156
|
+
this.#warnedNoWebpackMaps = true;
|
|
157
|
+
this.#notify("webpack is serving your code without source maps (devtool 'eval'), so browser files can't be shown. " +
|
|
158
|
+
"Set devtool: 'eval-cheap-module-source-map' in your webpack config for development.");
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
cdp.on('Debugger.paused', (e) => {
|
|
162
|
+
// CallFrame.url is empty for init scripts (measured); resolve the URL via scriptParsed.
|
|
163
|
+
const frame = e.callFrames[0];
|
|
164
|
+
const url = frame ? (this.#scripts.get(frame.location.scriptId)?.url ?? frame.url) : undefined;
|
|
165
|
+
this.#onPaused(url, frame?.functionName);
|
|
166
|
+
});
|
|
167
|
+
await cdp.send('Debugger.enable');
|
|
168
|
+
await cdp.send('Profiler.enable');
|
|
169
|
+
await cdp.send('Profiler.startPreciseCoverage', { callCount: true, detailed: true });
|
|
170
|
+
this.#opts.send({
|
|
171
|
+
type: 'hello',
|
|
172
|
+
protocolVersion: 1,
|
|
173
|
+
kind: 'browser',
|
|
174
|
+
pid: process.pid,
|
|
175
|
+
processTitle: `browser ${this.#opts.url}`,
|
|
176
|
+
cwd: process.cwd(),
|
|
177
|
+
runtime: { name: 'chromium', version: this.#browser.version() },
|
|
178
|
+
});
|
|
179
|
+
await page.addInitScript({ content: HOOK_SOURCE });
|
|
180
|
+
await page.route('**/*', (route, request) => this.#onRoute(route, request));
|
|
181
|
+
page.on('request', (r) => countsTowardSettle(r.resourceType()) && this.#settle.requestStarted());
|
|
182
|
+
page.on('requestfinished', (r) => countsTowardSettle(r.resourceType()) && this.#settle.requestEnded());
|
|
183
|
+
page.on('requestfailed', (r) => countsTowardSettle(r.resourceType()) && this.#settle.requestEnded());
|
|
184
|
+
// Page load compiles and runs most of the app. Reading that now keeps the first action's
|
|
185
|
+
// pause as short as later ones (bench/README.md: 40 ms vs 8 ms at 12,000 functions).
|
|
186
|
+
page.on('load', () => {
|
|
187
|
+
if (this.#navigating) {
|
|
188
|
+
this.#navigating = false;
|
|
189
|
+
this.#settle.requestEnded();
|
|
190
|
+
}
|
|
191
|
+
this.#afterLoad();
|
|
192
|
+
});
|
|
193
|
+
await page.goto(this.#opts.url);
|
|
194
|
+
this.#log(`browser open at ${this.#opts.url}`);
|
|
195
|
+
}
|
|
196
|
+
async stop() {
|
|
197
|
+
this.#settle.cancel();
|
|
198
|
+
await this.#clickChain;
|
|
199
|
+
await this.#browser?.close();
|
|
200
|
+
}
|
|
201
|
+
#onPaused(url, functionName) {
|
|
202
|
+
if (url !== HOOK_URL) {
|
|
203
|
+
// A `debugger;` statement in app code. Lightpath isn't a debugger; keep the app running.
|
|
204
|
+
void this.#resume();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (functionName === HOOK_LEAVE) {
|
|
208
|
+
void this.#onLeave();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// A new click ends the previous window early. The previous click's closing take still runs
|
|
212
|
+
// before this click's code, because the page stays paused until #click resumes it.
|
|
213
|
+
if (this.#active)
|
|
214
|
+
this.#settle.cancel();
|
|
215
|
+
this.#clickChain = this.#clickChain
|
|
216
|
+
.then(() => this.#click())
|
|
217
|
+
.catch(async (err) => {
|
|
218
|
+
this.#log(`click handling failed: ${err.stack ?? err}`);
|
|
219
|
+
await this.#resume();
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
/** Runs while the page is paused in the hook. Does as little as possible before resuming. */
|
|
223
|
+
async #click() {
|
|
224
|
+
const pausedAt = performance.now();
|
|
225
|
+
let readMs = 0;
|
|
226
|
+
const [before, target] = await Promise.all([
|
|
227
|
+
this.#take().then((r) => {
|
|
228
|
+
readMs = performance.now() - pausedAt;
|
|
229
|
+
return r;
|
|
230
|
+
}),
|
|
231
|
+
this.#lastTarget(),
|
|
232
|
+
]);
|
|
233
|
+
const traceId = newTraceId();
|
|
234
|
+
const at = Date.now();
|
|
235
|
+
this.#active = traceId; // before resuming, so the click's own requests are tagged
|
|
236
|
+
const settled = this.#settle.begin();
|
|
237
|
+
await this.#resume();
|
|
238
|
+
const boundary = this.#release ? 'release' : 'press';
|
|
239
|
+
this.#log(`click on ${labelFor(target)}: paused ${(performance.now() - pausedAt).toFixed(1)}ms, boundary ${boundary}, trace ${traceId}`);
|
|
240
|
+
// Key presses never lose events to a pause, so only pointer presses count toward the budget.
|
|
241
|
+
if (!target.key && boundary === 'press')
|
|
242
|
+
await this.#checkBudget(readMs);
|
|
243
|
+
void this.#emit('background', before, at);
|
|
244
|
+
this.#opts.send({
|
|
245
|
+
type: 'traceStart',
|
|
246
|
+
traceId,
|
|
247
|
+
label: labelFor(target),
|
|
248
|
+
target: {
|
|
249
|
+
...(target.role ? { role: target.role } : {}),
|
|
250
|
+
...(target.name ? { name: target.name } : {}),
|
|
251
|
+
...(target.testId ? { testId: target.testId } : {}),
|
|
252
|
+
...(target.text ? { text: target.text } : {}),
|
|
253
|
+
},
|
|
254
|
+
at,
|
|
255
|
+
});
|
|
256
|
+
const reason = await settled;
|
|
257
|
+
// A navigation that never loaded (a download, a cancelled leave) mustn't hold later windows open.
|
|
258
|
+
if (this.#navigating) {
|
|
259
|
+
this.#navigating = false;
|
|
260
|
+
this.#settle.requestEnded();
|
|
261
|
+
}
|
|
262
|
+
const coverage = await this.#take();
|
|
263
|
+
await this.#emit('action', coverage, at, traceId);
|
|
264
|
+
this.#opts.send({ type: 'traceEnd', traceId, at: Date.now(), settled: reason });
|
|
265
|
+
if (this.#active === traceId)
|
|
266
|
+
this.#active = undefined;
|
|
267
|
+
}
|
|
268
|
+
async #lastTarget() {
|
|
269
|
+
if (!this.#cdp)
|
|
270
|
+
return {};
|
|
271
|
+
const { result } = await this.#cdp.send('Runtime.evaluate', {
|
|
272
|
+
expression: `window.${LAST}`,
|
|
273
|
+
returnByValue: true,
|
|
274
|
+
silent: true,
|
|
275
|
+
});
|
|
276
|
+
return result.value ?? {};
|
|
277
|
+
}
|
|
278
|
+
async #resume() {
|
|
279
|
+
try {
|
|
280
|
+
await this.#cdp?.send('Debugger.resume');
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
// Not paused (already resumed, or the page navigated).
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async #onRoute(route, request) {
|
|
287
|
+
const traceId = this.#active;
|
|
288
|
+
// A main-frame page load started by this action (a link, a form post) is backend work for it.
|
|
289
|
+
const type = request.isNavigationRequest() && request.frame() === this.#page?.mainFrame()
|
|
290
|
+
? 'navigation'
|
|
291
|
+
: request.resourceType();
|
|
292
|
+
if (traceId && shouldTag(request.url(), type, this.#backendOrigins)) {
|
|
293
|
+
await route.continue({ headers: { ...request.headers(), [TRACE_HEADER]: traceId } });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
await route.continue();
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Files everything since the page started loading as background. Queued on the click chain so it
|
|
300
|
+
* never runs during an action's reads; skipped if an action is open (a navigation's new page
|
|
301
|
+
* belongs to that action's trace).
|
|
302
|
+
*/
|
|
303
|
+
#afterLoad() {
|
|
304
|
+
this.#clickChain = this.#clickChain
|
|
305
|
+
.then(async () => {
|
|
306
|
+
if (this.#active)
|
|
307
|
+
return;
|
|
308
|
+
await this.#emit('background', await this.#take(), Date.now());
|
|
309
|
+
// A second read files what the page ran right after load, background too. It isn't timed
|
|
310
|
+
// against the press budget: the page can still be busy here, so this read can cost more than
|
|
311
|
+
// any click's (Actual: 20.8 ms, while 103 press reads stayed under 18.9 ms). Only a read at a
|
|
312
|
+
// pointer press moves the boundary (D-082, D-105).
|
|
313
|
+
await this.#emit('background', await this.#take(), Date.now());
|
|
314
|
+
})
|
|
315
|
+
.catch((err) => this.#log(`load-time coverage read failed: ${err.message}`));
|
|
316
|
+
}
|
|
317
|
+
/** Called only with the read taken while the page is paused at a pointer press (D-105). */
|
|
318
|
+
async #checkBudget(readMs) {
|
|
319
|
+
const budget = this.#opts.pressBudgetMs ?? PRESS_BUDGET_MS;
|
|
320
|
+
if (this.#release || readMs <= budget)
|
|
321
|
+
return;
|
|
322
|
+
this.#release = true;
|
|
323
|
+
await this.#setReleaseFlag();
|
|
324
|
+
const message = `a coverage read took ${readMs.toFixed(1)} ms (budget ${budget} ms), so clicks now start at the mouse ` +
|
|
325
|
+
'release instead of the press: Chrome drops a release that arrives during a long pause. ' +
|
|
326
|
+
"Code that runs on press (pointerdown and mousedown handlers) won't appear in click traces.";
|
|
327
|
+
this.#log(message);
|
|
328
|
+
this.#opts.notice?.(message);
|
|
329
|
+
}
|
|
330
|
+
/** Turns on the release boundary now, and from the first moment of every later document. */
|
|
331
|
+
async #setReleaseFlag() {
|
|
332
|
+
const expression = `window.${RELEASE} = true`;
|
|
333
|
+
await this.#cdp?.send('Runtime.evaluate', { expression, silent: true });
|
|
334
|
+
await this.#page?.addInitScript({ content: expression });
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Runs while the page is paused in the hook's `beforeunload` listener: the current document is
|
|
338
|
+
* about to go, and its coverage and sources go with it. Taking coverage from a route handler
|
|
339
|
+
* instead deadlocks, because the renderer waits for the request the handler holds (measured).
|
|
340
|
+
*/
|
|
341
|
+
async #onLeave() {
|
|
342
|
+
const traceId = this.#active;
|
|
343
|
+
// The page is leaving during an action: count the navigation as in flight until the next
|
|
344
|
+
// document's `load`, so a slow read here can't end the window before the new page runs (D-091).
|
|
345
|
+
if (traceId && !this.#navigating) {
|
|
346
|
+
this.#navigating = true;
|
|
347
|
+
this.#settle.requestStarted();
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
const coverage = await this.#take();
|
|
351
|
+
if (traceId) {
|
|
352
|
+
await this.#emit('action', coverage, Date.now(), traceId);
|
|
353
|
+
this.#log(`page unloading during ${traceId}: kept its coverage`);
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
await this.#emit('background', coverage, Date.now());
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
this.#log(`could not read coverage before unload: ${err.message}`);
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
await this.#resume();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async #take() {
|
|
367
|
+
if (!this.#cdp)
|
|
368
|
+
return [];
|
|
369
|
+
const { result } = await this.#cdp.send('Profiler.takePreciseCoverage');
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
async #emit(phase, raw, startedAt, traceId) {
|
|
373
|
+
const coverage = raw.filter((s) => !isNoiseScript(s.url));
|
|
374
|
+
if (coverage.length === 0 && phase === 'background')
|
|
375
|
+
return;
|
|
376
|
+
// Sources go first: the daemon can't map a delta for a script it has no source for.
|
|
377
|
+
await this.#pushSources(coverage);
|
|
378
|
+
this.#opts.send({
|
|
379
|
+
type: 'delta',
|
|
380
|
+
phase,
|
|
381
|
+
startedAt,
|
|
382
|
+
endedAt: Date.now(),
|
|
383
|
+
coverage,
|
|
384
|
+
...(traceId ? { traceId } : {}),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Sends script info once per script id and the source once per hash, before the delta that needs
|
|
389
|
+
* them. A reload parses the same files again under new ids with the same hash; the daemon looks a
|
|
390
|
+
* delta up by id, so each new id must be announced even when its source was sent before (D-101).
|
|
391
|
+
*/
|
|
392
|
+
async #pushSources(coverage) {
|
|
393
|
+
for (const { scriptId } of coverage) {
|
|
394
|
+
const meta = this.#scripts.get(scriptId);
|
|
395
|
+
if (!meta || this.#sentScripts.has(scriptId) || !this.#cdp)
|
|
396
|
+
continue;
|
|
397
|
+
this.#sentScripts.add(scriptId);
|
|
398
|
+
const needSource = !this.#sentHashes.has(meta.hash);
|
|
399
|
+
try {
|
|
400
|
+
const scriptSource = needSource
|
|
401
|
+
? (await this.#cdp.send('Debugger.getScriptSource', { scriptId })).scriptSource
|
|
402
|
+
: undefined;
|
|
403
|
+
this.#opts.send({
|
|
404
|
+
type: 'scripts',
|
|
405
|
+
scripts: [
|
|
406
|
+
{
|
|
407
|
+
scriptId,
|
|
408
|
+
url: meta.url,
|
|
409
|
+
hash: meta.hash,
|
|
410
|
+
length: meta.length,
|
|
411
|
+
// Vite inlines maps as data: URLs (unverified, M3). External maps are not fetched yet.
|
|
412
|
+
...(meta.sourceMapURL.startsWith('data:') ? { sourceMapURL: meta.sourceMapURL } : {}),
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
});
|
|
416
|
+
if (scriptSource !== undefined) {
|
|
417
|
+
this.#sentHashes.add(meta.hash);
|
|
418
|
+
this.#opts.send({ type: 'source', scriptId, hash: meta.hash, url: meta.url, source: scriptSource });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
this.#log(`could not read source of ${meta.url}: ${err.message}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
//# sourceMappingURL=collector.js.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { BrowserCollector, type CollectorOptions } from './collector.js';
|
|
2
|
+
export { type LaunchOptions, launchBrowser } from './launch.js';
|
|
3
|
+
export { countsTowardSettle, isNoiseScript, labelFor, shouldTag, type TargetInfo } from './rules.js';
|
|
4
|
+
export { type SettleOptions, type SettleReason, SettleTracker } from './settle.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
package/dist/launch.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Browser } from 'playwright-core';
|
|
2
|
+
export interface LaunchOptions {
|
|
3
|
+
headless?: boolean;
|
|
4
|
+
/** Force one channel, for example 'chrome-beta'. */
|
|
5
|
+
channel?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function launchBrowser(options?: LaunchOptions): Promise<Browser>;
|
|
8
|
+
//# sourceMappingURL=launch.d.ts.map
|
package/dist/launch.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { chromium } from 'playwright-core';
|
|
2
|
+
/** Installed browsers first, so users don't download one; Playwright's own Chromium last. */
|
|
3
|
+
const CHANNELS = ['chrome', 'msedge', undefined];
|
|
4
|
+
export async function launchBrowser(options = {}) {
|
|
5
|
+
const tried = [];
|
|
6
|
+
for (const channel of options.channel ? [options.channel] : CHANNELS) {
|
|
7
|
+
try {
|
|
8
|
+
return await chromium.launch({ headless: options.headless ?? false, ...(channel ? { channel } : {}) });
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
tried.push(`${channel ?? 'Playwright Chromium'}: ${String(err.message).split('\n')[0]}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
throw new Error(`Lightpath couldn't start a Chromium-based browser. Tried:\n ${tried.join('\n ')}\n` +
|
|
15
|
+
'Install Google Chrome or Microsoft Edge, or run: npx playwright install chromium');
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=launch.js.map
|
package/dist/rules.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tags fetch/XHR requests to configured backend origins and to any loopback origin (D-086).
|
|
3
|
+
*
|
|
4
|
+
* The header is added by request interception after the browser has done its CORS checks, so it
|
|
5
|
+
* never triggers a preflight, even when the API's CORS policy doesn't allow custom headers
|
|
6
|
+
* (measured in Chrome). Loopback is the common dev setup: Vite on :5173 calling an API on :3001.
|
|
7
|
+
* Remote origins (a payment provider, a CDN) are never tagged: the trace ID is no business of theirs.
|
|
8
|
+
*/
|
|
9
|
+
export declare function shouldTag(requestUrl: string, resourceType: string, backendOrigins: readonly string[]): boolean;
|
|
10
|
+
/** Requests that count toward the settle window. Long-lived streams would keep a click open forever. */
|
|
11
|
+
export declare function countsTowardSettle(resourceType: string): boolean;
|
|
12
|
+
export interface TargetInfo {
|
|
13
|
+
tag?: string | undefined;
|
|
14
|
+
role?: string | undefined;
|
|
15
|
+
name?: string | undefined;
|
|
16
|
+
testId?: string | undefined;
|
|
17
|
+
text?: string | undefined;
|
|
18
|
+
/** Set when the action was a key press (`Enter` or `Space`) rather than a pointer press. */
|
|
19
|
+
key?: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
/** A short human label for an action target, like `button: Checkout` or `button: Checkout (Enter)`. */
|
|
22
|
+
export declare function labelFor(target: TargetInfo): string;
|
|
23
|
+
/**
|
|
24
|
+
* Scripts whose source never needs to leave the browser: dependencies and dev-server internals.
|
|
25
|
+
* Webpack's per-module scripts (`webpack://`, `webpack-internal://`) are kept unless they're
|
|
26
|
+
* dependencies or webpack's runtime (D-087).
|
|
27
|
+
*/
|
|
28
|
+
export declare function isNoiseScript(url: string): boolean;
|
|
29
|
+
//# sourceMappingURL=rules.d.ts.map
|
package/dist/rules.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { webpackModulePath } from '@lightpath-dev/core';
|
|
2
|
+
/**
|
|
3
|
+
* Resource types that carry app requests worth tagging (ADR 0004). `navigation` is a main-frame
|
|
4
|
+
* page load started by an action (the collector passes it explicitly): a server-rendered page or a
|
|
5
|
+
* form post is backend work for that action. Navigations aren't subject to CORS, so the header
|
|
6
|
+
* can't trigger a preflight.
|
|
7
|
+
*/
|
|
8
|
+
const TAGGABLE = new Set(['fetch', 'xhr', 'navigation']);
|
|
9
|
+
/**
|
|
10
|
+
* Tags fetch/XHR requests to configured backend origins and to any loopback origin (D-086).
|
|
11
|
+
*
|
|
12
|
+
* The header is added by request interception after the browser has done its CORS checks, so it
|
|
13
|
+
* never triggers a preflight, even when the API's CORS policy doesn't allow custom headers
|
|
14
|
+
* (measured in Chrome). Loopback is the common dev setup: Vite on :5173 calling an API on :3001.
|
|
15
|
+
* Remote origins (a payment provider, a CDN) are never tagged: the trace ID is no business of theirs.
|
|
16
|
+
*/
|
|
17
|
+
export function shouldTag(requestUrl, resourceType, backendOrigins) {
|
|
18
|
+
if (!TAGGABLE.has(resourceType))
|
|
19
|
+
return false;
|
|
20
|
+
let url;
|
|
21
|
+
try {
|
|
22
|
+
url = new URL(requestUrl);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (backendOrigins.includes(url.origin))
|
|
28
|
+
return true;
|
|
29
|
+
return /^https?:$/.test(url.protocol) && LOOPBACK.has(url.hostname);
|
|
30
|
+
}
|
|
31
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
32
|
+
/** Requests that count toward the settle window. Long-lived streams would keep a click open forever. */
|
|
33
|
+
export function countsTowardSettle(resourceType) {
|
|
34
|
+
return !['websocket', 'eventsource', 'media', 'manifest', 'ping'].includes(resourceType);
|
|
35
|
+
}
|
|
36
|
+
/** A short human label for an action target, like `button: Checkout` or `button: Checkout (Enter)`. */
|
|
37
|
+
export function labelFor(target) {
|
|
38
|
+
const kind = target.role || target.tag || 'element';
|
|
39
|
+
const name = (target.name || target.testId || target.text || '').replace(/\s+/g, ' ').trim();
|
|
40
|
+
const short = name.length > 40 ? `${name.slice(0, 39)}…` : name;
|
|
41
|
+
const base = short ? `${kind}: ${short}` : kind;
|
|
42
|
+
return target.key ? `${base} (${target.key})` : base;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Scripts whose source never needs to leave the browser: dependencies and dev-server internals.
|
|
46
|
+
* Webpack's per-module scripts (`webpack://`, `webpack-internal://`) are kept unless they're
|
|
47
|
+
* dependencies or webpack's runtime (D-087).
|
|
48
|
+
*/
|
|
49
|
+
export function isNoiseScript(url) {
|
|
50
|
+
const webpack = webpackModulePath(url);
|
|
51
|
+
if (webpack !== undefined)
|
|
52
|
+
return webpack === 'internal' || webpack === 'node_modules';
|
|
53
|
+
if (!/^https?:\/\//.test(url))
|
|
54
|
+
return true;
|
|
55
|
+
return /\/node_modules\/|\/@vite\/|\/@react-refresh|\/@id\/__x00__|\/__vite/.test(url);
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=rules.js.map
|
package/dist/settle.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type SettleReason = 'quiet' | 'timeout';
|
|
2
|
+
export interface SettleOptions {
|
|
3
|
+
/** Quiet time with no requests in flight before a click counts as finished. */
|
|
4
|
+
settleMs: number;
|
|
5
|
+
/** Hard cap on a click's window. */
|
|
6
|
+
maxSettleMs: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Decides when a click is over (ARCHITECTURE section 5.1): no requests in flight for `settleMs`,
|
|
10
|
+
* or `maxSettleMs` since the click, whichever comes first.
|
|
11
|
+
*/
|
|
12
|
+
export declare class SettleTracker {
|
|
13
|
+
#private;
|
|
14
|
+
constructor(opts: SettleOptions);
|
|
15
|
+
get inFlight(): number;
|
|
16
|
+
/** Starts a window. Requests already in flight count toward it. */
|
|
17
|
+
begin(): Promise<SettleReason>;
|
|
18
|
+
requestStarted(): void;
|
|
19
|
+
requestEnded(): void;
|
|
20
|
+
/** Ends the current window early, for example when the next click starts. */
|
|
21
|
+
cancel(): void;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=settle.d.ts.map
|
package/dist/settle.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides when a click is over (ARCHITECTURE section 5.1): no requests in flight for `settleMs`,
|
|
3
|
+
* or `maxSettleMs` since the click, whichever comes first.
|
|
4
|
+
*/
|
|
5
|
+
export class SettleTracker {
|
|
6
|
+
#inFlight = 0;
|
|
7
|
+
#quietTimer;
|
|
8
|
+
#maxTimer;
|
|
9
|
+
#resolve;
|
|
10
|
+
#opts;
|
|
11
|
+
constructor(opts) {
|
|
12
|
+
this.#opts = opts;
|
|
13
|
+
}
|
|
14
|
+
get inFlight() {
|
|
15
|
+
return this.#inFlight;
|
|
16
|
+
}
|
|
17
|
+
/** Starts a window. Requests already in flight count toward it. */
|
|
18
|
+
begin() {
|
|
19
|
+
this.#finish('quiet');
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
this.#resolve = resolve;
|
|
22
|
+
this.#maxTimer = setTimeout(() => this.#finish('timeout'), this.#opts.maxSettleMs);
|
|
23
|
+
if (this.#inFlight === 0)
|
|
24
|
+
this.#armQuiet();
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
requestStarted() {
|
|
28
|
+
this.#inFlight++;
|
|
29
|
+
this.#clearQuiet();
|
|
30
|
+
}
|
|
31
|
+
requestEnded() {
|
|
32
|
+
this.#inFlight = Math.max(0, this.#inFlight - 1);
|
|
33
|
+
if (this.#inFlight === 0 && this.#resolve)
|
|
34
|
+
this.#armQuiet();
|
|
35
|
+
}
|
|
36
|
+
/** Ends the current window early, for example when the next click starts. */
|
|
37
|
+
cancel() {
|
|
38
|
+
this.#finish('quiet');
|
|
39
|
+
}
|
|
40
|
+
#armQuiet() {
|
|
41
|
+
this.#clearQuiet();
|
|
42
|
+
this.#quietTimer = setTimeout(() => this.#finish('quiet'), this.#opts.settleMs);
|
|
43
|
+
}
|
|
44
|
+
#clearQuiet() {
|
|
45
|
+
if (this.#quietTimer)
|
|
46
|
+
clearTimeout(this.#quietTimer);
|
|
47
|
+
this.#quietTimer = undefined;
|
|
48
|
+
}
|
|
49
|
+
#finish(reason) {
|
|
50
|
+
this.#clearQuiet();
|
|
51
|
+
if (this.#maxTimer)
|
|
52
|
+
clearTimeout(this.#maxTimer);
|
|
53
|
+
this.#maxTimer = undefined;
|
|
54
|
+
const resolve = this.#resolve;
|
|
55
|
+
this.#resolve = undefined;
|
|
56
|
+
resolve?.(reason);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=settle.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lightpath-dev/browser",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Lightpath browser collector: launches Chrome, marks click boundaries, and collects frontend coverage",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"coverage",
|
|
7
|
+
"chrome",
|
|
8
|
+
"cdp",
|
|
9
|
+
"click",
|
|
10
|
+
"trace",
|
|
11
|
+
"frontend"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"homepage": "https://github.com/Unknown-333/Lightpath#readme",
|
|
15
|
+
"bugs": "https://github.com/Unknown-333/Lightpath/issues",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/Unknown-333/Lightpath.git",
|
|
19
|
+
"directory": "packages/browser"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"!dist/.tsbuildinfo",
|
|
31
|
+
"!dist/**/*.map"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22.12"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@lightpath-dev/core": "0.0.1",
|
|
41
|
+
"@lightpath-dev/protocol": "0.0.1",
|
|
42
|
+
"playwright-core": "1.64.1"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"typecheck": "tsc -p test"
|
|
47
|
+
}
|
|
6
48
|
}
|