pi-ui-extend 1.0.31 → 1.0.35

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.
@@ -15,8 +15,28 @@ const AUTH_SECRET_PLACEHOLDER = /^__PI_QA_SECRET_[1-9][0-9]*__$/;
15
15
  const SUBAGENT_AGENT_DIR_ENV = "PI_SUBAGENT_AGENT_DIR";
16
16
  const QA_WORKSPACE_RELATIVE = "browser-qa";
17
17
  const EVIDENCE_RELATIVE = "evidence";
18
- const FORM_VIDEO_STEP_DELAY_MS = 250;
18
+ const UI_READY_SETTLE_MS = 500;
19
+ const UI_READY_POLL_MS = 100;
20
+ const FORM_VIDEO_STEP_DELAY_MS = UI_READY_SETTLE_MS;
19
21
  const POPUP_VIDEO_SETTLE_MS = 250;
22
+ const CLICK_VIDEO_DELAY_MS = 120;
23
+ const DRAG_VIDEO_REPLAY_MS = 450;
24
+ const UI_READY_ACTIONS = new Set([
25
+ "goto", "reload", "click", "doubleClick", "hover", "fill", "press", "check", "uncheck", "selectOption",
26
+ "dragTo", "uploadFiles", "openPopup", "download", "wheel", "evaluate",
27
+ ]);
28
+ const VISIBLE_BUSY_SELECTOR = [
29
+ '[aria-busy="true"]:visible',
30
+ '[role="progressbar"]:visible',
31
+ '[data-loading="true"]:visible',
32
+ '[data-state="loading"]:visible',
33
+ '[data-testid*="loading" i]:visible',
34
+ '[data-testid*="spinner" i]:visible',
35
+ '[data-testid*="skeleton" i]:visible',
36
+ '.loading:visible',
37
+ '[class*="spinner" i]:visible',
38
+ '[class*="skeleton" i]:visible',
39
+ ].join(", ");
20
40
  const EXIT_AUTH_UPDATE_REQUIRED = 42;
21
41
  const EXIT_RUNNER_TIMEOUT = 124;
22
42
  const DEFAULT_RUNNER_TIMEOUT_MS = 90_000;
@@ -82,6 +102,10 @@ class QaStatusError extends Error {
82
102
  }
83
103
  }
84
104
 
105
+ // Declared before the main() dispatch below: main() runs synchronously up to
106
+ // its first await, so module state it touches must already be initialized.
107
+ let runnerMayHaveBrowserChildren = false;
108
+
85
109
  if (!isMainThread && workerData?.operation === "sanitize_trace") {
86
110
  if (process.env.BROWSER_QA_TEST_HANG_STAGE === "trace_sanitize") {
87
111
  setInterval(() => {}, 1000);
@@ -109,6 +133,11 @@ if (!isMainThread && workerData?.operation === "sanitize_trace") {
109
133
  });
110
134
 
111
135
  async function main() {
136
+ // End the synchronous first tick before running any command handler: the
137
+ // handler bodies run ahead of later module-level declarations otherwise,
138
+ // and a synchronous failure (e.g. playwright failing to load) would hit
139
+ // temporal-dead-zone errors in the catch blocks instead of clean reasons.
140
+ await null;
112
141
  const [command = "profiles", ...rawArgs] = process.argv.slice(2);
113
142
  const cwd = fs.realpathSync(process.cwd());
114
143
  if (command === "auth") {
@@ -184,6 +213,7 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
184
213
  progress("playwright_load_started");
185
214
  const playwright = loadPlaywright(cwd);
186
215
  progress("playwright_load_finished");
216
+ runnerMayHaveBrowserChildren = true;
187
217
  const browser = await runStage(progress, "browser_launch", deadline, () => playwright.chromium.launch({ headless: true }), profileId);
188
218
  let context;
189
219
  let page;
@@ -203,6 +233,13 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
203
233
  recordVideo: { dir: evidenceDir, size: viewport },
204
234
  viewport,
205
235
  }), profileId);
236
+ await runStage(
237
+ progress,
238
+ "interaction_visualizer_install",
239
+ deadline,
240
+ () => context.addInitScript(installInteractionVisualizer, { dragReplayMs: DRAG_VIDEO_REPLAY_MS }),
241
+ profileId,
242
+ );
206
243
  await runStage(progress, "origin_guard_install", deadline, () => installOriginGuard(context, allowedOrigins, profile.auth), profileId);
207
244
  await runStage(progress, "context_auth_apply", deadline, () => applyContextAuth(context, profile.auth, allowedOrigins, baseURL, profileId), profileId);
208
245
  page = await runStage(progress, "page_create", deadline, () => context.newPage(), profileId);
