wtagent 0.3.0 → 0.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.
@@ -59,6 +59,12 @@ class CdpTimeoutError extends Error {
59
59
  }
60
60
  }
61
61
 
62
+ function recoveryTargetUnavailable(message, cause = null) {
63
+ const error = new Error(message, cause ? { cause } : undefined);
64
+ error.code = "RECOVERY_TARGET_UNAVAILABLE";
65
+ return error;
66
+ }
67
+
62
68
  async function withTimeout(promise, timeoutMs, message) {
63
69
  let timer;
64
70
  try {
@@ -192,12 +198,49 @@ function sameOriginPath(left, right) {
192
198
  }
193
199
  }
194
200
 
201
+ async function getPageTargetId(context, page, { timeoutMs = 1_500 } = {}) {
202
+ if (!page || typeof context?.newCDPSession !== "function") {
203
+ return null;
204
+ }
205
+ let session = null;
206
+ const probe = (async () => {
207
+ session = await context.newCDPSession(page).catch(() => null);
208
+ if (!session) {
209
+ return null;
210
+ }
211
+ try {
212
+ const { targetInfo } = await session.send("Target.getTargetInfo");
213
+ return targetInfo?.targetId ?? null;
214
+ } catch {
215
+ return null;
216
+ } finally {
217
+ await settleWithin(
218
+ Promise.resolve().then(() => session.detach()),
219
+ 500,
220
+ );
221
+ }
222
+ })();
223
+ try {
224
+ return await withTimeout(
225
+ probe,
226
+ timeoutMs,
227
+ `Timed out reading the Chrome target identity after ${timeoutMs}ms.`,
228
+ );
229
+ } catch {
230
+ // A target that cannot prove its identity is never eligible for selection.
231
+ // The still-running probe owns eventual session cleanup through its finally.
232
+ return null;
233
+ }
234
+ }
235
+
195
236
  export async function launchAndConnectCdpChrome({
196
237
  executablePath,
197
238
  profileDir,
198
239
  url = "about:blank",
199
240
  minimized = false,
200
241
  preferredUrl = null,
242
+ preferredTargetId = null,
243
+ exactTargetOnly = false,
201
244
  }, {
202
245
  acquireProfileLock = acquireCdpProfileLock,
203
246
  connectOverCDP = (endpoint) => chromium.connectOverCDP(endpoint),
@@ -214,7 +257,19 @@ export async function launchAndConnectCdpChrome({
214
257
  waitForExit = waitForProcessExit,
215
258
  waitForReady = waitForCdp,
216
259
  connectTimeoutMs = 20_000,
260
+ transportCloseTimeoutMs = 1_500,
217
261
  } = {}) {
262
+ if (
263
+ exactTargetOnly
264
+ && (
265
+ typeof preferredTargetId !== "string"
266
+ || preferredTargetId.trim().length === 0
267
+ )
268
+ ) {
269
+ throw recoveryTargetUnavailable(
270
+ "Exact-target recovery requires a saved Chrome target ID.",
271
+ );
272
+ }
218
273
  const releaseProfileLock = await acquireProfileLock(profileDir);
219
274
  let child = null;
220
275
  let state = null;
@@ -227,9 +282,23 @@ export async function launchAndConnectCdpChrome({
227
282
  }
228
283
 
229
284
  try {
230
- state = await findReusable();
285
+ try {
286
+ state = await findReusable();
287
+ } catch (error) {
288
+ if (exactTargetOnly) {
289
+ throw recoveryTargetUnavailable(
290
+ "The saved Chrome instance could not be inspected for recovery.",
291
+ error,
292
+ );
293
+ }
294
+ throw error;
295
+ }
231
296
  if (state) {
232
297
  reused = true;
298
+ } else if (exactTargetOnly) {
299
+ throw recoveryTargetUnavailable(
300
+ "The saved Chrome instance is not available for exact-target recovery.",
301
+ );
233
302
  } else {
234
303
  await removeState(profileDir);
235
304
  // A prior instance may have died leaving renderer children (and Chrome's
@@ -294,14 +363,28 @@ export async function launchAndConnectCdpChrome({
294
363
  // without this guard Playwright hangs ~30s and leaves a dirty CDP state.
295
364
  let context;
296
365
  try {
297
- if (reused) {
366
+ if (reused && !exactTargetOnly) {
298
367
  await ensurePageTarget(state.endpoint);
299
368
  }
300
- browser = await withTimeout(
301
- connectOverCDP(state.endpoint),
302
- connectTimeoutMs,
303
- `Timed out connecting to Chrome CDP at ${state.endpoint} after ${connectTimeoutMs}ms.`,
304
- );
369
+ const connectPromise = Promise.resolve().then(() => (
370
+ connectOverCDP(state.endpoint)
371
+ ));
372
+ try {
373
+ browser = await withTimeout(
374
+ connectPromise,
375
+ connectTimeoutMs,
376
+ `Timed out connecting to Chrome CDP at ${state.endpoint} after ${connectTimeoutMs}ms.`,
377
+ );
378
+ } catch (error) {
379
+ if (error instanceof CdpTimeoutError) {
380
+ // connectOverCDP is not cancellable. If it completes after our timeout,
381
+ // disconnect that late transport instead of leaking ownership of Chrome.
382
+ void connectPromise.then((lateBrowser) => (
383
+ settleWithin(lateBrowser?.close?.(), transportCloseTimeoutMs)
384
+ )).catch(() => null);
385
+ }
386
+ throw error;
387
+ }
305
388
  // contexts() forces a real protocol round-trip, so it hangs too when the
306
389
  // browser main thread is stuck — keep it inside the timeout budget.
307
390
  const contexts = await withTimeout(
@@ -311,11 +394,25 @@ export async function launchAndConnectCdpChrome({
311
394
  );
312
395
  context = contexts[0];
313
396
  } catch (error) {
397
+ if (exactTargetOnly) {
398
+ await settleWithin(
399
+ Promise.resolve().then(() => browser?.close()),
400
+ transportCloseTimeoutMs,
401
+ );
402
+ browser = null;
403
+ throw recoveryTargetUnavailable(
404
+ "The saved Chrome instance could not be attached for exact-target recovery.",
405
+ error,
406
+ );
407
+ }
314
408
  if (error instanceof CdpTimeoutError) {
315
409
  // The verified-but-unusable instance we launched is a dead end. Kill it
316
410
  // (only if we own it) and drop its CDP state so the next run starts
317
411
  // clean instead of trying to reuse a hung endpoint.
318
- await browser?.close().catch(() => null);
412
+ await settleWithin(
413
+ Promise.resolve().then(() => browser?.close()),
414
+ transportCloseTimeoutMs,
415
+ );
319
416
  if (!reused && child?.pid) {
320
417
  await killTree(child.pid).catch(() => null);
321
418
  }
@@ -328,25 +425,85 @@ export async function launchAndConnectCdpChrome({
328
425
  throw error;
329
426
  }
330
427
  if (!context) {
331
- throw new Error("Chrome CDP connection did not expose a browser context.");
428
+ throw exactTargetOnly
429
+ ? recoveryTargetUnavailable(
430
+ "The saved Chrome instance has no reusable browser context.",
431
+ )
432
+ : new Error("Chrome CDP connection did not expose a browser context.");
332
433
  }
333
434
 
334
435
  // A reused browser may still contain the previous conversation. Keep it
335
436
  // intact and, when a preferred URL is given, reuse an existing tab already
336
437
  // showing that conversation (origin + path) so resumed runs do not pile up
337
438
  // tabs; otherwise create a fresh target for this CLI session.
439
+ const targetProbeDeadline = Date.now() + connectTimeoutMs;
440
+ const probeTargetId = async (candidate) => {
441
+ const remaining = targetProbeDeadline - Date.now();
442
+ if (remaining <= 0) {
443
+ return null;
444
+ }
445
+ return await getPageTargetId(context, candidate, {
446
+ timeoutMs: Math.min(1_500, remaining),
447
+ });
448
+ };
449
+ const pages = context.pages();
450
+ const existingPageUrls = pages.map((candidate) => (
451
+ candidate.url?.() ?? ""
452
+ ));
338
453
  let page;
339
- if (reused) {
340
- const matchingTab = preferredUrl
341
- ? context.pages().find((candidate) =>
454
+ let preferredTabMatched = false;
455
+ let preferredTargetMatched = false;
456
+ let preferredTabAmbiguous = false;
457
+ let targetId = null;
458
+ if (exactTargetOnly) {
459
+ const matchingTargets = [];
460
+ for (const candidate of pages) {
461
+ const candidateTargetId = await probeTargetId(candidate);
462
+ if (candidateTargetId === preferredTargetId) {
463
+ matchingTargets.push({ page: candidate, targetId: candidateTargetId });
464
+ }
465
+ }
466
+ if (matchingTargets.length !== 1) {
467
+ throw recoveryTargetUnavailable(
468
+ matchingTargets.length === 0
469
+ ? "The exact saved Chrome target is no longer available."
470
+ : "The saved Chrome target identity is ambiguous.",
471
+ );
472
+ }
473
+ page = matchingTargets[0].page;
474
+ targetId = matchingTargets[0].targetId;
475
+ preferredTargetMatched = true;
476
+ } else if (reused) {
477
+ if (preferredTargetId) {
478
+ for (const candidate of pages) {
479
+ const candidateTargetId = await probeTargetId(candidate);
480
+ if (candidateTargetId === preferredTargetId) {
481
+ page = candidate;
482
+ targetId = candidateTargetId;
483
+ preferredTargetMatched = true;
484
+ break;
485
+ }
486
+ }
487
+ }
488
+ if (!page && preferredUrl) {
489
+ const matchingTabs = pages.filter((candidate) =>
342
490
  sameOriginPath(candidate.url?.() ?? "", preferredUrl)
343
- ) ?? null
344
- : null;
345
- page = matchingTab ?? await context.newPage();
491
+ );
492
+ // Duplicate exact-URL tabs are ambiguous. Opening a fresh page is safer
493
+ // than attaching a pending result or follow-up to an arbitrary copy.
494
+ if (matchingTabs.length === 1) {
495
+ page = matchingTabs[0];
496
+ preferredTabMatched = true;
497
+ } else if (matchingTabs.length > 1) {
498
+ preferredTabAmbiguous = true;
499
+ }
500
+ }
501
+ page ??= await context.newPage();
346
502
  } else {
347
- page = context.pages()[0] ?? await context.newPage();
503
+ page = pages[0] ?? await context.newPage();
348
504
  }
349
- if (minimized) {
505
+ targetId ??= await probeTargetId(page);
506
+ if (minimized && !exactTargetOnly) {
350
507
  await setWindowState(context, page, "minimized");
351
508
  }
352
509
 
@@ -358,35 +515,67 @@ export async function launchAndConnectCdpChrome({
358
515
  page,
359
516
  pid: state.pid,
360
517
  reused,
518
+ targetId,
519
+ existingPageUrls,
520
+ exactTargetOnly,
521
+ preferredTabMatched,
522
+ preferredTargetMatched,
523
+ preferredTabAmbiguous,
361
524
  // Minimize / restore the visible window on demand. The runtime restores
362
525
  // the window when it needs the user (manual login, CAPTCHA) and
363
526
  // re-minimizes afterward. Uses the live current page each time so it
364
527
  // targets the window the user is actually looking at.
365
528
  async minimize() {
366
- return await setWindowState(context, page, "minimized");
529
+ return exactTargetOnly
530
+ ? false
531
+ : await setWindowState(context, page, "minimized");
367
532
  },
368
533
  async restore() {
369
- return await setWindowState(context, page, "normal");
534
+ return exactTargetOnly
535
+ ? false
536
+ : await setWindowState(context, page, "normal");
370
537
  },
371
538
  // Drops only the Playwright transport. Unlike close(), never asks Chrome
372
539
  // to exit and never kills the process: used to recover from a dead CDP
373
540
  // connection (e.g. after the Mac slept) while Chrome itself is alive.
374
541
  // The profile lock stays held because the same CLI session will reconnect.
375
542
  async disconnect() {
376
- await settleWithin(browser.close(), 1_500);
543
+ await settleWithin(
544
+ Promise.resolve().then(() => browser.close()),
545
+ transportCloseTimeoutMs,
546
+ );
377
547
  },
378
548
  // Leaves Chrome running (and its saved CDP state intact) but drops the
379
549
  // Playwright transport and the profile lock so a later WTAgent process
380
550
  // can reuse the window. Used after a failed run that wants the page
381
551
  // left open for inspection without blocking the next launch.
382
552
  async detach() {
383
- await settleWithin(browser.close(), 1_500);
384
- await releaseProfileLock();
553
+ try {
554
+ await settleWithin(
555
+ Promise.resolve().then(() => browser.close()),
556
+ transportCloseTimeoutMs,
557
+ );
558
+ } finally {
559
+ await releaseProfileLock();
560
+ }
385
561
  },
386
562
  async close() {
387
563
  if (closePromise) {
388
564
  return await closePromise;
389
565
  }
566
+ if (exactTargetOnly) {
567
+ closePromise = (async () => {
568
+ try {
569
+ await settleWithin(
570
+ Promise.resolve().then(() => browser.close()),
571
+ transportCloseTimeoutMs,
572
+ );
573
+ } finally {
574
+ await releaseProfileLock();
575
+ }
576
+ })();
577
+ return await closePromise;
578
+ }
390
579
  closePromise = (async () => {
391
580
  let exited = false;
392
581
  try {
@@ -396,10 +585,16 @@ export async function launchAndConnectCdpChrome({
396
585
  .catch(() => null);
397
586
  if (session) {
398
587
  await settleWithin(session.send("Browser.close"), 1_500);
399
- await settleWithin(session.detach(), 500);
588
+ await settleWithin(
589
+ Promise.resolve().then(() => session.detach()),
590
+ 500,
591
+ );
400
592
  }
401
593
  exited = await waitForExit(state.pid, 3_000);
402
- await settleWithin(browser.close(), 1_500);
594
+ await settleWithin(
595
+ Promise.resolve().then(() => browser.close()),
596
+ transportCloseTimeoutMs,
597
+ );
403
598
 
404
599
  if (!exited) {
405
600
  const childStillOwnsPid = child?.pid === state.pid
@@ -429,7 +624,10 @@ export async function launchAndConnectCdpChrome({
429
624
  },
430
625
  };
431
626
  } catch (error) {
432
- await browser?.close().catch(() => null);
627
+ await settleWithin(
628
+ Promise.resolve().then(() => browser?.close()),
629
+ transportCloseTimeoutMs,
630
+ );
433
631
  if (
434
632
  child
435
633
  && child.exitCode == null
@@ -545,7 +545,7 @@ export async function inspectCdpProfileState(profileDir, {
545
545
  // live lock-holder pid from a recycled one: a process that started after the
546
546
  // lock file was written cannot own it. Returns null when it cannot be
547
547
  // determined (unknown process, platform tool unavailable).
548
- async function processStartTimeMs(pid, { execFileImpl = execFileAsync } = {}) {
548
+ export async function processStartTimeMs(pid, { execFileImpl = execFileAsync } = {}) {
549
549
  if (process.platform === "win32") {
550
550
  const { stdout } = await execFileImpl(
551
551
  "powershell.exe",
@@ -555,7 +555,7 @@ async function processStartTimeMs(pid, { execFileImpl = execFileAsync } = {}) {
555
555
  "-Command",
556
556
  `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CreationDate`,
557
557
  ],
558
- { windowsHide: true },
558
+ { windowsHide: true, timeout: 1_500 },
559
559
  );
560
560
  const parsed = Date.parse(String(stdout).trim());
561
561
  return Number.isFinite(parsed) ? parsed : null;
@@ -563,10 +563,13 @@ async function processStartTimeMs(pid, { execFileImpl = execFileAsync } = {}) {
563
563
 
564
564
  const { stdout } = await execFileImpl(
565
565
  "ps",
566
- ["-p", String(pid), "-o", "etimes="],
566
+ ["-p", String(pid), "-o", "lstart="],
567
+ {
568
+ env: { ...process.env, LANG: "C", LC_ALL: "C" },
569
+ },
567
570
  );
568
- const seconds = Number.parseInt(String(stdout), 10);
569
- return Number.isFinite(seconds) ? Date.now() - seconds * 1_000 : null;
571
+ const parsed = Date.parse(String(stdout).trim());
572
+ return Number.isFinite(parsed) ? parsed : null;
570
573
  }
571
574
 
572
575
  export async function acquireCdpProfileLock(profileDir, {