pi-ui-extend 1.0.32 → 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.
- package/dist/app/app.js +0 -1
- package/dist/app/commands/command-host.d.ts +1 -2
- package/dist/app/commands/command-model-actions.js +5 -5
- package/dist/app/constants.d.ts +0 -1
- package/dist/app/constants.js +0 -34
- package/dist/app/popup/menu-items-controller.d.ts +1 -3
- package/dist/app/popup/menu-items-controller.js +10 -32
- package/dist/app/rendering/popup-menu-renderer.js +1 -1
- package/dist/app/runtime.js +1 -3
- package/dist/app/screen/file-link-opener.js +15 -49
- package/external/pi-tools-suite/README.md +15 -6
- package/external/pi-tools-suite/docs/browser-qa-subagent.md +24 -6
- package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +13 -9
- package/external/pi-tools-suite/src/async-subagents/core/config.ts +2 -2
- package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +23 -4
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/SKILL.md +40 -4
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-design.md +19 -0
- package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/scripts/browser-qa-runner.mjs +444 -13
- package/external/pi-tools-suite/src/coding-discipline/index.ts +12 -2
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +6 -6
- package/external/pi-tools-suite/src/tool-descriptions.ts +2 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
|