@@ -298,6 +335,273 @@ async function runQa({ cwd, agentDir, args, profileId, profile, deadline, progre
298
335
  writeStatus({ status: "QA_PASSED", profile: profileId, ...evidenceDetails });
299
336
  }
300
337
 
338
+ function installInteractionVisualizer({ dragReplayMs } = {}) {
339
+ const replayDuration = Number.isFinite(dragReplayMs) && dragReplayMs > 0 ? dragReplayMs : 1_000;
340
+ const state = {
341
+ host: undefined,
342
+ root: undefined,
343
+ svg: undefined,
344
+ cursor: undefined,
345
+ cursorTimer: undefined,
346
+ dragTimer: undefined,
347
+ pressed: false,
348
+ dragging: false,
349
+ points: [],
350
+ preparedPoints: undefined,
351
+ path: undefined,
352
+ replayAnimations: [],
353
+ replayTimer: undefined,
354
+ replayPath: undefined,
355
+ replayCursor: undefined,
356
+ };
357
+
358
+ const topLayerContainer = (event) => {
359
+ for (const candidate of event?.composedPath?.() ?? []) {
360
+ if (!(candidate instanceof Element)) continue;
361
+ if (candidate === document.fullscreenElement) return candidate;
362
+ if (typeof HTMLDialogElement !== "undefined" && candidate instanceof HTMLDialogElement && candidate.open) return candidate;
363
+ try {
364
+ if (candidate.matches(":popover-open")) return candidate;
365
+ } catch { /* unsupported selector */ }
366
+ }
367
+ return document.fullscreenElement ?? document.documentElement;
368
+ };
369
+ const ensureRoot = (container = document.documentElement) => {
370
+ if (state.host?.isConnected && state.root && state.svg && state.cursor) {
371
+ if (state.host.parentElement !== container) container.appendChild(state.host);
372
+ return state.svg;
373
+ }
374
+ const host = document.createElement("div");
375
+ host.setAttribute("data-pi-browser-qa-visualizer", "");
376
+ host.setAttribute("aria-hidden", "true");
377
+ const root = host.attachShadow({ mode: "closed" });
378
+ const stylesheet = new CSSStyleSheet();
379
+ stylesheet.replaceSync(`
380
+ :host { all: initial; position: fixed !important; inset: 0 !important; width: 100vw !important; height: 100vh !important; overflow: visible !important; pointer-events: none !important; z-index: 2147483647 !important; }
381
+ svg { position: fixed; inset: 0; width: 100vw; height: 100vh; overflow: visible; pointer-events: none; }
382
+ .cursor { fill: #0ea5e9; stroke: #fff; stroke-width: 3; filter: drop-shadow(0 0 2px #082f49) drop-shadow(0 2px 4px #000a); opacity: 0; transition: opacity 90ms linear; }
383
+ .cursor.visible { opacity: 1; }
384
+ .cursor.dragging { fill: #ff7a00; }
385
+ .cursor.replay { stroke-width: 4; filter: drop-shadow(0 0 3px #fff) drop-shadow(0 0 10px #ff7a00) drop-shadow(0 3px 5px #000c); transform-box: view-box; transform-origin: 0 0; }
386
+ .pulse { fill: none; stroke: #fbbf24; stroke-width: 4; filter: drop-shadow(0 0 2px #fff) drop-shadow(0 0 7px #f59e0b); transform-box: fill-box; transform-origin: center; }
387
+ .drop { fill: none; stroke: #22c55e; stroke-width: 6; filter: drop-shadow(0 0 3px #fff) drop-shadow(0 0 12px #16a34a); transform-box: fill-box; transform-origin: center; }
388
+ path { fill: none; stroke: #ff7a00; stroke-width: 8; stroke-linecap: round; stroke-linejoin: round; filter: drop-shadow(0 0 3px #fff) drop-shadow(0 0 8px #ff7a00) drop-shadow(0 3px 4px #000c); }
389
+ `);
390
+ root.adoptedStyleSheets = [stylesheet];
391
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
392
+ const cursor = document.createElementNS("http://www.w3.org/2000/svg", "circle");
393
+ cursor.setAttribute("r", "9");
394
+ cursor.setAttribute("class", "cursor");
395
+ svg.appendChild(cursor);
396
+ root.appendChild(svg);
397
+ container.appendChild(host);
398
+ state.host = host;
399
+ state.root = root;
400
+ state.svg = svg;
401
+ state.cursor = cursor;
402
+ return svg;
403
+ };
404
+
405
+ const dragReplayEvent = "pi-browser-qa-drag-replay";
406
+ const pointFor = (event) => ({ x: event.clientX, y: event.clientY });
407
+ const position = (element, point) => {
408
+ element.setAttribute("cx", String(point.x));
409
+ element.setAttribute("cy", String(point.y));
410
+ };
411
+ const showCursor = (point, dragging = state.dragging, container) => {
412
+ ensureRoot(container);
413
+ position(state.cursor, point);
414
+ state.cursor.classList.toggle("dragging", dragging);
415
+ state.cursor.classList.add("visible");
416
+ clearTimeout(state.cursorTimer);
417
+ state.cursorTimer = setTimeout(() => state.cursor?.classList.remove("visible"), 360);
418
+ };
419
+ const marker = (className, point, container) => {
420
+ const element = document.createElementNS("http://www.w3.org/2000/svg", "circle");
421
+ element.setAttribute("r", className === "drop" ? "17" : "9");
422
+ element.setAttribute("class", className);
423
+ position(element, point);
424
+ ensureRoot(container).appendChild(element);
425
+ element.animate(
426
+ className === "drop"
427
+ ? [{ opacity: 1, transform: "scale(.65)" }, { opacity: 0, transform: "scale(2.4)" }]
428
+ : [{ opacity: 1, transform: "scale(.55)" }, { opacity: 0, transform: "scale(2.8)" }],
429
+ { duration: 460, easing: "ease-out", fill: "forwards" },
430
+ );
431
+ setTimeout(() => element.remove(), 520);
432
+ };
433
+ const clearReplay = () => {
434
+ clearTimeout(state.replayTimer);
435
+ for (const animation of state.replayAnimations) animation.cancel();
436
+ state.replayCursor?.remove();
437
+ state.replayPath?.remove();
438
+ state.replayAnimations = [];
439
+ state.replayTimer = undefined;
440
+ state.replayCursor = undefined;
441
+ state.replayPath = undefined;
442
+ };
443
+ const replayDrag = (points, finishedPath, container) => {
444
+ if (!finishedPath || points.length === 0) return;
445
+ clearReplay();
446
+ const svg = ensureRoot(container);
447
+ const replayCursor = document.createElementNS("http://www.w3.org/2000/svg", "circle");
448
+ replayCursor.setAttribute("r", "14");
449
+ replayCursor.setAttribute("class", "cursor visible dragging replay");
450
+ svg.appendChild(replayCursor);
451
+ state.replayCursor = replayCursor;
452
+ state.replayPath = finishedPath;
453
+
454
+ const cumulative = [0];
455
+ for (let index = 1; index < points.length; index += 1) {
456
+ const previous = points[index - 1];
457
+ const current = points[index];
458
+ cumulative.push(cumulative[index - 1] + Math.hypot(current.x - previous.x, current.y - previous.y));
459
+ }
460
+ const totalLength = cumulative[cumulative.length - 1];
461
+ const firstPoint = points[0];
462
+ const finalPoint = points[points.length - 1];
463
+ const cursorKeyframes = points
464
+ .map((point, index) => ({
465
+ offset: totalLength > 0 ? cumulative[index] / totalLength : index / Math.max(points.length - 1, 1),
466
+ transform: `translate(${point.x}px, ${point.y}px)`,
467
+ }))
468
+ .filter((frame, index, frames) => index === 0 || index === frames.length - 1 || frame.offset > frames[index - 1].offset);
469
+ if (cursorKeyframes.length === 1) cursorKeyframes.push({ ...cursorKeyframes[0], offset: 1 });
470
+ cursorKeyframes[0].offset = 0;
471
+ cursorKeyframes[cursorKeyframes.length - 1].offset = 1;
472
+ const pathLength = Math.max(finishedPath.getTotalLength(), totalLength, 1);
473
+ finishedPath.setAttribute("stroke-dasharray", `${pathLength} ${pathLength}`);
474
+ finishedPath.setAttribute("stroke-dashoffset", String(pathLength));
475
+ replayCursor.setAttribute("cx", "0");
476
+ replayCursor.setAttribute("cy", "0");
477
+ replayCursor.setAttribute("transform", `translate(${firstPoint.x} ${firstPoint.y})`);
478
+ state.replayAnimations = [
479
+ replayCursor.animate(cursorKeyframes, { duration: replayDuration, easing: "linear", fill: "forwards" }),
480
+ finishedPath.animate(
481
+ [{ strokeDashoffset: `${pathLength}px` }, { strokeDashoffset: "0px" }],
482
+ { duration: replayDuration, easing: "linear", fill: "forwards" },
483
+ ),
484
+ ];
485
+ const finishReplay = () => {
486
+ state.replayAnimations = [];
487
+ state.replayTimer = undefined;
488
+ state.replayCursor = undefined;
489
+ state.replayPath = undefined;
490
+ replayCursor.remove();
491
+ marker("drop", finalPoint, container);
492
+ finishedPath.removeAttribute("stroke-dasharray");
493
+ finishedPath.removeAttribute("stroke-dashoffset");
494
+ finishedPath.animate(
495
+ [{ opacity: 1, offset: 0 }, { opacity: 1, offset: .55 }, { opacity: 0, offset: 1 }],
496
+ { duration: 460, easing: "ease-out", fill: "forwards" },
497
+ );
498
+ setTimeout(() => finishedPath.remove(), 520);
499
+ };
500
+ state.replayTimer = setTimeout(finishReplay, replayDuration);
501
+ };
502
+ const scheduleDragCleanup = () => {
503
+ clearTimeout(state.dragTimer);
504
+ state.dragTimer = setTimeout(cancelDrag, 5_000);
505
+ };
506
+ const updatePath = (point, container) => {
507
+ if (state.points.length === 0) state.points.push(point);
508
+ const previous = state.points[state.points.length - 1];
509
+ if (Math.hypot(point.x - previous.x, point.y - previous.y) >= 3) state.points.push(point);
510
+ if (state.points.length > 80) state.points.splice(1, state.points.length - 80);
511
+ if (!state.path) {
512
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
513
+ ensureRoot(container).appendChild(path);
514
+ state.path = path;
515
+ }
516
+ state.path.setAttribute("d", state.points.map((entry, index) => `${index === 0 ? "M" : "L"}${entry.x} ${entry.y}`).join(" "));
517
+ scheduleDragCleanup();
518
+ };
519
+ const beginDrag = (point, container) => {
520
+ if (!state.dragging) clearReplay();
521
+ state.dragging = true;
522
+ state.points = state.points.length > 0 ? state.points : [point];
523
+ updatePath(point, container);
524
+ showCursor(point, true, container);
525
+ };
526
+ const finishDrag = (point, container) => {
527
+ if (!state.dragging) {
528
+ state.points = [];
529
+ return;
530
+ }
531
+ updatePath(point, container);
532
+ const finishedPath = state.path;
533
+ const finishedPoints = state.preparedPoints ?? [...state.points];
534
+ state.preparedPoints = undefined;
535
+ if (finishedPath) {
536
+ finishedPath.setAttribute("d", finishedPoints.map((entry, index) => `${index === 0 ? "M" : "L"}${entry.x} ${entry.y}`).join(" "));
537
+ }
538
+ clearTimeout(state.dragTimer);
539
+ state.dragging = false;
540
+ state.points = [];
541
+ state.path = undefined;
542
+ state.cursor?.classList.remove("visible", "dragging");
543
+ replayDrag(finishedPoints, finishedPath, container);
544
+ };
545
+ function cancelDrag() {
546
+ clearTimeout(state.dragTimer);
547
+ state.path?.remove();
548
+ state.pressed = false;
549
+ state.dragging = false;
550
+ state.points = [];
551
+ state.path = undefined;
552
+ state.cursor?.classList.remove("dragging");
553
+ }
554
+
555
+ document.addEventListener(dragReplayEvent, (event) => {
556
+ const points = event.detail?.points;
557
+ if (!Array.isArray(points) || points.length < 2) return;
558
+ if (!points.every((point) => Number.isFinite(point?.x) && Number.isFinite(point?.y))) return;
559
+ state.preparedPoints = points.map(({ x, y }) => ({ x, y }));
560
+ }, true);
561
+ document.addEventListener("pointermove", (event) => {
562
+ const point = pointFor(event);
563
+ const container = topLayerContainer(event);
564
+ if (state.pressed && (event.buttons & 1) === 1) {
565
+ const start = state.points[0] ?? point;
566
+ if (state.dragging || Math.hypot(point.x - start.x, point.y - start.y) >= 6) beginDrag(point, container);
567
+ if (state.dragging) updatePath(point, container);
568
+ }
569
+ showCursor(point, state.dragging, container);
570
+ }, true);
571
+ document.addEventListener("pointerdown", (event) => {
572
+ if (event.button !== 0) return;
573
+ const point = pointFor(event);
574
+ const container = topLayerContainer(event);
575
+ state.pressed = true;
576
+ state.points = [point];
577
+ marker("pulse", point, container);
578
+ showCursor(point, false, container);
579
+ }, true);
580
+ document.addEventListener("pointerup", (event) => {
581
+ state.pressed = false;
582
+ finishDrag(pointFor(event), topLayerContainer(event));
583
+ }, true);
584
+ document.addEventListener("pointercancel", (event) => {
585
+ showCursor(pointFor(event), false, topLayerContainer(event));
586
+ cancelDrag();
587
+ }, true);
588
+ document.addEventListener("dragstart", (event) => beginDrag(pointFor(event), topLayerContainer(event)), true);
589
+ document.addEventListener("dragover", (event) => {
590
+ const point = pointFor(event);
591
+ const container = topLayerContainer(event);
592
+ if (!state.dragging) beginDrag(point, container);
593
+ updatePath(point, container);
594
+ showCursor(point, true, container);
595
+ }, true);
596
+ document.addEventListener("drop", (event) => finishDrag(pointFor(event), topLayerContainer(event)), true);
597
+ document.addEventListener("dragend", (event) => finishDrag(pointFor(event), topLayerContainer(event)), true);
598
+ document.addEventListener("lostpointercapture", cancelDrag, true);
599
+ document.addEventListener("visibilitychange", () => {
600
+ if (document.visibilityState === "hidden") cancelDrag();
601
+ }, true);
602
+ window.addEventListener("blur", cancelDrag, true);
603
+ }
604
+
301
605
  async function runAuthScaffold(cwd, args) {
302
606
  const profileId = args.profile;
303
607
  if (!isSafeName(profileId) || profileId.length > 128) throw new QaStatusError("QA_RUN_FAILED", "--profile must be a safe profile id no longer than 128 characters", 1);
@@ -315,6 +619,7 @@ async function runAuthScaffold(cwd, args) {
315
619
  try {
316
620
  progress("auth_scaffold_started", { timeoutMs: timeout });
317
621
  const playwright = loadPlaywright(cwd);
622
+ runnerMayHaveBrowserChildren = true;
318
623
  browser = await runStage(progress, "auth_scaffold_browser_launch", deadline, () => playwright.chromium.launch({ headless: true }), profileId);
319
624
  context = await runStage(progress, "auth_scaffold_context_create", deadline, () => browser.newContext({
320
625
  ...DEFAULT_ENVIRONMENT,
@@ -744,9 +1049,16 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
744
1049
  let popupCaptureArmed = false;
745
1050
  let capturedPopup;
746
1051
  let unexpectedPopupError;
1052
+ const readinessTrackers = new Map();
1053
+ const readinessFor = (candidate) => {
1054
+ if (!readinessTrackers.has(candidate)) readinessTrackers.set(candidate, createPageReadinessTracker(candidate));
1055
+ return readinessTrackers.get(candidate);
1056
+ };
1057
+ readinessFor(page);
747
1058
  const onContextPage = (candidate) => {
748
1059
  if (candidate === page) return;
749
1060
  if (popupCaptureArmed && !capturedPopup) {
1061
+ readinessFor(candidate);
750
1062
  capturedPopup = candidate;
751
1063
  return;
752
1064
  }
@@ -779,8 +1091,8 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
779
1091
  if (scope !== ownerPage) throw flowError(profileId, index, "reload is not supported for frame targets");
780
1092
  await ownerPage.reload({ waitUntil: validWaitUntil(step.waitUntil) });
781
1093
  break;
782
- case "click": await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).click() }); break;
783
- case "doubleClick": await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).dblclick() }); break;
1094
+ case "click": await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).click({ delay: CLICK_VIDEO_DELAY_MS }) }); break;
1095
+ case "doubleClick": await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).dblclick({ delay: CLICK_VIDEO_DELAY_MS }) }); break;
784
1096
  case "hover": await requireLocator(locator, profileId, index).hover(); break;
