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.
Files changed (69) hide show
  1. package/README.md +146 -11
  2. package/config.d.ts +48 -3
  3. package/lib/browserContextFactory.js +15 -313
  4. package/lib/browserContextFactory.js.map +1 -1
  5. package/lib/browserServerBackend.js +11 -0
  6. package/lib/browserServerBackend.js.map +1 -1
  7. package/lib/browserSessions.js +3 -0
  8. package/lib/browserSessions.js.map +1 -1
  9. package/lib/config.js +70 -12
  10. package/lib/config.js.map +1 -1
  11. package/lib/context.js +463 -57
  12. package/lib/context.js.map +1 -1
  13. package/lib/extension/cdpRelay.js +82 -14
  14. package/lib/extension/cdpRelay.js.map +1 -1
  15. package/lib/extension/extensionContextFactory.js +15 -8
  16. package/lib/extension/extensionContextFactory.js.map +1 -1
  17. package/lib/index.js +2 -0
  18. package/lib/index.js.map +1 -1
  19. package/lib/mcp/http.js +115 -41
  20. package/lib/mcp/http.js.map +1 -1
  21. package/lib/mcp/server.js +16 -8
  22. package/lib/mcp/server.js.map +1 -1
  23. package/lib/mcp/tool.js +11 -9
  24. package/lib/mcp/tool.js.map +1 -1
  25. package/lib/program.js +28 -8
  26. package/lib/program.js.map +1 -1
  27. package/lib/response.js +22 -5
  28. package/lib/response.js.map +1 -1
  29. package/lib/sessionLog.js +66 -20
  30. package/lib/sessionLog.js.map +1 -1
  31. package/lib/tab.js +13 -7
  32. package/lib/tab.js.map +1 -1
  33. package/lib/tools/auditKeyboard.js +27 -25
  34. package/lib/tools/auditKeyboard.js.map +1 -1
  35. package/lib/tools/auditScreenReader.js +27 -18
  36. package/lib/tools/auditScreenReader.js.map +1 -1
  37. package/lib/tools/auditSite.js +88 -56
  38. package/lib/tools/auditSite.js.map +1 -1
  39. package/lib/tools/axe.js +32 -7
  40. package/lib/tools/axe.js.map +1 -1
  41. package/lib/tools/common.js +39 -1
  42. package/lib/tools/common.js.map +1 -1
  43. package/lib/tools/files.js +60 -3
  44. package/lib/tools/files.js.map +1 -1
  45. package/lib/tools/install.js +1 -1
  46. package/lib/tools/install.js.map +1 -1
  47. package/lib/tools/pdf.js +4 -2
  48. package/lib/tools/pdf.js.map +1 -1
  49. package/lib/tools/recorder.js +52 -0
  50. package/lib/tools/recorder.js.map +1 -0
  51. package/lib/tools/report.js +13 -0
  52. package/lib/tools/report.js.map +1 -0
  53. package/lib/tools/scanPageMatrix.js +20 -33
  54. package/lib/tools/scanPageMatrix.js.map +1 -1
  55. package/lib/tools/screenshot.js +7 -3
  56. package/lib/tools/screenshot.js.map +1 -1
  57. package/lib/tools/snapshot.js +10 -3
  58. package/lib/tools/snapshot.js.map +1 -1
  59. package/lib/tools.js +5 -1
  60. package/lib/tools.js.map +1 -1
  61. package/lib/vscode/browserContextFactory.js +16 -12
  62. package/lib/vscode/browserContextFactory.js.map +1 -1
  63. package/lib/vscode/host.js +25 -2
  64. package/lib/vscode/host.js.map +1 -1
  65. package/lib/vscode/main.js +15 -0
  66. package/lib/vscode/main.js.map +1 -1
  67. package/lib/vscode/validation.js +78 -0
  68. package/lib/vscode/validation.js.map +1 -0
  69. 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
- await browserContext.tracing.stop();
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.close();
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
- void this.closeBrowserContext();
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
- void settled.then(() => this._pendingDownloads.delete(settled));
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
- let hub = recorderHubs.get(browserContext);
413
- if (!hub) {
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)?.recorders.delete(this);
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, data, code) {
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(data.action, tab, code, false);
867
+ this._context.sessionLog.logUserAction(action, tab, code, false);
470
868
  }
471
- _actionUpdated(page, data, code) {
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(data.action, tab, code, true);
875
+ this._context.sessionLog.logUserAction(action, tab, code, true);
475
876
  }
476
- _signalAdded(page, data) {
477
- if (data.signal.name !== 'navigation')
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: data.signal.url,
888
+ url: signal.url,
483
889
  signals: [],
484
890
  };
485
891
  if (tab)
486
- this._context.sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
892
+ this._context.sessionLog.logUserAction(navigateAction, tab, `await page.goto('${signal.url}');`, false);
487
893
  }
488
894
  }
489
895
  //# sourceMappingURL=context.js.map