humanish 0.74.0 → 0.76.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.
- package/README.md +28 -11
- package/dist/chrome-cdp-probe.d.ts +93 -0
- package/dist/chrome-cdp-probe.js +395 -0
- package/dist/chrome-cdp-probe.js.map +1 -0
- package/dist/cua-actor-lab.d.ts +29 -10
- package/dist/cua-actor-lab.js +251 -154
- package/dist/cua-actor-lab.js.map +1 -1
- package/dist/feedback.d.ts +20 -5
- package/dist/feedback.js +47 -16
- package/dist/feedback.js.map +1 -1
- package/dist/lab-config.d.ts +21 -0
- package/dist/lab-config.js +30 -0
- package/dist/lab-config.js.map +1 -1
- package/dist/pricing.js +15 -6
- package/dist/pricing.js.map +1 -1
- package/dist/program.js +26 -8
- package/dist/program.js.map +1 -1
- package/dist/run.d.ts +26 -0
- package/dist/run.js.map +1 -1
- package/dist/telemetry.d.ts +10 -0
- package/dist/telemetry.js +12 -0
- package/dist/telemetry.js.map +1 -1
- package/docs/contracts/schemas.md +4 -2
- package/docs/goals/current.md +25 -1
- package/docs/ramp/README.md +1 -1
- package/package.json +1 -1
package/dist/cua-actor-lab.js
CHANGED
|
@@ -46,6 +46,7 @@ import { appendSandboxReceipt } from "./sandbox-receipts.js";
|
|
|
46
46
|
import { assertScreenshotEvidence } from "./image-evidence.js";
|
|
47
47
|
import { buildObserverData } from "./observer-data.js";
|
|
48
48
|
import { corepackCommandFor, needsNodeRuntime, nodeBootstrapCommand } from "./subject-runtime.js";
|
|
49
|
+
import { chromeCdpProbeCommand, parseChromeCdpProbeOutput } from "./chrome-cdp-probe.js";
|
|
49
50
|
import { personaToDirectives, renderPersonaPromptSection } from "./persona.js";
|
|
50
51
|
import { labPersonaIds, resolveCommittedPersonas } from "./persona-resolve.js";
|
|
51
52
|
import { renderTaskPrompt } from "./tasks.js";
|
|
@@ -505,6 +506,37 @@ function isoNow(now) {
|
|
|
505
506
|
return new Date(now()).toISOString();
|
|
506
507
|
}
|
|
507
508
|
/** Emit a phase-started event (no ok/durationMs: those belong to the matching completed event). */
|
|
509
|
+
/**
|
|
510
|
+
* Run a provisioning step and, when it fails with an EXIT CODE, run it once more (#602). A cold
|
|
511
|
+
* install of 0.74.0 lost its whole first live study to one transient TLS error inside the
|
|
512
|
+
* sandbox's `npm install`; the parallel install twenty seconds later passed, as had the ten
|
|
513
|
+
* before it. One retry clears that class. A TIMEOUT is not retried: its budget is already spent,
|
|
514
|
+
* and a second wait would double it. The retry runs under its own step name so both logs stay.
|
|
515
|
+
*/
|
|
516
|
+
async function runProvisioningStepWithOneRetry(desktop, args) {
|
|
517
|
+
const first = await runDetachedStep(desktop, {
|
|
518
|
+
name: args.name,
|
|
519
|
+
command: args.command,
|
|
520
|
+
cwd: args.cwd,
|
|
521
|
+
timeoutMs: args.timeoutMs,
|
|
522
|
+
requestTimeoutMs: args.requestTimeoutMs,
|
|
523
|
+
...args.timers
|
|
524
|
+
});
|
|
525
|
+
if (first.ok || first.timedOut)
|
|
526
|
+
return { ...first, attempts: 1 };
|
|
527
|
+
const retryStartedAt = args.now();
|
|
528
|
+
emitPhaseStarted(args.onPhase, args.now, args.retryPhase, `${args.retryMessage} (first attempt exited ${first.exitCode ?? "null"}; retrying once)`);
|
|
529
|
+
const second = await runDetachedStep(desktop, {
|
|
530
|
+
name: `${args.name}-retry`,
|
|
531
|
+
command: args.command,
|
|
532
|
+
cwd: args.cwd,
|
|
533
|
+
timeoutMs: args.timeoutMs,
|
|
534
|
+
requestTimeoutMs: args.requestTimeoutMs,
|
|
535
|
+
...args.timers
|
|
536
|
+
});
|
|
537
|
+
emitPhaseCompleted(args.onPhase, args.now, retryStartedAt, args.retryPhase, second.ok, second.ok ? `${args.retryMessage}: succeeded on the second attempt` : `${args.retryMessage}: failed twice`);
|
|
538
|
+
return { ...second, attempts: 2, ...(first.exitCode === undefined ? {} : { firstExitCode: first.exitCode }) };
|
|
539
|
+
}
|
|
508
540
|
function emitPhaseStarted(onPhase, now, phase, message) {
|
|
509
541
|
onPhase?.({ at: isoNow(now), type: `cua-lab.subject.${phase}.started`, message });
|
|
510
542
|
}
|
|
@@ -704,10 +736,13 @@ async function fillDesktopBrowserWindow(desktop, windowId, resolution, requestTi
|
|
|
704
736
|
})
|
|
705
737
|
.catch(() => undefined);
|
|
706
738
|
}
|
|
707
|
-
async function openDesktopBrowserTarget(desktop, targetUrl, requestTimeoutMs, browserPreference
|
|
739
|
+
async function openDesktopBrowserTarget(desktop, targetUrl, requestTimeoutMs, browserPreference,
|
|
740
|
+
/** Launch-time flags that make mobile fidelity (#221) hold across every tab: the user agent and
|
|
741
|
+
* touch events are browser-wide here, where the CDP holder covers only the launch page. */
|
|
742
|
+
extraChromiumFlags = []) {
|
|
708
743
|
const requestedBrowser = browserPreference ?? "default";
|
|
709
744
|
if (isHttpUrl(targetUrl)) {
|
|
710
|
-
const chromiumFlags = CHROMIUM_EVIDENCE_HYGIENE_FLAGS.map(shellSingleQuote).join(" ");
|
|
745
|
+
const chromiumFlags = [...CHROMIUM_EVIDENCE_HYGIENE_FLAGS, ...extraChromiumFlags].map(shellSingleQuote).join(" ");
|
|
711
746
|
const browserLaunchCommand = [
|
|
712
747
|
"set -euo pipefail",
|
|
713
748
|
`target_url=${shellSingleQuote(targetUrl)}`,
|
|
@@ -839,164 +874,145 @@ export function desktopBrowserFamily(value) {
|
|
|
839
874
|
return "unknown";
|
|
840
875
|
}
|
|
841
876
|
/**
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
"if (cdpPort === undefined && cdpProfileDir) {",
|
|
853
|
-
" try {",
|
|
854
|
-
" const { readFileSync } = await import('node:fs');",
|
|
855
|
-
" const marker = readFileSync(cdpProfileDir + '/DevToolsActivePort', 'utf8');",
|
|
856
|
-
" const parsed = Number.parseInt(String(marker.split('\\n')[0] ?? ''), 10);",
|
|
857
|
-
" if (Number.isInteger(parsed) && parsed > 0) cdpPort = parsed;",
|
|
858
|
-
" } catch {}",
|
|
859
|
-
"}",
|
|
860
|
-
"if (cdpPort === undefined) cdpPort = 9222;"
|
|
861
|
-
];
|
|
862
|
-
}
|
|
863
|
-
/** Shared CDP page-selection preamble: pinned target id first, then this lane's target URL,
|
|
864
|
-
* then a single-page fallback; never an arbitrary page from a multi-page endpoint. */
|
|
865
|
-
function chromeCdpPageSelectionScript(endpoint, targetId,
|
|
866
|
-
/**
|
|
867
|
-
* "pinned" (default): the launch-time target, for measurements about the ORIGINAL window
|
|
868
|
-
* (geometry). "active": the tab the participant is driving NOW — Chrome's /json lists page
|
|
869
|
-
* targets most-recently-focused first. The state observer must follow the participant: a
|
|
870
|
-
* verification link that opens in a NEW tab left the pinned observer reading the old tab
|
|
871
|
-
* forever, so the observed URL never changed again and stopWhen/task criteria went blind
|
|
872
|
-
* (a live run's funnel read reach-dashboard 0/2 under a screenshot OF the dashboard).
|
|
877
|
+
* The URL / title / page-text / scroll observer behind stopWhen and task criteria. One probe per
|
|
878
|
+
* observation, run on the sandbox's python3 (see chrome-cdp-probe.ts for why not node: #514).
|
|
879
|
+
*
|
|
880
|
+
* "active": follow the participant to whatever tab they are driving now — never pin the state
|
|
881
|
+
* observer to the launch tab (a verification link that opened in a NEW tab left a pinned observer
|
|
882
|
+
* reading the old tab forever).
|
|
883
|
+
*
|
|
884
|
+
* `onUnavailable` fires ONCE, on the first probe that could not read the page, with the reason.
|
|
885
|
+
* The observer still degrades to `{}` for the loop; the callback is how a lane says out loud that
|
|
886
|
+
* url/text criteria are not being measured, instead of letting the funnel report 0/N (#514).
|
|
873
887
|
*/
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
? "const page = httpPages[0] || (expectedTargetId ? httpPages.find((entry) => entry.id === expectedTargetId) : undefined);"
|
|
884
|
-
: "const page = expectedTargetId ? httpPages.find((entry) => entry.id === expectedTargetId) : (httpPages.find((entry) => normalizeUrl(entry.url) === normalizeUrl(expectedTargetUrl)) || (httpPages.length === 1 ? httpPages[0] : undefined));"
|
|
885
|
-
];
|
|
886
|
-
}
|
|
887
|
-
export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoint, targetId) {
|
|
888
|
+
export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoint, targetId, onUnavailable) {
|
|
889
|
+
let reported = false;
|
|
890
|
+
const unavailable = (reason) => {
|
|
891
|
+
if (!reported) {
|
|
892
|
+
reported = true;
|
|
893
|
+
onUnavailable?.(reason);
|
|
894
|
+
}
|
|
895
|
+
return {};
|
|
896
|
+
};
|
|
888
897
|
return async () => {
|
|
889
|
-
const
|
|
890
|
-
// "active": follow the participant to whatever tab they are driving now — never pin the
|
|
891
|
-
// state observer to the launch tab (see chromeCdpPageSelectionScript).
|
|
892
|
-
...chromeCdpPageSelectionScript(endpoint, targetId, "active"),
|
|
893
|
-
"if (!page) { console.log('{}'); process.exit(0); }",
|
|
894
|
-
"let text = '';",
|
|
895
|
-
"let scrollY = undefined;",
|
|
896
|
-
"let url = String(page.url || '');",
|
|
897
|
-
"let title = String(page.title || '');",
|
|
898
|
-
"if (typeof WebSocket === 'function' && page.webSocketDebuggerUrl) {",
|
|
899
|
-
" const ws = new WebSocket(page.webSocketDebuggerUrl);",
|
|
900
|
-
" const result = await new Promise((resolve) => {",
|
|
901
|
-
" const timer = setTimeout(() => resolve(undefined), 1500);",
|
|
902
|
-
" ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { returnByValue: true, expression: '({ url: location.href, title: document.title, text: (document.body && document.body.innerText || \"\").slice(0, 20000), scrollY: (window.scrollY || 0) })' } }));",
|
|
903
|
-
" ws.onmessage = (event) => {",
|
|
904
|
-
" try {",
|
|
905
|
-
" const payload = JSON.parse(String(event.data));",
|
|
906
|
-
" if (payload.id !== 1) return;",
|
|
907
|
-
" clearTimeout(timer);",
|
|
908
|
-
" resolve(payload.result && payload.result.result && payload.result.result.value);",
|
|
909
|
-
" } catch { clearTimeout(timer); resolve(undefined); }",
|
|
910
|
-
" };",
|
|
911
|
-
" ws.onerror = () => { clearTimeout(timer); resolve(undefined); };",
|
|
912
|
-
" }).finally(() => { try { ws.close(); } catch {} });",
|
|
913
|
-
" if (result && typeof result === 'object') {",
|
|
914
|
-
" url = typeof result.url === 'string' ? result.url : url;",
|
|
915
|
-
" title = typeof result.title === 'string' ? result.title : title;",
|
|
916
|
-
" text = typeof result.text === 'string' ? result.text : '';",
|
|
917
|
-
" scrollY = typeof result.scrollY === 'number' ? result.scrollY : undefined;",
|
|
918
|
-
" }",
|
|
919
|
-
"}",
|
|
920
|
-
"console.log(JSON.stringify({ url, title, text, scrollY }));"
|
|
921
|
-
].join("\n");
|
|
922
|
-
const result = await desktop.commands.run(`node --input-type=module -e ${shellSingleQuote(script)}`, {
|
|
923
|
-
requestTimeoutMs,
|
|
924
|
-
timeoutMs: 5_000
|
|
925
|
-
});
|
|
898
|
+
const result = await desktop.commands.run(chromeCdpProbeCommand({ ...endpoint, ...(targetId === undefined ? {} : { targetId }), prefer: "active", mode: "state" }), { requestTimeoutMs, timeoutMs: 5_000 });
|
|
926
899
|
if (result.exitCode !== undefined && result.exitCode !== 0) {
|
|
927
|
-
return {};
|
|
928
|
-
}
|
|
929
|
-
try {
|
|
930
|
-
const parsed = JSON.parse((result.stdout ?? "{}").trim() || "{}");
|
|
931
|
-
if (!parsed || typeof parsed !== "object") {
|
|
932
|
-
return {};
|
|
933
|
-
}
|
|
934
|
-
const record = parsed;
|
|
935
|
-
return {
|
|
936
|
-
...(typeof record.url === "string" && record.url.length > 0 ? { url: record.url } : {}),
|
|
937
|
-
...(typeof record.title === "string" && record.title.length > 0 ? { title: record.title } : {}),
|
|
938
|
-
...(typeof record.text === "string" && record.text.length > 0 ? { text: record.text } : {}),
|
|
939
|
-
...(typeof record.scrollY === "number" && Number.isFinite(record.scrollY) ? { scrollY: record.scrollY } : {})
|
|
940
|
-
};
|
|
941
|
-
}
|
|
942
|
-
catch {
|
|
943
|
-
return {};
|
|
900
|
+
return unavailable(`probe exited ${result.exitCode}: ${tailOf(result.stderr ?? result.stdout ?? "")}`);
|
|
944
901
|
}
|
|
902
|
+
const parsed = parseChromeCdpProbeOutput(result.stdout);
|
|
903
|
+
if (parsed.unavailable !== undefined)
|
|
904
|
+
return unavailable(parsed.unavailable);
|
|
905
|
+
return {
|
|
906
|
+
...(parsed.url === undefined ? {} : { url: parsed.url }),
|
|
907
|
+
...(parsed.title === undefined ? {} : { title: parsed.title }),
|
|
908
|
+
...(parsed.text === undefined ? {} : { text: parsed.text }),
|
|
909
|
+
...(parsed.scrollY === undefined ? {} : { scrollY: parsed.scrollY })
|
|
910
|
+
};
|
|
945
911
|
};
|
|
946
912
|
}
|
|
947
913
|
/**
|
|
948
914
|
* Read the running browser's actual outer-window bounds and CSS layout viewport through the
|
|
949
915
|
* already-enabled local Chrome DevTools endpoint. The returned values come from `window.*` in
|
|
950
916
|
* the target page; requested E2B resolution is deliberately not an input to this function.
|
|
917
|
+
* `undefined` carries the reason the measurement is missing via `onUnavailable`, so the geometry
|
|
918
|
+
* warning can name the cause (a dead CDP endpoint, no python3) instead of only the symptom.
|
|
951
919
|
*/
|
|
952
|
-
export function makeChromeDesktopGeometryObserver(desktop, requestTimeoutMs, endpoint, targetId) {
|
|
920
|
+
export function makeChromeDesktopGeometryObserver(desktop, requestTimeoutMs, endpoint, targetId, onUnavailable) {
|
|
953
921
|
return async () => {
|
|
954
|
-
const
|
|
955
|
-
...chromeCdpPageSelectionScript(endpoint, targetId),
|
|
956
|
-
"if (!page || typeof WebSocket !== 'function' || !page.webSocketDebuggerUrl) { console.log('{}'); process.exit(0); }",
|
|
957
|
-
"const ws = new WebSocket(page.webSocketDebuggerUrl);",
|
|
958
|
-
"const result = await new Promise((resolve) => {",
|
|
959
|
-
" const timer = setTimeout(() => resolve(undefined), 1500);",
|
|
960
|
-
" ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { returnByValue: true, expression: '({ browserWindow: { x: window.screenX, y: window.screenY, width: window.outerWidth, height: window.outerHeight }, viewport: { width: window.innerWidth, height: window.innerHeight, deviceScaleFactor: window.devicePixelRatio } })' } }));",
|
|
961
|
-
" ws.onmessage = (event) => {",
|
|
962
|
-
" try {",
|
|
963
|
-
" const payload = JSON.parse(String(event.data));",
|
|
964
|
-
" if (payload.id !== 1) return;",
|
|
965
|
-
" clearTimeout(timer);",
|
|
966
|
-
" resolve(payload.result && payload.result.result && payload.result.result.value);",
|
|
967
|
-
" } catch { clearTimeout(timer); resolve(undefined); }",
|
|
968
|
-
" };",
|
|
969
|
-
" ws.onerror = () => { clearTimeout(timer); resolve(undefined); };",
|
|
970
|
-
"}).finally(() => { try { ws.close(); } catch {} });",
|
|
971
|
-
"console.log(JSON.stringify(result ? { ...result, targetId: String(page.id || '') } : {}));"
|
|
972
|
-
].join("\n");
|
|
973
|
-
const result = await desktop.commands.run(`node --input-type=module -e ${shellSingleQuote(script)}`, {
|
|
974
|
-
requestTimeoutMs,
|
|
975
|
-
timeoutMs: 5_000
|
|
976
|
-
});
|
|
922
|
+
const result = await desktop.commands.run(chromeCdpProbeCommand({ ...endpoint, ...(targetId === undefined ? {} : { targetId }), prefer: "pinned", mode: "geometry" }), { requestTimeoutMs, timeoutMs: 5_000 });
|
|
977
923
|
if (result.exitCode !== undefined && result.exitCode !== 0) {
|
|
924
|
+
onUnavailable?.(`probe exited ${result.exitCode}: ${tailOf(result.stderr ?? result.stdout ?? "")}`);
|
|
978
925
|
return undefined;
|
|
979
926
|
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
const record = parsed;
|
|
985
|
-
const rawWindow = record.browserWindow;
|
|
986
|
-
const rawViewport = record.viewport;
|
|
987
|
-
if (!isMeasuredRect(rawWindow) || !isMeasuredViewport(rawViewport))
|
|
988
|
-
return undefined;
|
|
989
|
-
return {
|
|
990
|
-
browserWindow: { ...rawWindow, source: "cdp" },
|
|
991
|
-
viewport: { ...rawViewport, source: "cdp" },
|
|
992
|
-
...(typeof record.targetId === "string" && record.targetId.length > 0 ? { targetId: record.targetId } : {})
|
|
993
|
-
};
|
|
927
|
+
const parsed = parseChromeCdpProbeOutput(result.stdout);
|
|
928
|
+
if (parsed.unavailable !== undefined) {
|
|
929
|
+
onUnavailable?.(parsed.unavailable);
|
|
930
|
+
return undefined;
|
|
994
931
|
}
|
|
995
|
-
|
|
932
|
+
if (!isMeasuredRect(parsed.browserWindow) || !isMeasuredViewport(parsed.viewport)) {
|
|
933
|
+
onUnavailable?.("the page reported no usable window or viewport dimensions");
|
|
996
934
|
return undefined;
|
|
997
935
|
}
|
|
936
|
+
return {
|
|
937
|
+
browserWindow: { ...parsed.browserWindow, source: "cdp" },
|
|
938
|
+
viewport: { ...parsed.viewport, source: "cdp" },
|
|
939
|
+
...(parsed.targetId === undefined ? {} : { targetId: parsed.targetId })
|
|
940
|
+
};
|
|
998
941
|
};
|
|
999
942
|
}
|
|
943
|
+
/** The user agent a mobile-emulated lane presents unless the lab sets its own. */
|
|
944
|
+
export const DEFAULT_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
|
945
|
+
/**
|
|
946
|
+
* Apply mobile emulation (#221) to the lane's launch page and read back what the page reports.
|
|
947
|
+
* Fails CLOSED: a request that cannot be applied throws, because a desktop run labelled mobile is
|
|
948
|
+
* the over-trust this feature exists to prevent. A read-back that cannot be taken is a warning
|
|
949
|
+
* (the emulation was applied; only the proof is missing).
|
|
950
|
+
*/
|
|
951
|
+
export async function applyMobileEmulation(desktop, requestTimeoutMs, endpoint, targetId, request) {
|
|
952
|
+
const command = (mode) => chromeCdpProbeCommand({ ...endpoint, ...(targetId === undefined ? {} : { targetId }), prefer: "pinned", mode, emulation: request });
|
|
953
|
+
const read = async () => {
|
|
954
|
+
const result = await desktop.commands.run(command("fidelity"), { requestTimeoutMs, timeoutMs: 15_000 });
|
|
955
|
+
if (result.exitCode !== undefined && result.exitCode !== 0) {
|
|
956
|
+
return { unavailable: `probe exited ${result.exitCode}: ${tailOf(result.stderr ?? result.stdout ?? "")}` };
|
|
957
|
+
}
|
|
958
|
+
return parseChromeCdpProbeOutput(result.stdout);
|
|
959
|
+
};
|
|
960
|
+
// The UA / touch / DPR overrides are bound to the DevTools session that set them and lapse the
|
|
961
|
+
// moment its socket closes (measured: only the viewport width survived a one-shot apply). So the
|
|
962
|
+
// applier stays attached for the lane's whole life as a detached process; the sandbox teardown
|
|
963
|
+
// ends it. Its first stdout line says what was applied.
|
|
964
|
+
const holderName = `mobile-emulation-${Date.now().toString(36)}`;
|
|
965
|
+
await startDetachedProcess(desktop, { name: holderName, command: command("hold"), requestTimeoutMs });
|
|
966
|
+
let announced;
|
|
967
|
+
for (let attempt = 0; attempt < 30 && announced === undefined; attempt += 1) {
|
|
968
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
969
|
+
const log = await readDetachedLog(desktop, holderName, requestTimeoutMs).catch(() => "");
|
|
970
|
+
const line = log.split("\n").find((candidate) => candidate.trim().startsWith("{"));
|
|
971
|
+
if (line !== undefined)
|
|
972
|
+
announced = parseChromeCdpProbeOutput(line);
|
|
973
|
+
}
|
|
974
|
+
if (announced === undefined) {
|
|
975
|
+
throw new Error("mobile emulation could not be applied: the in-sandbox applier printed nothing within 15 s");
|
|
976
|
+
}
|
|
977
|
+
if (announced.unavailable !== undefined) {
|
|
978
|
+
throw new Error(`mobile emulation could not be applied (${announced.unavailable}); applied before failing: ${(announced.applied ?? []).join(", ") || "nothing"}`);
|
|
979
|
+
}
|
|
980
|
+
const applied = announced;
|
|
981
|
+
const warnings = [];
|
|
982
|
+
// The reload inside the applier takes a moment; the read-back is retried until the page reports
|
|
983
|
+
// the requested viewport and user agent, so a slow page does not read as "no proof".
|
|
984
|
+
let readBack = await read();
|
|
985
|
+
for (let attempt = 0; attempt < 20 && (readBack.fidelity === undefined || readBack.fidelity.innerWidth !== request.width || !readBack.fidelity.userAgent.includes(request.userAgent.slice(0, 24))); attempt += 1) {
|
|
986
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
987
|
+
readBack = await read();
|
|
988
|
+
}
|
|
989
|
+
const fidelityRead = readBack;
|
|
990
|
+
const requested = {
|
|
991
|
+
width: request.width,
|
|
992
|
+
height: request.height,
|
|
993
|
+
deviceScaleFactor: request.deviceScaleFactor,
|
|
994
|
+
touch: request.touch,
|
|
995
|
+
userAgent: request.userAgent
|
|
996
|
+
};
|
|
997
|
+
if (fidelityRead.fidelity === undefined) {
|
|
998
|
+
warnings.push(`Mobile emulation was applied but the page's own report could not be read (${fidelityRead.unavailable ?? "no fidelity read"}); desktopGeometry.fidelity carries the request without a resolved block.`);
|
|
999
|
+
return { fidelity: { tier: "mobile-emulated", requested, applied: applied.applied ?? [] }, warnings };
|
|
1000
|
+
}
|
|
1001
|
+
const resolved = { ...fidelityRead.fidelity, source: "cdp" };
|
|
1002
|
+
if (resolved.innerWidth !== request.width) {
|
|
1003
|
+
warnings.push(`Mobile emulation requested a ${request.width} px viewport; the page reports ${resolved.innerWidth} px.`);
|
|
1004
|
+
}
|
|
1005
|
+
if (resolved.devicePixelRatio !== request.deviceScaleFactor) {
|
|
1006
|
+
warnings.push(`Mobile emulation requested devicePixelRatio ${request.deviceScaleFactor}; the page reports ${resolved.devicePixelRatio}.`);
|
|
1007
|
+
}
|
|
1008
|
+
if (request.touch && resolved.maxTouchPoints === 0) {
|
|
1009
|
+
warnings.push("Mobile emulation requested touch; the page reports navigator.maxTouchPoints 0.");
|
|
1010
|
+
}
|
|
1011
|
+
if (!resolved.userAgent.includes("Mobile") && !resolved.userAgent.includes("Android") && !resolved.userAgent.includes("iPhone")) {
|
|
1012
|
+
warnings.push("Mobile emulation requested a mobile user agent; the page reports a desktop one.");
|
|
1013
|
+
}
|
|
1014
|
+
return { fidelity: { tier: "mobile-emulated", requested, applied: applied.applied ?? [], resolved }, warnings };
|
|
1015
|
+
}
|
|
1000
1016
|
function isMeasuredRect(value) {
|
|
1001
1017
|
if (!value || typeof value !== "object")
|
|
1002
1018
|
return false;
|
|
@@ -1063,25 +1079,37 @@ export async function captureDesktopBrowserGeometry(args) {
|
|
|
1063
1079
|
else {
|
|
1064
1080
|
warnings.push(`Browser window bounds could not be measured for lane ${args.laneId}; the live stream will use the full desktop.`);
|
|
1065
1081
|
}
|
|
1082
|
+
let cdpUnavailable;
|
|
1066
1083
|
const chromeGeometry = args.browserFamily === "chromium"
|
|
1067
1084
|
? await makeChromeDesktopGeometryObserver(args.desktop, args.requestTimeoutMs, {
|
|
1068
1085
|
...(args.launchIdentity?.cdpPort === undefined ? {} : { cdpPort: args.launchIdentity.cdpPort }),
|
|
1069
1086
|
...(args.launchIdentity?.profileDir === undefined ? {} : { profileDir: args.launchIdentity.profileDir }),
|
|
1070
1087
|
targetUrl: args.targetUrl
|
|
1071
|
-
}, args.browserTargetId
|
|
1088
|
+
}, args.browserTargetId, (reason) => {
|
|
1089
|
+
cdpUnavailable = reason;
|
|
1090
|
+
})().catch((error) => {
|
|
1091
|
+
cdpUnavailable = toErrorMessage(error);
|
|
1092
|
+
return undefined;
|
|
1093
|
+
})
|
|
1072
1094
|
: undefined;
|
|
1073
1095
|
const browserWindow = chromeGeometry?.browserWindow ?? xdotoolWindow;
|
|
1074
1096
|
const viewport = chromeGeometry?.viewport;
|
|
1097
|
+
// The fill check reads the X window when it was measured: under mobile emulation (#221) the
|
|
1098
|
+
// page's window.outerWidth reports the EMULATED screen (414), which is not a fill failure.
|
|
1099
|
+
const fillBounds = xdotoolWindow ?? browserWindow;
|
|
1075
1100
|
if (!browserWindow) {
|
|
1076
1101
|
warnings.push(`Browser outer bounds could not be measured for lane ${args.laneId}.`);
|
|
1077
1102
|
}
|
|
1078
|
-
else if (
|
|
1079
|
-
warnings.push(`Browser window fill did not reach the requested ${args.requestedScreen[0]}x${args.requestedScreen[1]} screen for lane ${args.laneId}; measured outer bounds are ${
|
|
1103
|
+
else if (fillBounds !== undefined && (fillBounds.width !== args.requestedScreen[0] || fillBounds.height !== args.requestedScreen[1])) {
|
|
1104
|
+
warnings.push(`Browser window fill did not reach the requested ${args.requestedScreen[0]}x${args.requestedScreen[1]} screen for lane ${args.laneId}; measured outer bounds are ${fillBounds.width}x${fillBounds.height}.`);
|
|
1080
1105
|
}
|
|
1081
1106
|
if (!viewport) {
|
|
1107
|
+
// Name the cause, not only the symptom: the same dead DevTools channel that loses the viewport
|
|
1108
|
+
// loses every url/text observation, and a reader of the bundle should learn that here (#514).
|
|
1109
|
+
const cause = cdpUnavailable === undefined ? "" : ` DevTools probe: ${redactText(cdpUnavailable)}.`;
|
|
1082
1110
|
warnings.push(args.browserFamily === "firefox"
|
|
1083
1111
|
? `Browser CSS viewport measurement is unavailable for Firefox on lane ${args.laneId}; stream.viewport is omitted instead of reading a different browser's CDP endpoint.`
|
|
1084
|
-
: `Browser CSS viewport could not be measured for lane ${args.laneId}; stream.viewport is omitted instead of copying the requested screen resolution
|
|
1112
|
+
: `Browser CSS viewport could not be measured for lane ${args.laneId}; stream.viewport is omitted instead of copying the requested screen resolution.${cause}`);
|
|
1085
1113
|
}
|
|
1086
1114
|
return {
|
|
1087
1115
|
...(browserWindowId === undefined ? {} : { browserWindowId }),
|
|
@@ -1216,8 +1244,17 @@ function hasBlockerLanguage(text) {
|
|
|
1216
1244
|
// study completed without a participant-reported finding". Friction is the INCLUSIVE scan; a
|
|
1217
1245
|
// false positive here adds a candidate a person then reads, which is the cheap direction.
|
|
1218
1246
|
const REPORTED_DEFECT_LANGUAGE = /\b(defects?|bugs?|accessibilit(y|ies)|inaccessible|not (keyboard|screen.?reader)[- ]?accessible|confus(ed|ing)|hesitat(ed|ion)|unexpected(ly)?|unclear|hard to (find|tell|see|read|reach)|no (visible )?focus|overlap(ped|ping|s)?|truncat(ed|es|ion)|cut off|did nothing|nothing happened|no effect)\b/;
|
|
1247
|
+
// The friction scan's own negations (#614). "Nothing was confusing", "no defects", "not unclear"
|
|
1248
|
+
// are what a participant writes when it has NOTHING to report, and until 2026-09-03 each of them
|
|
1249
|
+
// counted as reported friction and became a feedback candidate whose "actual" was a sentence
|
|
1250
|
+
// reporting no problem. Only the report-shaped adjectives are negatable here: "no visible focus",
|
|
1251
|
+
// "not keyboard-accessible" and "did nothing" are defects and stay.
|
|
1252
|
+
const NEGATED_REPORT_LANGUAGE = /\b(?:nothing|no|not|never|without|none)\s+(?:was\s+|were\s+|felt\s+|seemed\s+|really\s+|particularly\s+|especially\s+|major\s+|real\s+|obvious\s+|noticeable\s+)*(?:confus(?:ed|ing|ion)|unclear|unexpected(?:ly)?|hesitat(?:ed|ion|ions)|surpris(?:ed|ing|es)|defects?|bugs?|overlap(?:ped|ping|s)?|truncat(?:ed|es|ion)|hard to (?:find|tell|see|read|reach))\b/g;
|
|
1253
|
+
function stripNegatedReportLanguage(text) {
|
|
1254
|
+
return text.replace(NEGATED_REPORT_LANGUAGE, " ");
|
|
1255
|
+
}
|
|
1219
1256
|
function completionReasonContradictsGoal(reason) {
|
|
1220
|
-
const text = stripQuotedSpans(stripNegatedNonBlockerPhrases(reason.toLowerCase()));
|
|
1257
|
+
const text = stripQuotedSpans(stripNegatedReportLanguage(stripNegatedNonBlockerPhrases(reason.toLowerCase())));
|
|
1221
1258
|
return hasBlockerLanguage(text) || REPORTED_DEFECT_LANGUAGE.test(text);
|
|
1222
1259
|
}
|
|
1223
1260
|
/** The verdict scan (strict): like the friction scan, but resolved-arc segments are stripped
|
|
@@ -1422,6 +1459,7 @@ export async function runCuaLane(spec, deps) {
|
|
|
1422
1459
|
let browserLaunchIdentity;
|
|
1423
1460
|
let browserLaunched = false;
|
|
1424
1461
|
let initialBrowserGeometry;
|
|
1462
|
+
let appliedFidelity;
|
|
1425
1463
|
let browserWindowId;
|
|
1426
1464
|
let browserTargetId;
|
|
1427
1465
|
const declaredScreen = declaredScreenForRender(spec.devicePreset, spec.deviceName, spec.resolution);
|
|
@@ -1613,12 +1651,44 @@ export async function runCuaLane(spec, deps) {
|
|
|
1613
1651
|
});
|
|
1614
1652
|
}
|
|
1615
1653
|
if (!desktopCliRoute) {
|
|
1616
|
-
const
|
|
1654
|
+
const requestedFidelity = config.execution?.desktop?.fidelity;
|
|
1655
|
+
const browserLaunch = await openDesktopBrowserTarget(desktop, targetUrl, deps.requestTimeoutMs, config.execution?.desktop?.browser, requestedFidelity?.mobileEmulation && spec.devicePreset.isMobile
|
|
1656
|
+
? [
|
|
1657
|
+
`--user-agent=${requestedFidelity.userAgent ?? DEFAULT_MOBILE_USER_AGENT}`,
|
|
1658
|
+
...(requestedFidelity.touch === false ? [] : ["--touch-events=enabled"])
|
|
1659
|
+
]
|
|
1660
|
+
: []);
|
|
1617
1661
|
desktopBrowser = browserLaunch.evidence;
|
|
1618
1662
|
launchedBrowserFamily = browserLaunch.family;
|
|
1619
1663
|
browserLaunchIdentity = browserLaunch.identity;
|
|
1620
1664
|
browserLaunched = true;
|
|
1621
1665
|
await desktop.wait(BROWSER_SETTLE_MS).catch(() => undefined);
|
|
1666
|
+
// Mobile fidelity beyond viewport size (#221): applied to the launch page before the
|
|
1667
|
+
// geometry capture and the participant's first observation, OUTSIDE the stream/geometry
|
|
1668
|
+
// try below (whose catch degrades to a warning): a request that cannot be applied fails
|
|
1669
|
+
// the lane closed with the reason.
|
|
1670
|
+
// Only lanes on a mobile preset are emulated: a run-wide flag must not hand a desktop or
|
|
1671
|
+
// tablet lane an iPhone user agent (the first live proof did exactly that to the desktop
|
|
1672
|
+
// newcomer beside the phone lane). Those lanes carry no fidelity block, which is honest.
|
|
1673
|
+
const fidelityRequest = config.execution?.desktop?.fidelity;
|
|
1674
|
+
if (fidelityRequest?.mobileEmulation && spec.devicePreset.isMobile) {
|
|
1675
|
+
if (launchedBrowserFamily !== "chromium") {
|
|
1676
|
+
throw new Error(`execution.desktop.fidelity.mobileEmulation needs Chrome or Chromium on lane ${spec.laneId}; the launched browser family is ${launchedBrowserFamily}. Set execution.desktop.browser: chrome.`);
|
|
1677
|
+
}
|
|
1678
|
+
const applied = await applyMobileEmulation(desktop, deps.requestTimeoutMs, {
|
|
1679
|
+
...(browserLaunchIdentity?.cdpPort === undefined ? {} : { cdpPort: browserLaunchIdentity.cdpPort }),
|
|
1680
|
+
...(browserLaunchIdentity?.profileDir === undefined ? {} : { profileDir: browserLaunchIdentity.profileDir }),
|
|
1681
|
+
targetUrl
|
|
1682
|
+
}, browserTargetId, {
|
|
1683
|
+
width: spec.devicePreset.width,
|
|
1684
|
+
height: spec.devicePreset.height,
|
|
1685
|
+
deviceScaleFactor: fidelityRequest.deviceScaleFactor ?? spec.devicePreset.deviceScaleFactor,
|
|
1686
|
+
touch: fidelityRequest.touch ?? true,
|
|
1687
|
+
userAgent: fidelityRequest.userAgent ?? DEFAULT_MOBILE_USER_AGENT
|
|
1688
|
+
});
|
|
1689
|
+
appliedFidelity = applied.fidelity;
|
|
1690
|
+
warnings.push(...applied.warnings);
|
|
1691
|
+
}
|
|
1622
1692
|
}
|
|
1623
1693
|
else {
|
|
1624
1694
|
// A terminal window, opened the way the browser is opened on every other route: the
|
|
@@ -1752,7 +1822,13 @@ export async function runCuaLane(spec, deps) {
|
|
|
1752
1822
|
...(browserLaunchIdentity?.cdpPort === undefined ? {} : { cdpPort: browserLaunchIdentity.cdpPort }),
|
|
1753
1823
|
...(browserLaunchIdentity?.profileDir === undefined ? {} : { profileDir: browserLaunchIdentity.profileDir }),
|
|
1754
1824
|
targetUrl
|
|
1755
|
-
}, browserTargetId
|
|
1825
|
+
}, browserTargetId,
|
|
1826
|
+
// Once per lane: a dark observation channel is a gap in the instrument, and the
|
|
1827
|
+
// funnel's NEVER MEASURED count needs this line to explain itself (#514).
|
|
1828
|
+
(reason) => {
|
|
1829
|
+
warnings.push(`Browser-state observer unavailable for lane ${spec.laneId} (${redactText(deps.scrubKnownValues(reason))}); ` +
|
|
1830
|
+
"urlIncludes/urlPathEquals/textIncludes stop conditions and task criteria are NOT being measured this session.");
|
|
1831
|
+
})
|
|
1756
1832
|
}
|
|
1757
1833
|
}
|
|
1758
1834
|
: {}),
|
|
@@ -1837,6 +1913,7 @@ export async function runCuaLane(spec, deps) {
|
|
|
1837
1913
|
screen: desktopGeometry.screen,
|
|
1838
1914
|
...(chosenGeometry.browserWindow === undefined ? {} : { browserWindow: chosenGeometry.browserWindow }),
|
|
1839
1915
|
...(chosenGeometry.viewport === undefined ? {} : { viewport: chosenGeometry.viewport }),
|
|
1916
|
+
...(appliedFidelity === undefined ? {} : { fidelity: appliedFidelity }),
|
|
1840
1917
|
...((desktopGeometry.warnings?.length ?? 0) + geometryWarnings.length === 0
|
|
1841
1918
|
? {}
|
|
1842
1919
|
: { warnings: [...(desktopGeometry.warnings ?? []), ...geometryWarnings] })
|
|
@@ -3288,13 +3365,17 @@ async function runSubjectServePipeline(desktop, args) {
|
|
|
3288
3365
|
if (needsNodeRuntime(serveCommands)) {
|
|
3289
3366
|
const runtimeStartedAt = now();
|
|
3290
3367
|
emitPhaseStarted(args.onPhase, now, "runtime", "providing the Node runtime the serve pipeline needs");
|
|
3291
|
-
const bootstrap = await
|
|
3368
|
+
const bootstrap = await runProvisioningStepWithOneRetry(desktop, {
|
|
3292
3369
|
name: "subject-runtime-node",
|
|
3293
3370
|
command: nodeBootstrapCommand(),
|
|
3294
3371
|
cwd: SUBJECT_DIR,
|
|
3295
3372
|
timeoutMs: args.serve.installTimeoutMs ?? INSTALL_TIMEOUT_MS,
|
|
3296
3373
|
requestTimeoutMs: args.requestTimeoutMs,
|
|
3297
|
-
|
|
3374
|
+
timers,
|
|
3375
|
+
retryPhase: "runtime-retry",
|
|
3376
|
+
retryMessage: "Node runtime bootstrap",
|
|
3377
|
+
onPhase: args.onPhase,
|
|
3378
|
+
now
|
|
3298
3379
|
});
|
|
3299
3380
|
let ok = bootstrap.ok;
|
|
3300
3381
|
const corepack = ok ? corepackCommandFor(serveCommands) : undefined;
|
|
@@ -3311,23 +3392,39 @@ async function runSubjectServePipeline(desktop, args) {
|
|
|
3311
3392
|
}
|
|
3312
3393
|
emitPhaseCompleted(args.onPhase, now, runtimeStartedAt, "runtime", ok, ok ? "Node runtime ready" : "could not provide a Node runtime");
|
|
3313
3394
|
if (!ok) {
|
|
3314
|
-
throw new Error(`the subject's serve pipeline needs a Node runtime and this desktop template has none, and bootstrapping one failed: ${tailOf(args.scrub(bootstrap.logTail))}. Use execution.desktop.template with an image that ships Node, or change serve.install to a runtime the template provides.`);
|
|
3395
|
+
throw new Error(`the subject's serve pipeline needs a Node runtime and this desktop template has none, and bootstrapping one failed${bootstrap.attempts === 2 ? " twice" : ""}: ${tailOf(args.scrub(bootstrap.logTail))}. Use execution.desktop.template with an image that ships Node, or change serve.install to a runtime the template provides.`);
|
|
3315
3396
|
}
|
|
3316
3397
|
}
|
|
3317
3398
|
if (args.serve.install) {
|
|
3318
3399
|
const installStartedAt = now();
|
|
3319
3400
|
emitPhaseStarted(args.onPhase, now, "install", "installing subject dependencies");
|
|
3320
|
-
const install = await
|
|
3401
|
+
const install = await runProvisioningStepWithOneRetry(desktop, {
|
|
3321
3402
|
name: "subject-install",
|
|
3322
3403
|
command: args.serve.install,
|
|
3323
3404
|
cwd: SUBJECT_DIR,
|
|
3324
3405
|
timeoutMs: args.serve.installTimeoutMs ?? INSTALL_TIMEOUT_MS,
|
|
3325
3406
|
requestTimeoutMs: args.requestTimeoutMs,
|
|
3326
|
-
|
|
3407
|
+
timers,
|
|
3408
|
+
retryPhase: "install-retry",
|
|
3409
|
+
retryMessage: "subject install",
|
|
3410
|
+
onPhase: args.onPhase,
|
|
3411
|
+
now
|
|
3327
3412
|
});
|
|
3328
|
-
emitPhaseCompleted(args.onPhase, now, installStartedAt, "install", install.ok, install.ok
|
|
3413
|
+
emitPhaseCompleted(args.onPhase, now, installStartedAt, "install", install.ok, install.ok
|
|
3414
|
+
? install.attempts === 2
|
|
3415
|
+
? "subject dependencies installed (on the second attempt)"
|
|
3416
|
+
: "subject dependencies installed"
|
|
3417
|
+
: install.attempts === 2
|
|
3418
|
+
? "subject install failed twice"
|
|
3419
|
+
: "subject install failed");
|
|
3329
3420
|
if (!install.ok) {
|
|
3330
|
-
|
|
3421
|
+
// Lead with the line a person can act on; npm's own trace follows it (#602).
|
|
3422
|
+
const headline = install.timedOut
|
|
3423
|
+
? `subject install timed out after ${args.serve.installTimeoutMs ?? INSTALL_TIMEOUT_MS}ms`
|
|
3424
|
+
: install.attempts === 2
|
|
3425
|
+
? `subject install failed twice (exit ${install.firstExitCode ?? "null"}, then exit ${install.exitCode ?? "null"}); the sandbox could not complete serve.install`
|
|
3426
|
+
: `subject install failed (exit ${install.exitCode ?? "null"})`;
|
|
3427
|
+
throw new Error(`${headline}: ${tailOf(args.scrub(install.logTail))}`);
|
|
3331
3428
|
}
|
|
3332
3429
|
await refresh();
|
|
3333
3430
|
}
|