785
1097
  case "fill":
786
1098
  if (typeof step.value !== "string") throw flowError(profileId, index, "fill requires a string value");
@@ -797,11 +1109,26 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
797
1109
  await executeExpectedInteraction({ ownerPage, step, allowedOrigins, timeout, profileId, index, action: () => requireLocator(locator, profileId, index).selectOption(step.value) });
798
1110
  break;
799
1111
  case "dragTo": {
1112
+ const source = requireLocator(locator, profileId, index);
800
1113
  const destination = resolveLocator(scope, step.dropTarget, profileId, index);
801
1114
  const options = {};
802
1115
  if (step.sourcePosition !== undefined) options.sourcePosition = resolvePoint(step.sourcePosition, profileId, index, "sourcePosition");
803
1116
  if (step.dropPosition !== undefined) options.targetPosition = resolvePoint(step.dropPosition, profileId, index, "dropPosition");
804
- await requireLocator(locator, profileId, index).dragTo(destination, options);
1117
+ const replayPoints = await Promise.all([
1118
+ source.evaluate((element, position) => {
1119
+ const rect = element.getBoundingClientRect();
1120
+ return { x: (rect.left ?? rect.x) + (position?.x ?? rect.width / 2), y: (rect.top ?? rect.y) + (position?.y ?? rect.height / 2) };
1121
+ }, options.sourcePosition),
1122
+ destination.evaluate((element, position) => {
1123
+ const rect = element.getBoundingClientRect();
1124
+ return { x: (rect.left ?? rect.x) + (position?.x ?? rect.width / 2), y: (rect.top ?? rect.y) + (position?.y ?? rect.height / 2) };
1125
+ }, options.targetPosition),
1126
+ ]);
1127
+ await source.evaluate((element, points) => {
1128
+ element.dispatchEvent(new CustomEvent("pi-browser-qa-drag-replay", { bubbles: true, composed: true, detail: { points } }));
1129
+ }, replayPoints);
1130
+ await source.dragTo(destination, options);
1131
+ await ownerPage.waitForTimeout(DRAG_VIDEO_REPLAY_MS + UI_READY_POLL_MS);
805
1132
  break;
806
1133
  }
