mcp-accessibility-scanner 3.3.2 → 3.5.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/README.md +146 -11
- package/config.d.ts +48 -3
- package/lib/browserContextFactory.js +15 -313
- package/lib/browserContextFactory.js.map +1 -1
- package/lib/browserServerBackend.js +11 -0
- package/lib/browserServerBackend.js.map +1 -1
- package/lib/browserSessions.js +3 -0
- package/lib/browserSessions.js.map +1 -1
- package/lib/config.js +70 -12
- package/lib/config.js.map +1 -1
- package/lib/context.js +463 -57
- package/lib/context.js.map +1 -1
- package/lib/extension/cdpRelay.js +82 -14
- package/lib/extension/cdpRelay.js.map +1 -1
- package/lib/extension/extensionContextFactory.js +15 -8
- package/lib/extension/extensionContextFactory.js.map +1 -1
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -1
- package/lib/mcp/http.js +115 -41
- package/lib/mcp/http.js.map +1 -1
- package/lib/mcp/server.js +16 -8
- package/lib/mcp/server.js.map +1 -1
- package/lib/mcp/tool.js +11 -9
- package/lib/mcp/tool.js.map +1 -1
- package/lib/program.js +28 -8
- package/lib/program.js.map +1 -1
- package/lib/response.js +22 -5
- package/lib/response.js.map +1 -1
- package/lib/sessionLog.js +66 -20
- package/lib/sessionLog.js.map +1 -1
- package/lib/tab.js +13 -7
- package/lib/tab.js.map +1 -1
- package/lib/tools/auditKeyboard.js +27 -25
- package/lib/tools/auditKeyboard.js.map +1 -1
- package/lib/tools/auditScreenReader.js +27 -18
- package/lib/tools/auditScreenReader.js.map +1 -1
- package/lib/tools/auditSite.js +88 -56
- package/lib/tools/auditSite.js.map +1 -1
- package/lib/tools/axe.js +32 -7
- package/lib/tools/axe.js.map +1 -1
- package/lib/tools/common.js +39 -1
- package/lib/tools/common.js.map +1 -1
- package/lib/tools/files.js +60 -3
- package/lib/tools/files.js.map +1 -1
- package/lib/tools/install.js +1 -1
- package/lib/tools/install.js.map +1 -1
- package/lib/tools/pdf.js +4 -2
- package/lib/tools/pdf.js.map +1 -1
- package/lib/tools/recorder.js +52 -0
- package/lib/tools/recorder.js.map +1 -0
- package/lib/tools/report.js +13 -0
- package/lib/tools/report.js.map +1 -0
- package/lib/tools/scanPageMatrix.js +20 -33
- package/lib/tools/scanPageMatrix.js.map +1 -1
- package/lib/tools/screenshot.js +7 -3
- package/lib/tools/screenshot.js.map +1 -1
- package/lib/tools/snapshot.js +10 -3
- package/lib/tools/snapshot.js.map +1 -1
- package/lib/tools.js +5 -1
- package/lib/tools.js.map +1 -1
- package/lib/vscode/browserContextFactory.js +16 -12
- package/lib/vscode/browserContextFactory.js.map +1 -1
- package/lib/vscode/host.js +25 -2
- package/lib/vscode/host.js.map +1 -1
- package/lib/vscode/main.js +15 -0
- package/lib/vscode/main.js.map +1 -1
- package/lib/vscode/validation.js +78 -0
- package/lib/vscode/validation.js.map +1 -0
- package/package.json +9 -8
package/lib/context.js
CHANGED
|
@@ -20,6 +20,57 @@ import { Tab } from './tab.js';
|
|
|
20
20
|
import { outputFile } from './config.js';
|
|
21
21
|
import { ensureNetworkPolicyRoutes } from './networkPolicy.js';
|
|
22
22
|
const testDebug = debug('pw:mcp:test');
|
|
23
|
+
const recorderBufferMs = 500;
|
|
24
|
+
const recorderControlTools = new Set(['browser_start_recording', 'browser_stop_recording']);
|
|
25
|
+
const expectPrelude = "const { expect } = require('playwright/test');";
|
|
26
|
+
const pendingPageCloses = new WeakMap();
|
|
27
|
+
function pendingPageClose(page) {
|
|
28
|
+
const pending = pendingPageCloses.get(page);
|
|
29
|
+
if (pending)
|
|
30
|
+
return pending;
|
|
31
|
+
const close = page.close();
|
|
32
|
+
pendingPageCloses.set(page, close);
|
|
33
|
+
const clear = () => {
|
|
34
|
+
if (pendingPageCloses.get(page) === close)
|
|
35
|
+
pendingPageCloses.delete(page);
|
|
36
|
+
};
|
|
37
|
+
void close.then(clear, clear);
|
|
38
|
+
return close;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Chromium can acknowledge Target.closeTarget while a racing navigation keeps
|
|
42
|
+
* the target alive. Retry the public close call instead of letting one tool or
|
|
43
|
+
* an entire crawl wait forever.
|
|
44
|
+
*/
|
|
45
|
+
async function closePage(page, timeoutMs) {
|
|
46
|
+
const deadline = Date.now() + timeoutMs;
|
|
47
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
48
|
+
const remaining = deadline - Date.now();
|
|
49
|
+
if (remaining <= 0)
|
|
50
|
+
break;
|
|
51
|
+
let timer;
|
|
52
|
+
try {
|
|
53
|
+
await Promise.race([
|
|
54
|
+
pendingPageClose(page),
|
|
55
|
+
new Promise((_, reject) => {
|
|
56
|
+
timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms while closing the page.`)), remaining);
|
|
57
|
+
timer.unref?.();
|
|
58
|
+
}),
|
|
59
|
+
]);
|
|
60
|
+
if (page.isClosed())
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (page.isClosed())
|
|
65
|
+
return;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`Failed to close the page after 3 attempts within ${timeoutMs}ms.`);
|
|
73
|
+
}
|
|
23
74
|
class ContextRegistry {
|
|
24
75
|
_contexts = new Set();
|
|
25
76
|
register(context) {
|
|
@@ -34,6 +85,7 @@ class ContextRegistry {
|
|
|
34
85
|
}
|
|
35
86
|
const contextRegistry = new ContextRegistry();
|
|
36
87
|
const traceHubs = new WeakMap();
|
|
88
|
+
const idleGroups = new WeakMap();
|
|
37
89
|
async function acquireTrace(browserContext) {
|
|
38
90
|
let hub = traceHubs.get(browserContext);
|
|
39
91
|
if (!hub) {
|
|
@@ -74,7 +126,26 @@ async function releaseTrace(browserContext) {
|
|
|
74
126
|
if (!hub || --hub.users)
|
|
75
127
|
return;
|
|
76
128
|
traceHubs.delete(browserContext);
|
|
77
|
-
|
|
129
|
+
try {
|
|
130
|
+
await browserContext.tracing.stop();
|
|
131
|
+
}
|
|
132
|
+
catch (originalError) {
|
|
133
|
+
// Playwright's stop sends the server-side tracingStop only after the
|
|
134
|
+
// chunk export succeeds, so a stop that fails midway (an unwritable
|
|
135
|
+
// export target, a channel hiccup) leaves tracing started while the
|
|
136
|
+
// hub above is already gone — every later session on this browser
|
|
137
|
+
// context would fail its start with "Tracing has been already started".
|
|
138
|
+
// One bare retry ends the recording; when it succeeds the session
|
|
139
|
+
// recovered (the original failure is not worth surfacing), and when it
|
|
140
|
+
// also fails the original error is the real one to report. try/catch,
|
|
141
|
+
// not a .catch() chain, so a synchronously throwing stop is retried too.
|
|
142
|
+
try {
|
|
143
|
+
await browserContext.tracing.stop();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw originalError;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
78
149
|
}
|
|
79
150
|
export class Context {
|
|
80
151
|
tools;
|
|
@@ -91,6 +162,7 @@ export class Context {
|
|
|
91
162
|
// second still ran — letting the session TTL reaper (or a session close)
|
|
92
163
|
// dispose the browser mid-operation.
|
|
93
164
|
_runningTools = [];
|
|
165
|
+
_lastToolCallEndedAt = -Infinity;
|
|
94
166
|
// In-flight download saves (Tab hands them over as they start). A download
|
|
95
167
|
// outlives the tool call that triggered it — the response reports it as
|
|
96
168
|
// "still downloading" — so disposal must wait for these before closing the
|
|
@@ -101,8 +173,16 @@ export class Context {
|
|
|
101
173
|
_abortController = new AbortController();
|
|
102
174
|
_removePageObserver;
|
|
103
175
|
_inputRecorder;
|
|
176
|
+
_removeRecorderContext;
|
|
177
|
+
_recordingStartFinished;
|
|
178
|
+
_recording;
|
|
179
|
+
_recordingStops = new Set();
|
|
180
|
+
_closeAfterRecording = false;
|
|
104
181
|
// Resolved from options.sessionLog at the first browser context launch.
|
|
105
182
|
_sessionLog;
|
|
183
|
+
_idleGroup;
|
|
184
|
+
_lastActivityAt = Date.now();
|
|
185
|
+
_idleClosePromise;
|
|
106
186
|
constructor(options) {
|
|
107
187
|
this.tools = options.tools;
|
|
108
188
|
this.config = options.config;
|
|
@@ -170,16 +250,98 @@ export class Context {
|
|
|
170
250
|
await browserContext.newPage();
|
|
171
251
|
return this._currentTab;
|
|
172
252
|
}
|
|
253
|
+
async startRecording() {
|
|
254
|
+
this.assertRecordingCanStart();
|
|
255
|
+
await this._startRecording();
|
|
256
|
+
}
|
|
257
|
+
async startRecordingOnCurrentTab() {
|
|
258
|
+
this.assertRecordingCanStart();
|
|
259
|
+
let finishStart;
|
|
260
|
+
const startFinished = new Promise(resolve => finishStart = resolve);
|
|
261
|
+
this._recordingStartFinished = startFinished;
|
|
262
|
+
try {
|
|
263
|
+
const tab = await this.ensureTab();
|
|
264
|
+
await tab.page.bringToFront();
|
|
265
|
+
await this._startRecording();
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
if (this._recordingStartFinished === startFinished)
|
|
269
|
+
this._recordingStartFinished = undefined;
|
|
270
|
+
finishStart();
|
|
271
|
+
this._scheduleIdleTimeout();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async _startRecording() {
|
|
275
|
+
const actions = [];
|
|
276
|
+
const target = { actions, pageIndexes: new Map(), state: { stopping: false } };
|
|
277
|
+
const ready = this._ensureBrowserContext().then(async ({ browserContext }) => {
|
|
278
|
+
await InputRecorder.startRecording(this, browserContext, target);
|
|
279
|
+
return browserContext;
|
|
280
|
+
});
|
|
281
|
+
const recording = { target, ready, lastActivityAt: Date.now() };
|
|
282
|
+
this._recording = recording;
|
|
283
|
+
try {
|
|
284
|
+
await ready;
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (this._recording === recording)
|
|
288
|
+
this._recording = undefined;
|
|
289
|
+
this._scheduleIdleTimeout();
|
|
290
|
+
this._closeBrowserContextAfterRecording();
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async stopRecording() {
|
|
295
|
+
this.assertRecordingCanPersist();
|
|
296
|
+
if (this._recordingStartFinished)
|
|
297
|
+
await this._recordingStartFinished;
|
|
298
|
+
const recording = this._recording;
|
|
299
|
+
if (!recording)
|
|
300
|
+
return undefined;
|
|
301
|
+
this._recording = undefined;
|
|
302
|
+
recording.target.state.stopping = true;
|
|
303
|
+
let finishStop;
|
|
304
|
+
const stopFinished = new Promise(resolve => finishStop = resolve);
|
|
305
|
+
this._recordingStops.add(stopFinished);
|
|
306
|
+
try {
|
|
307
|
+
const browserContext = await recording.ready;
|
|
308
|
+
await InputRecorder.stopRecording(this, browserContext, recording.target);
|
|
309
|
+
return recording.target.actions.map(action => action.code.trim()).filter(Boolean);
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
this._recordingStops.delete(stopFinished);
|
|
313
|
+
finishStop();
|
|
314
|
+
this._lastActivityAt = Date.now();
|
|
315
|
+
this._scheduleIdleTimeout();
|
|
316
|
+
this._closeBrowserContextAfterRecording();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
recordingActivityAt() {
|
|
320
|
+
return this._recording?.lastActivityAt;
|
|
321
|
+
}
|
|
322
|
+
markRecordingActivity() {
|
|
323
|
+
if (this._recording)
|
|
324
|
+
this._recording.lastActivityAt = Date.now();
|
|
325
|
+
}
|
|
326
|
+
assertRecordingCanPersist() {
|
|
327
|
+
if (this.options.browserSession && !this.options.browserSessionId)
|
|
328
|
+
throw new Error('Recording over stateless HTTP requires a browserSessionId. Call browser_session_open, then pass its browserSessionId to browser_start_recording and browser_stop_recording. Shared-context modes that cannot open browser sessions require a stateful MCP connection.');
|
|
329
|
+
}
|
|
330
|
+
assertRecordingCanStart() {
|
|
331
|
+
this.assertRecordingCanPersist();
|
|
332
|
+
if (this._recording || this._recordingStartFinished)
|
|
333
|
+
throw new Error('Recording is already in progress.');
|
|
334
|
+
}
|
|
173
335
|
async closeTab(index) {
|
|
174
336
|
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
|
175
337
|
if (!tab)
|
|
176
338
|
throw new Error(`Tab ${index} not found`);
|
|
177
339
|
const url = tab.page.url();
|
|
178
|
-
await tab.page.
|
|
340
|
+
await closePage(tab.page, tab.operationTimeout());
|
|
179
341
|
return url;
|
|
180
342
|
}
|
|
181
|
-
async outputFile(name) {
|
|
182
|
-
return outputFile(this.config, name);
|
|
343
|
+
async outputFile(name, exclusive = false) {
|
|
344
|
+
return outputFile(this.config, name, exclusive);
|
|
183
345
|
}
|
|
184
346
|
/**
|
|
185
347
|
* The registry behind `browser_session_open` / `browser_session_close`.
|
|
@@ -192,6 +354,7 @@ export class Context {
|
|
|
192
354
|
return this.options.browserSessions;
|
|
193
355
|
}
|
|
194
356
|
_onPageCreated(page) {
|
|
357
|
+
this._closeAfterRecording = false;
|
|
195
358
|
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
|
196
359
|
this._tabs.push(tab);
|
|
197
360
|
if (!this._currentTab)
|
|
@@ -204,19 +367,36 @@ export class Context {
|
|
|
204
367
|
this._tabs.splice(index, 1);
|
|
205
368
|
if (this._currentTab === tab)
|
|
206
369
|
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
|
207
|
-
if (!this._tabs.length)
|
|
208
|
-
|
|
370
|
+
if (!this._tabs.length) {
|
|
371
|
+
if (this._recording || this._recordingStops.size)
|
|
372
|
+
this._closeAfterRecording = true;
|
|
373
|
+
else
|
|
374
|
+
void this.closeBrowserContext();
|
|
375
|
+
}
|
|
209
376
|
}
|
|
210
377
|
async closeBrowserContext() {
|
|
378
|
+
if (this._idleClosePromise) {
|
|
379
|
+
await this._idleClosePromise;
|
|
380
|
+
this._idleClosePromise = undefined;
|
|
381
|
+
}
|
|
211
382
|
if (!this._closeBrowserContextPromise)
|
|
212
383
|
this._closeBrowserContextPromise = this._closeBrowserContextImpl().catch(logUnhandledError);
|
|
213
384
|
await this._closeBrowserContextPromise;
|
|
214
385
|
this._closeBrowserContextPromise = undefined;
|
|
215
386
|
}
|
|
387
|
+
_closeBrowserContextAfterRecording() {
|
|
388
|
+
if (!this._closeAfterRecording || this._recording || this._recordingStops.size || this._closeBrowserContextPromise)
|
|
389
|
+
return;
|
|
390
|
+
this._closeAfterRecording = false;
|
|
391
|
+
void this.closeBrowserContext();
|
|
392
|
+
}
|
|
216
393
|
/** True while ANY tool call is running in this Context, overlap included. */
|
|
217
394
|
isRunningTool() {
|
|
218
395
|
return this._runningTools.length > 0;
|
|
219
396
|
}
|
|
397
|
+
isRunningToolForRecording(buffered) {
|
|
398
|
+
return this._runningTools.some(name => !recorderControlTools.has(name)) || buffered && Date.now() - this._lastToolCallEndedAt <= recorderBufferMs;
|
|
399
|
+
}
|
|
220
400
|
/**
|
|
221
401
|
* Registers an in-flight download save. Disposal waits (bounded) for the
|
|
222
402
|
* registered saves before closing the browser context, and the session TTL
|
|
@@ -228,7 +408,12 @@ export class Context {
|
|
|
228
408
|
trackPendingDownload(promise) {
|
|
229
409
|
const settled = promise.catch(logUnhandledError);
|
|
230
410
|
this._pendingDownloads.add(settled);
|
|
231
|
-
|
|
411
|
+
this._scheduleIdleTimeout();
|
|
412
|
+
void settled.then(() => {
|
|
413
|
+
this._pendingDownloads.delete(settled);
|
|
414
|
+
this._lastActivityAt = Date.now();
|
|
415
|
+
this._scheduleIdleTimeout();
|
|
416
|
+
});
|
|
232
417
|
}
|
|
233
418
|
/** True while a download save is still writing its file. */
|
|
234
419
|
hasPendingDownloads() {
|
|
@@ -272,6 +457,7 @@ export class Context {
|
|
|
272
457
|
*/
|
|
273
458
|
beginToolCall(name) {
|
|
274
459
|
this._runningTools.push(name);
|
|
460
|
+
this._scheduleIdleTimeout();
|
|
275
461
|
let released = false;
|
|
276
462
|
return () => {
|
|
277
463
|
if (released)
|
|
@@ -280,8 +466,48 @@ export class Context {
|
|
|
280
466
|
const index = this._runningTools.lastIndexOf(name);
|
|
281
467
|
if (index !== -1)
|
|
282
468
|
this._runningTools.splice(index, 1);
|
|
469
|
+
if (!recorderControlTools.has(name))
|
|
470
|
+
this._lastToolCallEndedAt = Date.now();
|
|
471
|
+
this._lastActivityAt = Date.now();
|
|
472
|
+
this._scheduleIdleTimeout();
|
|
283
473
|
};
|
|
284
474
|
}
|
|
475
|
+
async resumeAfterIdle() {
|
|
476
|
+
if (!this._idleClosePromise)
|
|
477
|
+
return;
|
|
478
|
+
await this._idleClosePromise;
|
|
479
|
+
await this._ensureBrowserContext();
|
|
480
|
+
this._idleClosePromise = undefined;
|
|
481
|
+
return 'The browser connection was released after inactivity and has been reopened. Use browser_navigate to navigate again if needed; previous element references are no longer valid.';
|
|
482
|
+
}
|
|
483
|
+
_scheduleIdleTimeout() {
|
|
484
|
+
const group = this._idleGroup;
|
|
485
|
+
if (!group)
|
|
486
|
+
return;
|
|
487
|
+
clearTimeout(group.timer);
|
|
488
|
+
group.timer = undefined;
|
|
489
|
+
if (group.closing || !group.contexts.size)
|
|
490
|
+
return;
|
|
491
|
+
let deadline = 0;
|
|
492
|
+
for (const context of group.contexts) {
|
|
493
|
+
if (!context.config.timeouts.idle || context.options.browserSession || context.isRunningTool() || context.hasPendingDownloads() || context._recording || context._recordingStartFinished || context._recordingStops.size)
|
|
494
|
+
return;
|
|
495
|
+
deadline = Math.max(deadline, context._lastActivityAt + context.config.timeouts.idle);
|
|
496
|
+
}
|
|
497
|
+
group.timer = setTimeout(() => {
|
|
498
|
+
group.timer = undefined;
|
|
499
|
+
const contexts = [...group.contexts];
|
|
500
|
+
// Invoke every close synchronously before another tool can acquire a
|
|
501
|
+
// shared context. Factory release hooks preserve external ownership.
|
|
502
|
+
group.closing = Promise.all(contexts.map(context => context.closeBrowserContext())).then(() => {
|
|
503
|
+
idleGroups.delete(group.browserContext);
|
|
504
|
+
});
|
|
505
|
+
for (const context of contexts)
|
|
506
|
+
context._idleClosePromise = group.closing;
|
|
507
|
+
void group.closing.catch(logUnhandledError);
|
|
508
|
+
}, Math.max(0, deadline - Date.now()));
|
|
509
|
+
group.timer.unref?.();
|
|
510
|
+
}
|
|
285
511
|
async _closeBrowserContextImpl() {
|
|
286
512
|
if (!this._browserContextPromise)
|
|
287
513
|
return;
|
|
@@ -309,6 +535,9 @@ export class Context {
|
|
|
309
535
|
// window to finish, so the files tool responses reported as "still
|
|
310
536
|
// downloading" actually materialize.
|
|
311
537
|
await this._waitForPendingDownloads();
|
|
538
|
+
if (this._recording)
|
|
539
|
+
await this.stopRecording().catch(logUnhandledError);
|
|
540
|
+
await Promise.all(this._recordingStops);
|
|
312
541
|
this._detachFromBrowserContext();
|
|
313
542
|
// close() is the factory's only cleanup hook — for storage-state
|
|
314
543
|
// sessions it also removes the disposable profile — and this close
|
|
@@ -344,10 +573,20 @@ export class Context {
|
|
|
344
573
|
// listener would keep creating tabs inside a disposed Context, and the tab
|
|
345
574
|
// wrappers' own page listeners would pile up with session churn.
|
|
346
575
|
_detachFromBrowserContext() {
|
|
576
|
+
if (this._idleGroup) {
|
|
577
|
+
this._idleGroup.contexts.delete(this);
|
|
578
|
+
this._scheduleIdleTimeout();
|
|
579
|
+
if (!this._idleGroup.contexts.size && !this._idleGroup.closing)
|
|
580
|
+
idleGroups.delete(this._idleGroup.browserContext);
|
|
581
|
+
this._idleGroup = undefined;
|
|
582
|
+
}
|
|
347
583
|
this._removePageObserver?.();
|
|
348
584
|
this._removePageObserver = undefined;
|
|
585
|
+
this._removeRecorderContext?.();
|
|
586
|
+
this._removeRecorderContext = undefined;
|
|
349
587
|
this._inputRecorder?.dispose();
|
|
350
588
|
this._inputRecorder = undefined;
|
|
589
|
+
this._closeAfterRecording = false;
|
|
351
590
|
for (const tab of this._tabs)
|
|
352
591
|
tab.dispose();
|
|
353
592
|
this._tabs = [];
|
|
@@ -369,6 +608,23 @@ export class Context {
|
|
|
369
608
|
// The factory gets the most recently started call's name — with overlap
|
|
370
609
|
// that is the call whose execution is creating the context right now.
|
|
371
610
|
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal, this._runningTools[this._runningTools.length - 1], { browserSession: this.options.browserSession });
|
|
611
|
+
const closingGroup = idleGroups.get(result.browserContext)?.closing;
|
|
612
|
+
if (closingGroup) {
|
|
613
|
+
// A new client can acquire a shared factory lease during idle cleanup.
|
|
614
|
+
// Release it before waiting so the last old client can close the browser.
|
|
615
|
+
await result.close();
|
|
616
|
+
await closingGroup;
|
|
617
|
+
return this._setupBrowserContext();
|
|
618
|
+
}
|
|
619
|
+
let group = idleGroups.get(result.browserContext);
|
|
620
|
+
if (!group) {
|
|
621
|
+
group = { browserContext: result.browserContext, contexts: new Set() };
|
|
622
|
+
idleGroups.set(result.browserContext, group);
|
|
623
|
+
}
|
|
624
|
+
group.contexts.add(this);
|
|
625
|
+
this._idleGroup = group;
|
|
626
|
+
this._lastActivityAt = Date.now();
|
|
627
|
+
this._scheduleIdleTimeout();
|
|
372
628
|
// The factory handed ownership over with close(); a setup failure past
|
|
373
629
|
// this point would otherwise discard that callback with the browser still
|
|
374
630
|
// running — and, for storage-state sessions, the disposable profile
|
|
@@ -376,6 +632,7 @@ export class Context {
|
|
|
376
632
|
try {
|
|
377
633
|
const { browserContext } = result;
|
|
378
634
|
await this._setupRequestInterception(browserContext);
|
|
635
|
+
this._removeRecorderContext = InputRecorder.attachContext(this, browserContext);
|
|
379
636
|
// First real use of this context: resolve — and, once per backend,
|
|
380
637
|
// create — the session log before deciding whether to record input.
|
|
381
638
|
await this.resolveSessionLog();
|
|
@@ -399,60 +656,45 @@ export class Context {
|
|
|
399
656
|
return result;
|
|
400
657
|
}
|
|
401
658
|
}
|
|
659
|
+
const actionIsBuffered = (action) => action.name === 'click' && action.button === 'left' || action.name === 'navigate';
|
|
660
|
+
const pageAliasFromCode = (code) => code.match(/^\s*await\s+(page\d*)\./m)?.[1]
|
|
661
|
+
?? code.match(/^\s*await\s+expect\((page\d*)(?:\.|\))/m)?.[1];
|
|
662
|
+
const addMissingPageAlias = (recorded, page, code, pageIndexes, browserContext) => {
|
|
663
|
+
const alias = pageAliasFromCode(code);
|
|
664
|
+
if (!alias || alias === 'page')
|
|
665
|
+
return;
|
|
666
|
+
const declaration = new RegExp(`^\\s*const\\s+${alias}\\s*=`, 'm');
|
|
667
|
+
if (declaration.test(code) || recorded.some(action => declaration.test(action.code)))
|
|
668
|
+
return;
|
|
669
|
+
const initialPageIndex = pageIndexes.get(page);
|
|
670
|
+
const pageIndex = initialPageIndex ?? browserContext.pages().indexOf(page);
|
|
671
|
+
if (pageIndex === -1)
|
|
672
|
+
return;
|
|
673
|
+
const declarationAction = { page, code: `const ${alias} = context.pages()[${pageIndex}];` };
|
|
674
|
+
if (initialPageIndex === undefined)
|
|
675
|
+
recorded.push(declarationAction);
|
|
676
|
+
else
|
|
677
|
+
recorded.unshift(declarationAction);
|
|
678
|
+
};
|
|
402
679
|
const recorderHubs = new WeakMap();
|
|
680
|
+
const recorderContexts = new WeakMap();
|
|
403
681
|
export class InputRecorder {
|
|
404
682
|
_context;
|
|
405
683
|
_browserContext;
|
|
684
|
+
_lastActions = new WeakMap();
|
|
406
685
|
constructor(context, browserContext) {
|
|
407
686
|
this._context = context;
|
|
408
687
|
this._browserContext = browserContext;
|
|
409
688
|
}
|
|
410
689
|
static async create(context, browserContext) {
|
|
411
690
|
const recorder = new InputRecorder(context, browserContext);
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const recorders = new Set();
|
|
415
|
-
const dispatch = (handle) => {
|
|
416
|
-
// A tool call drives the page through Playwright, and the recorder
|
|
417
|
-
// cannot attribute a DOM event to the session that caused it — so
|
|
418
|
-
// while any session sharing this context runs a tool, the events are
|
|
419
|
-
// automation for every session's log, not user actions. (A real user
|
|
420
|
-
// action racing a sibling's tool call is suppressed with them — the
|
|
421
|
-
// same trade-off a single session already accepts for its own runs.)
|
|
422
|
-
for (const registered of recorders) {
|
|
423
|
-
if (registered._context.isRunningTool())
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
for (const registered of recorders)
|
|
427
|
-
handle(registered);
|
|
428
|
-
};
|
|
429
|
-
const created = {
|
|
430
|
-
recorders,
|
|
431
|
-
ready: browserContext._enableRecorder({
|
|
432
|
-
mode: 'recording',
|
|
433
|
-
recorderMode: 'api',
|
|
434
|
-
}, {
|
|
435
|
-
actionAdded: (page, data, code) => {
|
|
436
|
-
dispatch(registered => registered._actionAdded(page, data, code));
|
|
437
|
-
},
|
|
438
|
-
actionUpdated: (page, data, code) => {
|
|
439
|
-
dispatch(registered => registered._actionUpdated(page, data, code));
|
|
440
|
-
},
|
|
441
|
-
signalAdded: (page, data) => {
|
|
442
|
-
dispatch(registered => registered._signalAdded(page, data));
|
|
443
|
-
},
|
|
444
|
-
}),
|
|
445
|
-
};
|
|
446
|
-
created.ready.catch(() => {
|
|
447
|
-
if (recorderHubs.get(browserContext) === created)
|
|
448
|
-
recorderHubs.delete(browserContext);
|
|
449
|
-
});
|
|
450
|
-
recorderHubs.set(browserContext, created);
|
|
451
|
-
hub = created;
|
|
452
|
-
}
|
|
691
|
+
const existingHub = recorderHubs.get(browserContext);
|
|
692
|
+
const hub = InputRecorder._ensureHub(browserContext);
|
|
453
693
|
hub.recorders.add(recorder);
|
|
454
694
|
try {
|
|
455
695
|
await hub.ready;
|
|
696
|
+
if (existingHub)
|
|
697
|
+
await hub.ensureArmed();
|
|
456
698
|
}
|
|
457
699
|
catch (error) {
|
|
458
700
|
hub.recorders.delete(recorder);
|
|
@@ -460,30 +702,194 @@ export class InputRecorder {
|
|
|
460
702
|
}
|
|
461
703
|
return recorder;
|
|
462
704
|
}
|
|
705
|
+
static attachContext(context, browserContext) {
|
|
706
|
+
let contexts = recorderContexts.get(browserContext);
|
|
707
|
+
if (!contexts) {
|
|
708
|
+
contexts = new Set();
|
|
709
|
+
recorderContexts.set(browserContext, contexts);
|
|
710
|
+
}
|
|
711
|
+
contexts.add(context);
|
|
712
|
+
return () => contexts.delete(context);
|
|
713
|
+
}
|
|
714
|
+
static async startRecording(context, browserContext, target) {
|
|
715
|
+
const existingHub = recorderHubs.get(browserContext);
|
|
716
|
+
const hub = InputRecorder._ensureHub(browserContext);
|
|
717
|
+
++hub.starting;
|
|
718
|
+
try {
|
|
719
|
+
await hub.ready;
|
|
720
|
+
if (existingHub) {
|
|
721
|
+
await new Promise(resolve => setTimeout(resolve, recorderBufferMs));
|
|
722
|
+
await hub.arm();
|
|
723
|
+
}
|
|
724
|
+
for (const [index, page] of browserContext.pages().entries())
|
|
725
|
+
target.pageIndexes.set(page, index);
|
|
726
|
+
hub.recordings.set(context, target);
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
if (hub.recordings.get(context) === target)
|
|
730
|
+
hub.recordings.delete(context);
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
finally {
|
|
734
|
+
--hub.starting;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
static async stopRecording(context, browserContext, target) {
|
|
738
|
+
// Playwright buffers clicks and navigations for 500ms so a later
|
|
739
|
+
// event can refine them. Keep this recording registered until that last
|
|
740
|
+
// event arrives; config.timeouts.settle may be shorter or disabled.
|
|
741
|
+
const recordings = recorderHubs.get(browserContext)?.recordings;
|
|
742
|
+
await new Promise(resolve => setTimeout(resolve, recorderBufferMs));
|
|
743
|
+
if (target && recordings?.get(context) === target) {
|
|
744
|
+
recordings.delete(context);
|
|
745
|
+
const hub = recorderHubs.get(browserContext);
|
|
746
|
+
await hub?.standbyIfIdle().catch(logUnhandledError);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
463
749
|
dispose() {
|
|
464
|
-
recorderHubs.get(this._browserContext)
|
|
750
|
+
const hub = recorderHubs.get(this._browserContext);
|
|
751
|
+
hub?.recorders.delete(this);
|
|
752
|
+
void hub?.standbyIfIdle().catch(logUnhandledError);
|
|
753
|
+
}
|
|
754
|
+
static _ensureHub(browserContext) {
|
|
755
|
+
const hub = recorderHubs.get(browserContext);
|
|
756
|
+
if (hub)
|
|
757
|
+
return hub;
|
|
758
|
+
const recorders = new Set();
|
|
759
|
+
const recordings = new Map();
|
|
760
|
+
let actionSequence = 0;
|
|
761
|
+
const lastActionSequence = new WeakMap();
|
|
762
|
+
let armed = false;
|
|
763
|
+
let transition = Promise.resolve();
|
|
764
|
+
const enqueue = (callback) => {
|
|
765
|
+
const result = transition.then(callback, callback);
|
|
766
|
+
transition = result.catch(() => { });
|
|
767
|
+
return result;
|
|
768
|
+
};
|
|
769
|
+
const dispatch = (buffered, flushable, log, record) => {
|
|
770
|
+
const contexts = new Set(recorderContexts.get(browserContext));
|
|
771
|
+
for (const context of recordings.keys())
|
|
772
|
+
contexts.add(context);
|
|
773
|
+
const running = [...contexts].filter(context => context.isRunningToolForRecording(buffered));
|
|
774
|
+
if (!running.length) {
|
|
775
|
+
for (const recorder of recorders)
|
|
776
|
+
log(recorder);
|
|
777
|
+
}
|
|
778
|
+
for (const [context, target] of recordings) {
|
|
779
|
+
if ((!target.state.stopping || flushable) && !running.some(runningContext => runningContext !== context)) {
|
|
780
|
+
record(target);
|
|
781
|
+
context.markRecordingActivity();
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
const params = {
|
|
786
|
+
mode: 'recording',
|
|
787
|
+
recorderMode: 'api',
|
|
788
|
+
omitCallTracking: true,
|
|
789
|
+
language: 'javascript',
|
|
790
|
+
};
|
|
791
|
+
const sink = {
|
|
792
|
+
actionAdded: (page, data, code) => {
|
|
793
|
+
const sequence = ++actionSequence;
|
|
794
|
+
lastActionSequence.set(page, sequence);
|
|
795
|
+
const action = 'action' in data ? data.action : data;
|
|
796
|
+
const isAssertion = action.name.startsWith('assert');
|
|
797
|
+
if (isAssertion)
|
|
798
|
+
code = code.replace(/^(\s*)\/\/ ?/gm, '$1');
|
|
799
|
+
const buffered = actionIsBuffered(action);
|
|
800
|
+
dispatch(buffered, buffered || action.name === 'closePage', recorder => recorder._actionAdded(page, action, isAssertion ? `${expectPrelude}\n${code}` : code, sequence), target => {
|
|
801
|
+
if (isAssertion && !target.actions.some(action => action.code === expectPrelude))
|
|
802
|
+
target.actions.push({ page, code: expectPrelude });
|
|
803
|
+
addMissingPageAlias(target.actions, page, code, target.pageIndexes, browserContext);
|
|
804
|
+
target.actions.push({ page, code, sequence });
|
|
805
|
+
});
|
|
806
|
+
},
|
|
807
|
+
actionUpdated: (page, data, code) => {
|
|
808
|
+
const sequence = lastActionSequence.get(page);
|
|
809
|
+
if (sequence === undefined)
|
|
810
|
+
return;
|
|
811
|
+
const action = 'action' in data ? data.action : data;
|
|
812
|
+
dispatch(true, true, recorder => recorder._actionUpdated(page, action, code, sequence), target => {
|
|
813
|
+
const recorded = target.actions.findLast(action => action.sequence === sequence);
|
|
814
|
+
if (recorded)
|
|
815
|
+
recorded.code = code;
|
|
816
|
+
});
|
|
817
|
+
},
|
|
818
|
+
signalAdded: (page, data, code) => {
|
|
819
|
+
const sequence = lastActionSequence.get(page);
|
|
820
|
+
const signal = 'signal' in data ? data.signal : data;
|
|
821
|
+
dispatch(true, true, recorder => recorder._signalAdded(page, signal, code, sequence), target => {
|
|
822
|
+
if (sequence === undefined)
|
|
823
|
+
return;
|
|
824
|
+
const action = target.actions.findLast(action => action.sequence === sequence);
|
|
825
|
+
if (action && code)
|
|
826
|
+
action.code = code;
|
|
827
|
+
});
|
|
828
|
+
},
|
|
829
|
+
};
|
|
830
|
+
const arm = async () => {
|
|
831
|
+
await browserContext._enableRecorder(params, sink);
|
|
832
|
+
armed = true;
|
|
833
|
+
};
|
|
834
|
+
const created = {
|
|
835
|
+
recorders,
|
|
836
|
+
recordings,
|
|
837
|
+
starting: 0,
|
|
838
|
+
ready: enqueue(arm),
|
|
839
|
+
arm: () => enqueue(arm),
|
|
840
|
+
ensureArmed: () => enqueue(async () => {
|
|
841
|
+
if (armed)
|
|
842
|
+
return;
|
|
843
|
+
await arm();
|
|
844
|
+
}),
|
|
845
|
+
standbyIfIdle: () => enqueue(async () => {
|
|
846
|
+
if (created.starting || recorders.size || recordings.size)
|
|
847
|
+
return;
|
|
848
|
+
try {
|
|
849
|
+
await browserContext._disableRecorder();
|
|
850
|
+
}
|
|
851
|
+
finally {
|
|
852
|
+
armed = false;
|
|
853
|
+
}
|
|
854
|
+
}),
|
|
855
|
+
};
|
|
856
|
+
created.ready.catch(() => {
|
|
857
|
+
if (recorderHubs.get(browserContext) === created)
|
|
858
|
+
recorderHubs.delete(browserContext);
|
|
859
|
+
});
|
|
860
|
+
recorderHubs.set(browserContext, created);
|
|
861
|
+
return created;
|
|
465
862
|
}
|
|
466
|
-
_actionAdded(page,
|
|
863
|
+
_actionAdded(page, action, code, sequence) {
|
|
864
|
+
this._lastActions.set(page, { action, sequence });
|
|
467
865
|
const tab = this._context.tabForPage(page);
|
|
468
866
|
if (tab)
|
|
469
|
-
this._context.sessionLog.logUserAction(
|
|
867
|
+
this._context.sessionLog.logUserAction(action, tab, code, false);
|
|
470
868
|
}
|
|
471
|
-
_actionUpdated(page,
|
|
869
|
+
_actionUpdated(page, action, code, sequence) {
|
|
870
|
+
if (this._lastActions.get(page)?.sequence !== sequence)
|
|
871
|
+
return;
|
|
872
|
+
this._lastActions.set(page, { action, sequence });
|
|
472
873
|
const tab = this._context.tabForPage(page);
|
|
473
874
|
if (tab)
|
|
474
|
-
this._context.sessionLog.logUserAction(
|
|
875
|
+
this._context.sessionLog.logUserAction(action, tab, code, true);
|
|
475
876
|
}
|
|
476
|
-
_signalAdded(page,
|
|
477
|
-
|
|
877
|
+
_signalAdded(page, signal, code, sequence) {
|
|
878
|
+
const lastAction = this._lastActions.get(page);
|
|
879
|
+
if (sequence !== undefined && lastAction?.sequence !== sequence)
|
|
478
880
|
return;
|
|
479
881
|
const tab = this._context.tabForPage(page);
|
|
882
|
+
if (signal.name !== 'navigation' && tab && code && lastAction)
|
|
883
|
+
this._context.sessionLog.logUserAction(lastAction.action, tab, code, true);
|
|
884
|
+
if (signal.name !== 'navigation')
|
|
885
|
+
return;
|
|
480
886
|
const navigateAction = {
|
|
481
887
|
name: 'navigate',
|
|
482
|
-
url:
|
|
888
|
+
url: signal.url,
|
|
483
889
|
signals: [],
|
|
484
890
|
};
|
|
485
891
|
if (tab)
|
|
486
|
-
this._context.sessionLog.logUserAction(navigateAction, tab, `await page.goto('${
|
|
892
|
+
this._context.sessionLog.logUserAction(navigateAction, tab, `await page.goto('${signal.url}');`, false);
|
|
487
893
|
}
|
|
488
894
|
}
|
|
489
895
|
//# sourceMappingURL=context.js.map
|