humanish 0.75.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.
@@ -736,10 +736,13 @@ async function fillDesktopBrowserWindow(desktop, windowId, resolution, requestTi
736
736
  })
737
737
  .catch(() => undefined);
738
738
  }
739
- 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 = []) {
740
743
  const requestedBrowser = browserPreference ?? "default";
741
744
  if (isHttpUrl(targetUrl)) {
742
- const chromiumFlags = CHROMIUM_EVIDENCE_HYGIENE_FLAGS.map(shellSingleQuote).join(" ");
745
+ const chromiumFlags = [...CHROMIUM_EVIDENCE_HYGIENE_FLAGS, ...extraChromiumFlags].map(shellSingleQuote).join(" ");
743
746
  const browserLaunchCommand = [
744
747
  "set -euo pipefail",
745
748
  `target_url=${shellSingleQuote(targetUrl)}`,
@@ -937,6 +940,79 @@ export function makeChromeDesktopGeometryObserver(desktop, requestTimeoutMs, end
937
940
  };
938
941
  };
939
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
+ }
940
1016
  function isMeasuredRect(value) {
941
1017
  if (!value || typeof value !== "object")
942
1018
  return false;
@@ -1018,11 +1094,14 @@ export async function captureDesktopBrowserGeometry(args) {
1018
1094
  : undefined;
1019
1095
  const browserWindow = chromeGeometry?.browserWindow ?? xdotoolWindow;
1020
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;
1021
1100
  if (!browserWindow) {
1022
1101
  warnings.push(`Browser outer bounds could not be measured for lane ${args.laneId}.`);
1023
1102
  }
1024
- else if (browserWindow.width !== args.requestedScreen[0] || browserWindow.height !== args.requestedScreen[1]) {
1025
- 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 ${browserWindow.width}x${browserWindow.height}.`);
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}.`);
1026
1105
  }
1027
1106
  if (!viewport) {
1028
1107
  // Name the cause, not only the symptom: the same dead DevTools channel that loses the viewport
@@ -1165,8 +1244,17 @@ function hasBlockerLanguage(text) {
1165
1244
  // study completed without a participant-reported finding". Friction is the INCLUSIVE scan; a
1166
1245
  // false positive here adds a candidate a person then reads, which is the cheap direction.
1167
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
+ }
1168
1256
  function completionReasonContradictsGoal(reason) {
1169
- const text = stripQuotedSpans(stripNegatedNonBlockerPhrases(reason.toLowerCase()));
1257
+ const text = stripQuotedSpans(stripNegatedReportLanguage(stripNegatedNonBlockerPhrases(reason.toLowerCase())));
1170
1258
  return hasBlockerLanguage(text) || REPORTED_DEFECT_LANGUAGE.test(text);
1171
1259
  }
1172
1260
  /** The verdict scan (strict): like the friction scan, but resolved-arc segments are stripped
@@ -1371,6 +1459,7 @@ export async function runCuaLane(spec, deps) {
1371
1459
  let browserLaunchIdentity;
1372
1460
  let browserLaunched = false;
1373
1461
  let initialBrowserGeometry;
1462
+ let appliedFidelity;
1374
1463
  let browserWindowId;
1375
1464
  let browserTargetId;
1376
1465
  const declaredScreen = declaredScreenForRender(spec.devicePreset, spec.deviceName, spec.resolution);
@@ -1562,12 +1651,44 @@ export async function runCuaLane(spec, deps) {
1562
1651
  });
1563
1652
  }
1564
1653
  if (!desktopCliRoute) {
1565
- const browserLaunch = await openDesktopBrowserTarget(desktop, targetUrl, deps.requestTimeoutMs, config.execution?.desktop?.browser);
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
+ : []);
1566
1661
  desktopBrowser = browserLaunch.evidence;
1567
1662
  launchedBrowserFamily = browserLaunch.family;
1568
1663
  browserLaunchIdentity = browserLaunch.identity;
1569
1664
  browserLaunched = true;
1570
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
+ }
1571
1692
  }
1572
1693
  else {
1573
1694
  // A terminal window, opened the way the browser is opened on every other route: the
@@ -1792,6 +1913,7 @@ export async function runCuaLane(spec, deps) {
1792
1913
  screen: desktopGeometry.screen,
1793
1914
  ...(chosenGeometry.browserWindow === undefined ? {} : { browserWindow: chosenGeometry.browserWindow }),
1794
1915
  ...(chosenGeometry.viewport === undefined ? {} : { viewport: chosenGeometry.viewport }),
1916
+ ...(appliedFidelity === undefined ? {} : { fidelity: appliedFidelity }),
1795
1917
  ...((desktopGeometry.warnings?.length ?? 0) + geometryWarnings.length === 0
1796
1918
  ? {}
1797
1919
  : { warnings: [...(desktopGeometry.warnings ?? []), ...geometryWarnings] })