807
1134
  case "uploadFiles": {
@@ -819,12 +1146,18 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
819
1146
  let popup;
820
1147
  let popupVideo;
821
1148
  try {
822
- [, popup] = await Promise.all([requireLocator(locator, profileId, index).click(), popupPromise]);
1149
+ [, popup] = await Promise.all([requireLocator(locator, profileId, index).click({ delay: CLICK_VIDEO_DELAY_MS }), popupPromise]);
823
1150
  popup.setDefaultTimeout(timeout);
824
1151
  popup.setDefaultNavigationTimeout(timeout);
825
1152
  popupVideo = { name: `video-popup-${step.name}.webm`, video: popup.video(), discard: false };
826
1153
  videos.push(popupVideo);
827
1154
  await popup.waitForLoadState("domcontentloaded", { timeout });
1155
+ await waitForUiReady({
1156
+ scope: popup,
1157
+ tracker: readinessFor(popup),
1158
+ timeout,
1159
+ failure: () => flowError(profileId, index, "popup did not finish loading"),
1160
+ });
828
1161
  const origin = assertSameOriginPage(popup, ownerPage, allowedOrigins, profileId, index, "popup");
829
1162
  await popup.bringToFront();
830
1163
  // A static popup may not repaint after Playwright starts its screencast, leaving the video blank.
@@ -971,11 +1304,68 @@ async function executeFlow({ context, page, baseURL, evidenceDir, flow, allowedO
971
1304
  }
972
1305
  default: throw flowError(profileId, index, `unsupported action: ${step.action}`);
973
1306
  }
1307
+ if (UI_READY_ACTIONS.has(step.action)) {
1308
+ await waitForUiReady({
1309
+ scope,
1310
+ tracker: readinessFor(ownerPage),
1311
+ timeout,
1312
+ failure: () => flowError(profileId, index, "page did not finish loading"),
1313
+ });
1314
+ }
974
1315
  if (unexpectedPopupError) throw unexpectedPopupError;
975
1316
  await assertBrowserDoesNotExposeSecrets(context, page, secrets, profileId);
976
1317
  }
977
1318
  } finally {
978
1319
  context.off("page", onContextPage);
1320
+ for (const tracker of readinessTrackers.values()) tracker.dispose();
1321
+ }
1322
+ }
1323
+
1324
+ function createPageReadinessTracker(page) {
1325
+ const pendingRequests = new Set();
1326
+ const onRequest = (request) => pendingRequests.add(request);
1327
+ const onRequestDone = (request) => pendingRequests.delete(request);
1328
+ page.on("request", onRequest);
1329
+ page.on("requestfinished", onRequestDone);
1330
+ page.on("requestfailed", onRequestDone);
1331
+ return {
1332
+ page,
1333
+ pendingRequests,
1334
+ dispose() {
1335
+ page.off("request", onRequest);
1336
+ page.off("requestfinished", onRequestDone);
1337
+ page.off("requestfailed", onRequestDone);
1338
+ },
1339
+ };
1340
+ }
1341
+
1342
+ async function waitForUiReady({ scope, tracker, timeout, failure }) {
1343
+ const deadline = Date.now() + timeout;
1344
+ try {
1345
+ await scope.waitForLoadState("domcontentloaded", { timeout });
1346
+ } catch {
1347
+ throw failure();
1348
+ }
1349
+ while (true) {
1350
+ const remaining = deadline - Date.now();
1351
+ if (remaining <= 0) throw failure();
1352
+ const hasBusyIndicator = await hasVisibleBusyIndicator(scope);
1353
+ if (tracker.pendingRequests.size === 0 && !hasBusyIndicator) {
1354
+ await tracker.page.waitForTimeout(Math.min(UI_READY_SETTLE_MS, remaining));
1355
+ const stillBusy = await hasVisibleBusyIndicator(scope);
1356
+ if (tracker.pendingRequests.size === 0 && !stillBusy) return;
1357
+ continue;
1358
+ }
1359
+ await tracker.page.waitForTimeout(Math.min(UI_READY_POLL_MS, remaining));
1360
+ }
1361
+ }
1362
+
1363
+ async function hasVisibleBusyIndicator(scope) {
1364
+ try {
1365
+ return await scope.locator(VISIBLE_BUSY_SELECTOR).count() > 0;
1366
+ } catch {
1367
+ // A navigation can replace the execution context between readiness polls.
1368
+ return true;
979
1369
  }
980
1370
  }
