@modelprofile.com/browser-runtime 5.2.1 → 5.4.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.
@@ -105,6 +105,7 @@ interface INormalizedRuntimeOptions {
105
105
  frameAcknowledgementTimeoutMs: number;
106
106
  screencast: INormalizedScreencastOptions;
107
107
  video: Required<plugins.smartpuppeteer.ILiveVideoOptions>;
108
+ devTools: boolean;
108
109
  egress: NonNullable<IBrowserRuntimeOptions['egress']>;
109
110
  ownership?: IBrowserRuntimeTestingOptions['ownership'];
110
111
  beforeLeasePublication?: IBrowserRuntimeTestingOptions['beforeLeasePublication'];
@@ -144,10 +145,25 @@ interface ILeaseRecord {
144
145
  videoPeerId: string;
145
146
  videoPeerMayExist: boolean;
146
147
  frameCaptureUsed: boolean;
148
+ devTools?: IDevToolsRecord;
147
149
  heldKeys: Map<string, plugins.smartpuppeteer.ILiveBrowserKeyInput>;
148
150
  heldButtons: Map<string, plugins.smartpuppeteer.ILiveBrowserMouseInput>;
149
151
  }
150
152
 
153
+ interface IDevToolsRecord {
154
+ authority: TBrowserRuntimeLeaseAuthority;
155
+ session: ILiveBrowserSessionLike;
156
+ controller: AbortController;
157
+ connection?: plugins.smartpuppeteer.ILiveBrowserDevToolsConnection;
158
+ opening?: Promise<plugins.smartpuppeteer.ILiveBrowserDevToolsConnection>;
159
+ closePromise?: Promise<void>;
160
+ pending: Set<Promise<void>>;
161
+ commands: number;
162
+ interrupts: number;
163
+ closed: boolean;
164
+ removeAbortListener(): void;
165
+ }
166
+
151
167
  export interface IBrowserRuntimeLeaseRenewalOptions {
152
168
  signal?: AbortSignal;
153
169
  /** Lifetime from successful reauthorization; defaults to the runtime capability lifetime. */
@@ -246,7 +262,9 @@ interface IResourceSlot {
246
262
  profileDirectory?: string;
247
263
  unsubscribeSession?: () => void;
248
264
  leases: Map<string, ILeaseRecord>;
249
- operation?: IOperationRecord;
265
+ operations: Set<IOperationRecord>;
266
+ activeQueuedOperations: Set<IQueuedOperationRecord>;
267
+ inputCleanup?: { session: ILiveBrowserSessionLike; incarnationGeneration: number; promise: Promise<void> };
250
268
  operationQueue: IQueuedOperationRecord[];
251
269
  operationSchedulerRunning: boolean;
252
270
  frameSubscriptions: Map<string, IFrameSubscriptionRecord>;
@@ -318,6 +336,9 @@ export class BrowserRuntime {
318
336
  if (options.beforeOperation !== undefined && typeof options.beforeOperation !== 'function') {
319
337
  throw new BrowserRuntimeError('INVALID_INPUT', 'beforeOperation must be a function');
320
338
  }
339
+ if (options.devTools !== undefined && typeof options.devTools !== 'boolean') {
340
+ throw new BrowserRuntimeError('INVALID_INPUT', 'devTools must be a boolean');
341
+ }
321
342
  const testingOptions = browserRuntimeTesting in options
322
343
  && options[browserRuntimeTesting] === true
323
344
  ? options as IBrowserRuntimeTestingOptions
@@ -570,6 +591,7 @@ export class BrowserRuntime {
570
591
  ),
571
592
  screencast,
572
593
  video: plugins.smartpuppeteer.normalizeLiveVideoOptions(options.video ?? {}),
594
+ devTools: options.devTools ?? false,
573
595
  egress: options.egress ?? {},
574
596
  ownership: testingOptions?.ownership,
575
597
  beforeLeasePublication: testingOptions?.beforeLeasePublication,
@@ -723,6 +745,8 @@ export class BrowserRuntime {
723
745
  frameSubscriptions: new Map(),
724
746
  highestFrameSequence: 0,
725
747
  producerAcknowledgements: new Set(),
748
+ operations: new Set(),
749
+ activeQueuedOperations: new Set(),
726
750
  operationQueue: [],
727
751
  operationSchedulerRunning: false,
728
752
  lifecycleTail: Promise.resolve(),
@@ -1374,6 +1398,148 @@ export class BrowserRuntime {
1374
1398
  });
1375
1399
  }
1376
1400
 
1401
+ /** @internal */
1402
+ public async leaseOpenDevTools(lease: ILeaseRecord, options: plugins.smartpuppeteer.ILiveBrowserDevToolsOptions,
1403
+ operationOptions: IBrowserRuntimeOperationOptions = {}): Promise<plugins.smartpuppeteer.ILiveBrowserDevToolsConnection> {
1404
+ this.requireHuman(lease);
1405
+ if (!this.options.devTools) throw new BrowserRuntimeError('CAPABILITY_INVALID', 'DevTools is not enabled.');
1406
+ if (!options || typeof options.onMessage !== 'function' || typeof options.onClose !== 'function') {
1407
+ throw new BrowserRuntimeError('INVALID_INPUT');
1408
+ }
1409
+ const tabId = validateBoundedString(options.tabId, 'tabId', 1, 128);
1410
+ let created: IDevToolsRecord | undefined;
1411
+ try {
1412
+ return await this.runOperation(lease, 'openDevTools', 'devtools', operationOptions, async (signal, session) => {
1413
+ if (!session.openDevTools) throw new BrowserRuntimeError('CAPABILITY_INVALID', 'The browser adapter does not support DevTools.');
1414
+ if (lease.devTools) await this.closeLeaseDevTools(lease, lease.devTools);
1415
+ const controller = new AbortController();
1416
+ const record: IDevToolsRecord = {
1417
+ authority: this.createLeaseAuthority(lease), session, controller,
1418
+ pending: new Set(), commands: 0, interrupts: 0, closed: false,
1419
+ removeAbortListener: () => lease.controller.signal.removeEventListener('abort', abort),
1420
+ };
1421
+ const abort = (): void => { this.trackCleanup(this.closeLeaseDevTools(lease, record)); };
1422
+ lease.controller.signal.addEventListener('abort', abort, { once: true });
1423
+ created = record;
1424
+ lease.devTools = record;
1425
+ record.opening = session.openDevTools({ tabId,
1426
+ onMessage: async message => {
1427
+ this.requireDevToolsAuthority(lease, record);
1428
+ await options.onMessage(message);
1429
+ this.requireDevToolsAuthority(lease, record);
1430
+ },
1431
+ onClose: reason => {
1432
+ record.closed = true;
1433
+ controller.abort(new BrowserRuntimeError('ABORTED'));
1434
+ record.removeAbortListener();
1435
+ options.onClose(reason);
1436
+ },
1437
+ }, { signal });
1438
+ const connection = await record.opening;
1439
+ record.connection = connection;
1440
+ try {
1441
+ signal.throwIfAborted();
1442
+ this.requireDevToolsAuthority(lease, record);
1443
+ } catch (error) { await this.closeLeaseDevTools(lease, record); throw error; }
1444
+ return {
1445
+ tabId: connection.tabId,
1446
+ browserVersion: connection.browserVersion,
1447
+ send: message => this.sendLeaseDevTools(lease, record, message),
1448
+ close: () => this.closeLeaseDevTools(lease, record),
1449
+ };
1450
+ });
1451
+ } catch (error) {
1452
+ if (created) await this.closeLeaseDevTools(lease, created);
1453
+ throw error;
1454
+ }
1455
+ }
1456
+
1457
+ private requireDevToolsAuthority(lease: ILeaseRecord, record: IDevToolsRecord): void {
1458
+ this.requireHuman(lease);
1459
+ if (record.closed || record.controller.signal.aborted || lease.devTools !== record
1460
+ || lease.slot.session !== record.session || !this.isLeaseAuthorityCurrent(lease, record.authority)) {
1461
+ throw new BrowserRuntimeError('CAPABILITY_REVOKED');
1462
+ }
1463
+ }
1464
+
1465
+ private async sendLeaseDevTools(lease: ILeaseRecord, record: IDevToolsRecord, message: string): Promise<void> {
1466
+ this.requireDevToolsAuthority(lease, record);
1467
+ const limits = plugins.smartpuppeteer.liveBrowserDevToolsLimits;
1468
+ if (typeof message !== 'string' || plugins.Buffer.byteLength(message) > limits.commandBytes) {
1469
+ throw new BrowserRuntimeError('INVALID_INPUT');
1470
+ }
1471
+ let command: { method?: unknown };
1472
+ try { command = JSON.parse(message); } catch { throw new BrowserRuntimeError('INVALID_INPUT'); }
1473
+ if (!command || typeof command.method !== 'string' || !/^[A-Za-z]+\.[A-Za-z]+$/.test(command.method)) {
1474
+ throw new BrowserRuntimeError('INVALID_INPUT');
1475
+ }
1476
+ const action = command.method;
1477
+ const interrupt = limits.interruptMethods.includes(action);
1478
+ if (interrupt ? record.interrupts >= limits.interruptCommands : record.commands >= limits.commands) {
1479
+ throw new BrowserRuntimeError('QUOTA_EXCEEDED');
1480
+ }
1481
+ if (interrupt) record.interrupts++; else record.commands++;
1482
+ const startedAt = Date.now();
1483
+ const operation: IOperationRecord = {
1484
+ operationId: randomId(), lease, controller: record.controller, action, startedAt,
1485
+ session: record.session, incarnationGeneration: record.authority.incarnationGeneration,
1486
+ promise: Promise.resolve(),
1487
+ };
1488
+ const identity = this.createOperationIdentity(lease, operation, action, 'devtools', startedAt);
1489
+ const task = (async () => {
1490
+ let failed = false;
1491
+ let failure: unknown;
1492
+ try {
1493
+ await this.runBeforeOperation(identity, record.controller.signal);
1494
+ this.requireDevToolsAuthority(lease, record);
1495
+ await record.connection!.send(message);
1496
+ this.requireDevToolsAuthority(lease, record);
1497
+ } catch (error) { failed = true; failure = error; }
1498
+ finally {
1499
+ try {
1500
+ await this.emitAudit(Object.freeze({ ...identity, phase: failed ? 'failed' : 'completed',
1501
+ finishedAt: Date.now(), ...(failed ? { errorCode: this.operationErrorCode(failure) } : {}) }));
1502
+ } finally {
1503
+ if (interrupt) record.interrupts--; else record.commands--;
1504
+ }
1505
+ }
1506
+ if (failed) throw failure;
1507
+ })();
1508
+ record.pending.add(task);
1509
+ void task.then(() => record.pending.delete(task), () => record.pending.delete(task));
1510
+ return task;
1511
+ }
1512
+
1513
+ private closeLeaseDevTools(lease: ILeaseRecord, record: IDevToolsRecord): Promise<void> {
1514
+ if (record.closePromise) return record.closePromise;
1515
+ record.closed = true;
1516
+ record.controller.abort(new BrowserRuntimeError('ABORTED'));
1517
+ record.removeAbortListener();
1518
+ const closing = (async () => {
1519
+ try {
1520
+ const detached = await waitBounded((async () => {
1521
+ // Opening may still be completing when lease revocation arrives.
1522
+ await Promise.resolve();
1523
+ const connection = record.connection ?? await record.opening?.catch(() => undefined);
1524
+ await connection?.close();
1525
+ })(), this.options.quiescenceTimeoutMs + 3500);
1526
+ if (!detached.settled) throw new BrowserRuntimeError('TIMEOUT');
1527
+ const pending = await waitBounded(Promise.allSettled([...record.pending]),
1528
+ this.options.quiescenceTimeoutMs + this.options.auditTimeoutMs + this.options.beforeOperationTimeoutMs);
1529
+ if (!pending.settled) throw new BrowserRuntimeError('TIMEOUT');
1530
+ } catch (error) {
1531
+ await this.terminateRegisteredResourceInternal(lease.slot, false, {
1532
+ session: record.session, incarnationGeneration: record.authority.incarnationGeneration,
1533
+ });
1534
+ throw error;
1535
+ } finally {
1536
+ if (lease.devTools === record) lease.devTools = undefined;
1537
+ }
1538
+ })();
1539
+ record.closePromise = closing;
1540
+ return closing;
1541
+ }
1542
+
1377
1543
  /** @internal */
1378
1544
  public async leaseAnswerVideoPeer(lease: ILeaseRecord, negotiationId: string,
1379
1545
  description: plugins.smartpuppeteer.ILiveVideoDescription, options: IBrowserRuntimeOperationOptions = {}): Promise<void> {
@@ -1478,6 +1644,24 @@ export class BrowserRuntime {
1478
1644
  );
1479
1645
  }
1480
1646
 
1647
+ /** @internal */
1648
+ public async leaseRespondToDialog(lease: ILeaseRecord,
1649
+ input: plugins.smartpuppeteer.ILiveBrowserDialogResponse,
1650
+ options: IBrowserRuntimeOperationOptions = {}): Promise<void> {
1651
+ this.requireHuman(lease);
1652
+ const record = validateExactKeys(input, ['tabId', 'dialogId', 'accept', 'promptText'], 'dialog');
1653
+ const tabId = validateBoundedString(record.tabId, 'tabId', 1, 128);
1654
+ const dialogId = validateBoundedString(record.dialogId, 'dialogId', 1, 128);
1655
+ if (typeof record.accept !== 'boolean' || (record.promptText !== undefined
1656
+ && (typeof record.promptText !== 'string' || record.promptText.length > 32768))) {
1657
+ throw new BrowserRuntimeError('INVALID_INPUT');
1658
+ }
1659
+ const response = { tabId, dialogId, accept: record.accept,
1660
+ ...(record.promptText !== undefined ? { promptText: record.promptText as string } : {}) };
1661
+ return this.runOperation(lease, 'respondToDialog', 'dialog', options,
1662
+ async (signal, session) => session.respondToDialog(response, { signal }));
1663
+ }
1664
+
1481
1665
  /** @internal */
1482
1666
  public async leaseDispatchRawInput(
1483
1667
  lease: ILeaseRecord,
@@ -1560,7 +1744,7 @@ export class BrowserRuntime {
1560
1744
  const tab = state.tabs.find((value) => value.id === state.activeTabId);
1561
1745
  if (!tab || tab.status !== 'open' || tab.id !== input.tabId
1562
1746
  || tab.generation !== input.generation || state.viewportRevision !== input.viewportRevision
1563
- ) throw new BrowserRuntimeError('INVALID_INPUT');
1747
+ ) throw new BrowserRuntimeError('STALE_INPUT');
1564
1748
  }
1565
1749
 
1566
1750
  private mouseOwnershipKey(input: plugins.smartpuppeteer.ILiveBrowserMouseInput): string {
@@ -1608,6 +1792,29 @@ export class BrowserRuntime {
1608
1792
  return modifiers;
1609
1793
  }
1610
1794
 
1795
+ private async releaseStaleParticipantInput(slot: IResourceSlot, session: ILiveBrowserSessionLike): Promise<void> {
1796
+ const incarnationGeneration = slot.incarnationGeneration;
1797
+ if (slot.session !== session) throw new BrowserRuntimeError('ABORTED');
1798
+ if (slot.inputCleanup?.session === session
1799
+ && slot.inputCleanup.incarnationGeneration === incarnationGeneration) return slot.inputCleanup.promise;
1800
+ const state = session.getState();
1801
+ const tab = state.tabs.find(tab => tab.id === state.activeTabId);
1802
+ if (!tab?.streaming || tab.dialog) return;
1803
+ const affected = [...slot.leases.values()].filter(lease => [...lease.heldKeys.values(), ...lease.heldButtons.values()]
1804
+ .some(input => input.tabId !== tab.id || input.generation !== tab.generation
1805
+ || input.viewportRevision !== state.viewportRevision));
1806
+ if (!affected.length) return;
1807
+ // The runtime owns native held input, including link navigation that bypasses its command queue.
1808
+ const cleanup = { session, incarnationGeneration, promise: (async () => {
1809
+ for (const lease of affected) {
1810
+ if (slot.session !== session || slot.incarnationGeneration !== incarnationGeneration) return;
1811
+ await this.releaseParticipantInput(lease, session);
1812
+ }
1813
+ })() };
1814
+ slot.inputCleanup = cleanup;
1815
+ try { await cleanup.promise; } finally { if (slot.inputCleanup === cleanup) slot.inputCleanup = undefined; }
1816
+ }
1817
+
1611
1818
  private async releaseAllParticipantInput(
1612
1819
  slot: IResourceSlot, session: ILiveBrowserSessionLike,
1613
1820
  ): Promise<void> {
@@ -1762,10 +1969,9 @@ export class BrowserRuntime {
1762
1969
  /** @internal */
1763
1970
  public cancelLeaseOperation(lease: ILeaseRecord): boolean {
1764
1971
  this.requireValidLease(lease);
1765
- const operation = lease.slot.operation;
1766
- if (!operation || operation.lease !== lease) return false;
1767
- operation.controller.abort(new BrowserRuntimeError('ABORTED'));
1768
- return true;
1972
+ const operations = [...lease.slot.operations].filter(operation => operation.lease === lease);
1973
+ for (const operation of operations) operation.controller.abort(new BrowserRuntimeError('ABORTED'));
1974
+ return operations.length > 0;
1769
1975
  }
1770
1976
 
1771
1977
  /** @internal */
@@ -2013,6 +2219,7 @@ export class BrowserRuntime {
2013
2219
  usePipe: true,
2014
2220
  allowEvaluation: true,
2015
2221
  video: this.options.video,
2222
+ allowDevTools: this.options.devTools,
2016
2223
  screencast: {
2017
2224
  enabled: false, maxOutstandingFrames: this.options.maxOutstandingFrames,
2018
2225
  quality: this.options.screencast.quality,
@@ -2228,37 +2435,42 @@ export class BrowserRuntime {
2228
2435
  private drainOperationQueue(slot: IResourceSlot): void {
2229
2436
  if (slot.operationSchedulerRunning) return;
2230
2437
  slot.operationSchedulerRunning = true;
2231
- const scheduler = (async () => {
2232
- while (slot.operationQueue.length > 0) {
2233
- const queued = slot.operationQueue.shift()!;
2234
- if (queued.state === 'settled') continue;
2235
- queued.state = 'starting';
2236
- try {
2237
- const result = await this.executeQueuedOperation(queued);
2238
- if ((queued as IQueuedOperationRecord).state !== 'settled') {
2239
- queued.state = 'settled';
2240
- queued.resolve(result);
2241
- }
2242
- } catch (error) {
2243
- if (queued.state !== 'settled') {
2244
- queued.state = 'settled';
2245
- queued.reject(error);
2246
- }
2247
- } finally {
2248
- if (queued.onQueuedAbort) {
2249
- queued.signal.removeEventListener('abort', queued.onQueuedAbort);
2250
- queued.onQueuedAbort = undefined;
2251
- }
2438
+ void (async () => {
2439
+ try {
2440
+ while (slot.operationQueue.length > 0) {
2441
+ // Dialog replies must unblock a page operation already waiting on Chrome.
2442
+ const dialogIndex = slot.operationQueue.findIndex(entry => entry.classification === 'dialog');
2443
+ const index = dialogIndex >= 0 ? dialogIndex : 0;
2444
+ const queued = slot.operationQueue[index]!;
2445
+ const active = [...slot.activeQueuedOperations];
2446
+ if (queued.classification === 'dialog') {
2447
+ if (active.some(entry => entry.classification === 'dialog')) break;
2448
+ } else if (active.length > 0 && !(queued.action === 'dispatchWheel'
2449
+ && active.length < 4 && active.every(entry => entry.action === 'dispatchWheel'))) break;
2450
+ slot.operationQueue.splice(index, 1);
2451
+ if (queued.state === 'settled') continue;
2452
+ queued.state = 'starting';
2453
+ slot.activeQueuedOperations.add(queued);
2454
+ let dispatched!: () => void;
2455
+ const admission = new Promise<void>(resolve => { dispatched = resolve; });
2456
+ void this.executeQueuedOperation(queued, dispatched).then(result => {
2457
+ if (queued.state !== 'settled') { queued.state = 'settled'; queued.resolve(result); }
2458
+ }, error => {
2459
+ if (queued.state !== 'settled') { queued.state = 'settled'; queued.reject(error); }
2460
+ }).finally(() => {
2461
+ dispatched();
2462
+ if (queued.onQueuedAbort) queued.signal.removeEventListener('abort', queued.onQueuedAbort);
2463
+ slot.activeQueuedOperations.delete(queued);
2464
+ this.drainOperationQueue(slot);
2465
+ });
2466
+ // Preserve authorization and native-send order without waiting for wheel ACKs.
2467
+ await admission;
2252
2468
  }
2253
- }
2254
- })().finally(() => {
2255
- slot.operationSchedulerRunning = false;
2256
- if (slot.operationQueue.length > 0) this.drainOperationQueue(slot);
2257
- });
2258
- void scheduler.catch(() => undefined);
2469
+ } finally { slot.operationSchedulerRunning = false; }
2470
+ })();
2259
2471
  }
2260
2472
 
2261
- private async executeQueuedOperation(queued: IQueuedOperationRecord): Promise<unknown> {
2473
+ private async executeQueuedOperation(queued: IQueuedOperationRecord, dispatched: () => void): Promise<unknown> {
2262
2474
  const { lease, action, classification } = queued;
2263
2475
  const slot = lease.slot;
2264
2476
  const release = await slot.mutex.acquire();
@@ -2278,7 +2490,12 @@ export class BrowserRuntime {
2278
2490
  queued.signal.throwIfAborted();
2279
2491
  this.requireQueuedLease(lease, queued.participantCleanup);
2280
2492
  this.assertSlotAvailable(slot);
2281
- if (slot.operation) throw new BrowserRuntimeError('BUSY');
2493
+ const active = [...slot.operations];
2494
+ if (classification === 'dialog' ? active.some(operation => operation.action === 'respondToDialog')
2495
+ : active.length > 0 && !(action === 'dispatchWheel' && active.length < 4
2496
+ && active.every(operation => operation.action === 'dispatchWheel'))) {
2497
+ throw new BrowserRuntimeError('BUSY');
2498
+ }
2282
2499
  session = slot.session!;
2283
2500
  generation = slot.arbitrationGeneration;
2284
2501
  incarnationGeneration = slot.incarnationGeneration;
@@ -2308,7 +2525,7 @@ export class BrowserRuntime {
2308
2525
  incarnationGeneration,
2309
2526
  promise: new Promise<void>((resolve) => { resolvePreflight = resolve; }),
2310
2527
  };
2311
- slot.operation = operation;
2528
+ slot.operations.add(operation);
2312
2529
  queued.state = 'active';
2313
2530
  if (queued.onQueuedAbort) {
2314
2531
  queued.signal.removeEventListener('abort', queued.onQueuedAbort);
@@ -2344,7 +2561,7 @@ export class BrowserRuntime {
2344
2561
  this.requireQueuedLease(lease, queued.participantCleanup);
2345
2562
  this.assertSlotAvailable(slot);
2346
2563
  if (
2347
- slot.operation !== operation
2564
+ !slot.operations.has(operation)
2348
2565
  || slot.session !== session
2349
2566
  || slot.arbitrationGeneration !== generation
2350
2567
  || slot.incarnationGeneration !== incarnationGeneration
@@ -2354,8 +2571,11 @@ export class BrowserRuntime {
2354
2571
  if (classification === 'navigation' || classification === 'tab') {
2355
2572
  await this.releaseAllParticipantInput(slot, session);
2356
2573
  }
2574
+ if (classification !== 'dialog') await this.releaseStaleParticipantInput(slot, session);
2357
2575
  combinedSignal.throwIfAborted();
2358
- return queued.execute(combinedSignal, session);
2576
+ const result = queued.execute(combinedSignal, session);
2577
+ dispatched();
2578
+ return result;
2359
2579
  })();
2360
2580
  operation.promise = executionPromise.then(() => undefined, () => undefined).finally(() => {
2361
2581
  executionSettled = true;
@@ -2385,10 +2605,11 @@ export class BrowserRuntime {
2385
2605
  clearTimeout(timeout!);
2386
2606
  queued.externalSignal?.removeEventListener('abort', onExternalAbort!);
2387
2607
  resolvePreflight();
2608
+ dispatched();
2388
2609
  const cleanupSlot = async (): Promise<void> => {
2389
2610
  const cleanupRelease = await slot.mutex.acquire();
2390
2611
  try {
2391
- if (slot.operation === operation) slot.operation = undefined;
2612
+ slot.operations.delete(operation);
2392
2613
  } finally {
2393
2614
  cleanupRelease();
2394
2615
  }
@@ -2397,7 +2618,7 @@ export class BrowserRuntime {
2397
2618
  if (combinedSignal!.aborted) {
2398
2619
  await this.enforceOperationQuiescence(operation).catch(() => undefined);
2399
2620
  }
2400
- if (slot.operation === operation) {
2621
+ if (slot.operations.has(operation)) {
2401
2622
  const settled = await waitBounded(operation.promise, this.options.quiescenceTimeoutMs);
2402
2623
  if (settled.settled) await cleanupSlot();
2403
2624
  }
@@ -2407,7 +2628,7 @@ export class BrowserRuntime {
2407
2628
  if (
2408
2629
  queued.finalize
2409
2630
  && (!executionStarted || executionSettled)
2410
- && slot.operation !== operation
2631
+ && !slot.operations.has(operation)
2411
2632
  ) {
2412
2633
  try {
2413
2634
  await queued.finalize();
@@ -2455,6 +2676,8 @@ export class BrowserRuntime {
2455
2676
  );
2456
2677
  }
2457
2678
  if (errorArg instanceof BrowserRuntimeError) return errorArg;
2679
+ if (errorArg instanceof plugins.smartpuppeteer.LiveBrowserInputStaleError) return new BrowserRuntimeError('STALE_INPUT');
2680
+ if (errorArg instanceof plugins.smartpuppeteer.LiveBrowserDialogPendingError) return new BrowserRuntimeError('DIALOG_PENDING');
2458
2681
  if (errorArg instanceof Error && errorArg.name === 'TimeoutError') {
2459
2682
  return new BrowserRuntimeError('TIMEOUT');
2460
2683
  }
@@ -2727,6 +2950,7 @@ export class BrowserRuntime {
2727
2950
  generation: tab.generation,
2728
2951
  appliedViewportRevision: tab.appliedViewportRevision,
2729
2952
  streaming: tab.streaming,
2953
+ ...(tab.dialog ? { dialog: { ...tab.dialog } } : {}),
2730
2954
  })),
2731
2955
  ...(state.lastError
2732
2956
  ? {
@@ -3094,7 +3318,7 @@ export class BrowserRuntime {
3094
3318
  lease.released = true;
3095
3319
  lease.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3096
3320
  }
3097
- slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3321
+ for (const operation of slot.operations) operation.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3098
3322
  for (const subscription of subscribers) {
3099
3323
  try { subscription.listener(runtimeEvent); } catch { /* Producer failure owns cleanup. */ }
3100
3324
  }
@@ -3286,7 +3510,7 @@ export class BrowserRuntime {
3286
3510
  lease.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3287
3511
  this.notifyFramedLeaseEnded(lease);
3288
3512
  }
3289
- slot.operation?.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3513
+ for (const operation of slot.operations) operation.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3290
3514
  } finally {
3291
3515
  release();
3292
3516
  }
@@ -3302,7 +3526,7 @@ export class BrowserRuntime {
3302
3526
  slot.leases.delete(lease.leaseId);
3303
3527
  if (lease.capability.lease === lease) lease.capability.lease = undefined;
3304
3528
  }
3305
- slot.operation = undefined;
3529
+ slot.operations.clear();
3306
3530
  slot.fencingGeneration = undefined;
3307
3531
  }
3308
3532
  } finally {
@@ -3467,17 +3691,18 @@ export class BrowserRuntime {
3467
3691
  const slot = lease.slot;
3468
3692
  if (slot.leases.get(lease.leaseId) !== lease) return;
3469
3693
  const release = await slot.mutex.acquire();
3470
- let operation: IOperationRecord | undefined;
3694
+ let operations: IOperationRecord[];
3471
3695
  try {
3472
3696
  lease.released = true;
3473
3697
  lease.controller.abort(new BrowserRuntimeError('ABORTED'));
3474
- operation = slot.operation?.lease === lease ? slot.operation : undefined;
3475
- operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
3698
+ operations = [...slot.operations].filter(operation => operation.lease === lease);
3699
+ for (const operation of operations) operation.controller.abort(new BrowserRuntimeError('ABORTED'));
3476
3700
  } finally {
3477
3701
  release();
3478
3702
  }
3479
3703
  await this.closeFrameSubscriptionIfLease(lease);
3480
- if (operation) {
3704
+ if (lease.devTools) await this.closeLeaseDevTools(lease, lease.devTools);
3705
+ for (const operation of operations) {
3481
3706
  const settled = await waitBounded(operation.promise, this.options.quiescenceTimeoutMs);
3482
3707
  if (!settled.settled) {
3483
3708
  // An unquiescent operation belongs to the browser process, so its incarnation must end.
@@ -3525,13 +3750,12 @@ export class BrowserRuntime {
3525
3750
  }
3526
3751
 
3527
3752
  private async quiesceSlot(slot: IResourceSlot): Promise<void> {
3528
- const operation = slot.operation;
3529
- if (!operation) return;
3530
- operation.controller.abort(new BrowserRuntimeError('ABORTED'));
3531
- const settled = await waitBounded(operation.promise, this.options.quiescenceTimeoutMs);
3753
+ const operations = [...slot.operations];
3754
+ for (const operation of operations) operation.controller.abort(new BrowserRuntimeError('ABORTED'));
3755
+ const settled = await waitBounded(Promise.all(operations.map(operation => operation.promise)), this.options.quiescenceTimeoutMs);
3532
3756
  if (settled.settled) return;
3533
3757
  await this.terminateSlotSession(slot);
3534
- if (slot.operation === operation) slot.operation = undefined;
3758
+ for (const operation of operations) slot.operations.delete(operation);
3535
3759
  }
3536
3760
 
3537
3761
  private async enforceOperationQuiescence(operation: IOperationRecord): Promise<void> {
@@ -3547,7 +3771,7 @@ export class BrowserRuntime {
3547
3771
  } catch (error) {
3548
3772
  cleanupError = error;
3549
3773
  }
3550
- if (operation.lease.slot.operation !== operation) {
3774
+ if (!operation.lease.slot.operations.has(operation)) {
3551
3775
  if (cleanupError) throw cleanupError;
3552
3776
  return;
3553
3777
  }
@@ -3557,7 +3781,7 @@ export class BrowserRuntime {
3557
3781
  return;
3558
3782
  }
3559
3783
  const slot = operation.lease.slot;
3560
- if (slot.operation === operation && slot.session === operation.session
3784
+ if (slot.operations.has(operation) && slot.session === operation.session
3561
3785
  && slot.incarnationGeneration === operation.incarnationGeneration) {
3562
3786
  await this.terminateRegisteredResourceInternal(slot, false, {
3563
3787
  session: operation.session, incarnationGeneration: operation.incarnationGeneration,
@@ -3566,13 +3790,13 @@ export class BrowserRuntime {
3566
3790
  const release = await slot.mutex.acquire();
3567
3791
  try {
3568
3792
  if (
3569
- slot.operation === operation
3793
+ slot.operations.has(operation)
3570
3794
  && (
3571
3795
  slot.permanentlyFenced
3572
3796
  || slot.session !== operation.session
3573
3797
  || slot.incarnationGeneration !== operation.incarnationGeneration
3574
3798
  )
3575
- ) slot.operation = undefined;
3799
+ ) slot.operations.delete(operation);
3576
3800
  } finally {
3577
3801
  release();
3578
3802
  }
@@ -3614,6 +3838,7 @@ export class BrowserRuntime {
3614
3838
  }
3615
3839
  slot.unsubscribeSession?.();
3616
3840
  slot.unsubscribeSession = undefined;
3841
+ if (slot.inputCleanup?.session === session) slot.inputCleanup = undefined;
3617
3842
  slot.session = undefined;
3618
3843
  slot.highestFrameSequence = 0;
3619
3844
  slot.producerAcknowledgements.clear();
@@ -3698,7 +3923,7 @@ export class BrowserRuntime {
3698
3923
  lease.controller.abort(new BrowserRuntimeError('ABORTED'));
3699
3924
  if (!stopping) this.notifyFramedLeaseEnded(lease);
3700
3925
  }
3701
- slot.operation?.controller.abort(new BrowserRuntimeError('ABORTED'));
3926
+ for (const operation of slot.operations) operation.controller.abort(new BrowserRuntimeError('ABORTED'));
3702
3927
  } finally {
3703
3928
  release();
3704
3929
  }
@@ -3714,7 +3939,7 @@ export class BrowserRuntime {
3714
3939
  if (lease.capability.lease === lease) lease.capability.lease = undefined;
3715
3940
  slot.leases.delete(lease.leaseId);
3716
3941
  }
3717
- slot.operation = undefined;
3942
+ slot.operations.clear();
3718
3943
  slot.fencingGeneration = undefined;
3719
3944
  } finally {
3720
3945
  finalRelease();
@@ -3815,8 +4040,8 @@ export class BrowserRuntime {
3815
4040
  lease.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3816
4041
  }
3817
4042
  }
3818
- if (slot.operation?.lease.role === 'agent') {
3819
- slot.operation.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
4043
+ for (const operation of slot.operations) {
4044
+ if (operation.lease.role === 'agent') operation.controller.abort(new BrowserRuntimeError('CAPABILITY_REVOKED'));
3820
4045
  }
3821
4046
  } finally {
3822
4047
  release();
@@ -4342,6 +4567,11 @@ export class BrowserRuntimeLease {
4342
4567
  return this.runtime.leaseSetViewport(this.record, viewport, operationOptions);
4343
4568
  }
4344
4569
 
4570
+ public respondToDialog(input: plugins.smartpuppeteer.ILiveBrowserDialogResponse,
4571
+ options: IBrowserRuntimeOperationOptions = {}): Promise<void> {
4572
+ return this.runtime.leaseRespondToDialog(this.record, input, options);
4573
+ }
4574
+
4345
4575
  public dispatchMouse(
4346
4576
  input: plugins.smartpuppeteer.ILiveBrowserMouseInput,
4347
4577
  operationOptions?: IBrowserRuntimeOperationOptions,
@@ -4381,6 +4611,11 @@ export class BrowserRuntimeLease {
4381
4611
  return this.runtime.leaseOpenVideoPeer(this.record, options);
4382
4612
  }
4383
4613
 
4614
+ public openDevTools(options: plugins.smartpuppeteer.ILiveBrowserDevToolsOptions,
4615
+ operationOptions?: IBrowserRuntimeOperationOptions): Promise<plugins.smartpuppeteer.ILiveBrowserDevToolsConnection> {
4616
+ return this.runtime.leaseOpenDevTools(this.record, options, operationOptions);
4617
+ }
4618
+
4384
4619
  public answerVideoPeer(negotiationId: string, description: plugins.smartpuppeteer.ILiveVideoDescription,
4385
4620
  options?: IBrowserRuntimeOperationOptions): Promise<void> {
4386
4621
  return this.runtime.leaseAnswerVideoPeer(this.record, negotiationId, description, options);
package/ts/errors.ts CHANGED
@@ -11,6 +11,8 @@ export type TBrowserRuntimeErrorCode =
11
11
  | 'FENCED'
12
12
  | 'FRAME_STREAM_FAILED'
13
13
  | 'FRAME_TOO_LARGE'
14
+ | 'DIALOG_PENDING'
15
+ | 'STALE_INPUT'
14
16
  | 'INVALID_INPUT'
15
17
  | 'LOCKED'
16
18
  | 'NOT_RUNNING'
@@ -33,6 +35,8 @@ const publicMessages: Record<TBrowserRuntimeErrorCode, string> = {
33
35
  FENCED: 'The browser resource is permanently fenced.',
34
36
  FRAME_STREAM_FAILED: 'The browser frame stream failed.',
35
37
  FRAME_TOO_LARGE: 'The browser frame exceeded its limit.',
38
+ DIALOG_PENDING: 'Respond to the website dialog before sending input.',
39
+ STALE_INPUT: 'The input belongs to a previous document or dialog.',
36
40
  INVALID_INPUT: 'The request is invalid.',
37
41
  LOCKED: 'The browser runtime is already locked.',
38
42
  NOT_RUNNING: 'The browser runtime is not running.',