981
1371
 
@@ -1170,7 +1560,7 @@ async function executeDownload({ ownerPage, locator, step, evidenceDir, allowedO
1170
1560
  const retained = step.retain === true ? path.join(evidenceDir, `download-${step.name}.bin`) : undefined;
1171
1561
  if (retained && fs.existsSync(retained)) throw flowError(profileId, index, `retained download name is duplicated: ${step.name}`);
1172
1562
  const downloadPromise = ownerPage.waitForEvent("download", { timeout });
1173
- const [, download] = await Promise.all([locator.click(), downloadPromise]);
1563
+ const [, download] = await Promise.all([locator.click({ delay: CLICK_VIDEO_DELAY_MS }), downloadPromise]);
1174
1564
  try {
1175
1565
  const url = new URL(download.url());
1176
1566
  if (!allowedOrigins.includes(url.origin)) throw flowError(profileId, index, "download is outside allowedOrigins");
@@ -1418,12 +1808,18 @@ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
1418
1808
  if (!Array.isArray(auth.fields) || auth.fields.length === 0 || typeof auth.submitSelector !== "string") {
1419
1809
  throw authError(profileId, "form fields or submitSelector are missing");
1420
1810
  }
1811
+ const readinessTracker = createPageReadinessTracker(page);
1421
1812
  try {
1422
1813
  const timeout = Math.min(finitePositive(auth.timeoutMs) ?? 15_000, 60_000);
1423
1814
  page.setDefaultTimeout(timeout);
1424
1815
  page.setDefaultNavigationTimeout(timeout);
1425
1816
  await page.goto(auth.loginUrl, { timeout });
1426
- await page.waitForTimeout(FORM_VIDEO_STEP_DELAY_MS);
1817
+ await waitForUiReady({
1818
+ scope: page,
1819
+ tracker: readinessTracker,
1820
+ timeout,
1821
+ failure: () => authError(profileId, "form page did not finish loading"),
1822
+ });
1427
1823
  for (const field of auth.fields) {
1428
1824
  if (!isObject(field) || typeof field.selector !== "string" || typeof field.value !== "string") {
1429
1825
  throw authError(profileId, "form fields must contain selector/value strings");
@@ -1431,7 +1827,7 @@ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
1431
1827
  await page.locator(field.selector).fill(field.value);
1432
1828
  await page.waitForTimeout(FORM_VIDEO_STEP_DELAY_MS);
1433
1829
  }
1434
- await page.locator(auth.submitSelector).click();
1830
+ await page.locator(auth.submitSelector).click({ delay: CLICK_VIDEO_DELAY_MS });
1435
1831
  if (isObject(auth.success) && typeof auth.success.url === "string") await page.waitForURL(auth.success.url, { timeout });
1436
1832
  if (isObject(auth.success) && typeof auth.success.selector === "string") {
1437
1833
  await page.locator(auth.success.selector).waitFor({ timeout, state: validLocatorState(auth.success.state) });
@@ -1439,9 +1835,17 @@ async function applyFormAuth(page, auth, allowedOrigins, profileId) {
1439
1835
  if (!isObject(auth.success) || (typeof auth.success.url !== "string" && typeof auth.success.selector !== "string")) {
1440
1836
  throw authError(profileId, "form success.url or success.selector is required");
1441
1837
  }
1838
+ await waitForUiReady({
1839
+ scope: page,
1840
+ tracker: readinessTracker,
1841
+ timeout,
1842
+ failure: () => authError(profileId, "authenticated page did not finish loading"),
1843
+ });
1442
1844
  } catch (error) {
1443
1845
  if (error instanceof QaStatusError) throw error;
1444
1846
  throw authError(profileId, "form login was rejected or did not reach the configured success condition");
1847
+ } finally {
1848
+ readinessTracker.dispose();
1445
1849
  }
1446
1850
  }
1447
1851
 
@@ -1536,15 +1940,35 @@ function loadPlaywright(cwd) {
1536
1940
  if (cliPath) requireCandidates.push(createRequire(findPackageJson(fs.realpathSync(cliPath))));
1537
1941
  for (const require of requireCandidates) {
1538
1942
  for (const name of ["playwright", "@playwright/test"]) {
1539
- try {
1540
- const module = require(name);
1541
- if (module.chromium) return module;
1542
- } catch { /* try next resolution root */ }
1943
+ for (let attempt = 0; ; attempt++) {
1944
+ try {
1945
+ const module = require(name);
1946
+ if (module.chromium) return module;
1947
+ break;
1948
+ } catch (error) {
1949
+ // Windows AV scanners can briefly lock a freshly installed
1950
+ // playwright entrypoint; retry the transient case instead of
1951
+ // falling through to "playwright unavailable".
1952
+ if (attempt < 2 && isTransientFsError(error)) {
1953
+ sleepSync(100);
1954
+ continue;
1955
+ }
1956
+ break;
1957
+ }
1958
+ }
1543
1959
  }
1544
1960
  }
1545
1961
  throw new Error("Playwright is unavailable; install project playwright or @playwright/cli");
1546
1962
  }
1547
1963
 
1964
+ function isTransientFsError(error) {
1965
+ return typeof error?.code === "string" && ["EBUSY", "EPERM", "EACCES"].includes(error.code);
1966
+ }
1967
+
1968
+ function sleepSync(ms) {
1969
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
1970
+ }
1971
+
1548
1972
  function findExecutable(name) {
1549
1973
  const names = process.platform === "win32" ? [`${name}.cmd`, `${name}.exe`, name] : [name];
1550
1974
  for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
@@ -1627,6 +2051,10 @@ function createRunnerProgress(workspaceDir) {
1627
2051
  }
1628
2052
 
1629
2053
  function terminateRunnerDescendants() {
2054
+ // Only a browser launch can leave detached descendants (Playwright spawns
2055
+ // the browser as a child of this process), so config and validation
2056
+ // failures can skip the process-tree walk entirely.
2057
+ if (!runnerMayHaveBrowserChildren) return;
1630
2058
  if (process.platform === "win32") {
1631
2059
  // Detached children are not covered by a POSIX process-group kill. Walk
1632
2060
  // the Windows process tree explicitly so a timed-out Playwright/browser
@@ -1652,7 +2080,10 @@ foreach ($candidatePid in $descendants) {
1652
2080
  `;
1653
2081
  spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
1654
2082
  stdio: "ignore",
1655
- timeout: 2_000,
2083
+ // The first powershell.exe launch on a machine can stall for seconds
2084
+ // in AV scanning; give the tree walk room to complete so detached
2085
+ // browser children cannot outlive the runner and lock the workspace.
2086
+ timeout: 5_000,
1656
2087
  windowsHide: true,
1657
2088
  });
1658
2089
  return;
@@ -185,7 +185,7 @@ export default function codingDiscipline(pi: ExtensionAPI) {
185
185
  if (!Array.isArray(activeTools)) return;
186
186
 
187
187
  const lookupEnabled = Boolean(lookupModelFromConfig(cwd));
188
- const shouldExposeLookup = lookupEnabled && isGlmModel(modelRef);
188
+ const shouldExposeLookup = lookupEnabled && isBlindGlmModel(modelRef);
189
189
  const hasLookup = activeTools.includes(LOOKUP_TOOL_NAME);
190
190
 
191
191
  if (shouldExposeLookup === hasLookup) return;
@@ -219,7 +219,7 @@ export default function codingDiscipline(pi: ExtensionAPI) {
219
219
  if (!isGlmModel(modelRef)) return undefined;
220
220
  const cwd = contextCwd(ctx);
221
221
  const injected = injectCodingDisciplineIntoPayload(event.payload, {
222
- lookupEnabled: Boolean(lookupModelFromConfig(cwd)),
222
+ lookupEnabled: Boolean(lookupModelFromConfig(cwd)) && isBlindGlmModel(modelRef),
223
223
  strictness: codingDisciplineStrictnessFromConfig(cwd),
224
224
  });
225
225
  if (process.env.PI_DEBUG_PROMPT === "1") {
@@ -350,6 +350,16 @@ export function isGlmModel(modelRef: string | undefined): boolean {
350
350
  return /(?:^|[/:_.-])glm(?:$|[/:_.-]|\d)/i.test(modelRef);
351
351
  }
352
352
 
353
+ export function isVisionCapableGlmModel(modelRef: string | undefined): boolean {
354
+ if (!modelRef) return false;
355
+ const normalized = modelRef.trim().toLowerCase();
356
+ return normalized === "zai/glm-5.3-flash" || normalized === "glm-5.3-flash";
357
+ }
358
+
359
+ function isBlindGlmModel(modelRef: string | undefined): boolean {
360
+ return isGlmModel(modelRef) && !isVisionCapableGlmModel(modelRef);
361
+ }
362
+
353
363
  export function injectCodingDisciplineIntoPayload(payload: unknown, options: DisciplinePromptOptions = {}): unknown {
354
364
  if (!isRecord(payload)) return payload;
355
365