@ait-co/devtools 0.1.135 → 0.1.137

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.
Files changed (72) hide show
  1. package/dist/bundle-BKqyhEK9.d.ts.map +1 -1
  2. package/dist/{chii-relay-DkTOopRj.js → chii-relay-BZ3HqWL5.js} +2 -1
  3. package/dist/{chii-relay-DkTOopRj.js.map → chii-relay-BZ3HqWL5.js.map} +1 -1
  4. package/dist/{chii-relay-P6SKmB1g.cjs → chii-relay-D7eK2acz.cjs} +2 -1
  5. package/dist/{chii-relay-P6SKmB1g.cjs.map → chii-relay-D7eK2acz.cjs.map} +1 -1
  6. package/dist/{debug-server-DR84Z243.js → debug-server-BQplCzf8.js} +23 -4
  7. package/dist/debug-server-BQplCzf8.js.map +1 -0
  8. package/dist/{debug-server-B2aCwwtV.js → debug-server-CBK7dn8O.js} +21 -2
  9. package/dist/debug-server-CBK7dn8O.js.map +1 -0
  10. package/dist/{debug-server-CBqMfcUi.js → debug-server-D2wpPK3_.js} +3 -2
  11. package/dist/debug-server-D2wpPK3_.js.map +1 -0
  12. package/dist/mcp/cli.js +72 -19
  13. package/dist/mcp/cli.js.map +1 -1
  14. package/dist/mcp/server.js +1 -1
  15. package/dist/panel/index.js +1 -1
  16. package/dist/{pool-C6TgrcyW.d.ts → pool-CmfvXnwh.d.ts} +2 -2
  17. package/dist/{pool-C6TgrcyW.d.ts.map → pool-CmfvXnwh.d.ts.map} +1 -1
  18. package/dist/{qr-http-server-yQ1EWTwf.js → qr-http-server-C7q8Gn04.js} +2 -1
  19. package/dist/{qr-http-server-iBqsAKor.js.map → qr-http-server-C7q8Gn04.js.map} +1 -1
  20. package/dist/{qr-http-server-iBqsAKor.js → qr-http-server-CTLdclg_.js} +2 -1
  21. package/dist/{qr-http-server-yQ1EWTwf.js.map → qr-http-server-CTLdclg_.js.map} +1 -1
  22. package/dist/{qr-http-server-B1en4tTL.cjs → qr-http-server-Cz1mA07b.cjs} +2 -1
  23. package/dist/{qr-http-server-hnuUw9TK.cjs.map → qr-http-server-Cz1mA07b.cjs.map} +1 -1
  24. package/dist/{qr-http-server-hnuUw9TK.cjs → qr-http-server-D16Lq2ag.cjs} +2 -1
  25. package/dist/{qr-http-server-B1en4tTL.cjs.map → qr-http-server-D16Lq2ag.cjs.map} +1 -1
  26. package/dist/{qr-http-server-DlNWJEaH.js → qr-http-server-ZkT6F7is.js} +2 -1
  27. package/dist/{qr-http-server-CLt72Dwe.js.map → qr-http-server-ZkT6F7is.js.map} +1 -1
  28. package/dist/{qr-http-server-CLt72Dwe.js → qr-http-server-wy3L7Yut.js} +2 -1
  29. package/dist/{qr-http-server-DlNWJEaH.js.map → qr-http-server-wy3L7Yut.js.map} +1 -1
  30. package/dist/{relay-factory-CjqAJ3Y8.js → relay-factory-DVzpBRxz.js} +4 -3
  31. package/dist/relay-factory-DVzpBRxz.js.map +1 -0
  32. package/dist/{relay-worker-UK0nBfDX.js → relay-worker-6fy1eaIo.js} +6 -3
  33. package/dist/{relay-worker-UK0nBfDX.js.map → relay-worker-6fy1eaIo.js.map} +1 -1
  34. package/dist/{relay-worker-Dw3aIzIK.d.ts → relay-worker-DJnZkXza.d.ts} +41 -7
  35. package/dist/relay-worker-DJnZkXza.d.ts.map +1 -0
  36. package/dist/test-runner/bin.js +198 -30
  37. package/dist/test-runner/bin.js.map +1 -1
  38. package/dist/test-runner/bridge-stub.d.ts +125 -0
  39. package/dist/test-runner/bridge-stub.d.ts.map +1 -0
  40. package/dist/test-runner/bridge-stub.js +92 -0
  41. package/dist/test-runner/bridge-stub.js.map +1 -0
  42. package/dist/test-runner/bundle.js +44 -14
  43. package/dist/test-runner/bundle.js.map +1 -1
  44. package/dist/test-runner/config.d.ts +11 -1
  45. package/dist/test-runner/config.d.ts.map +1 -1
  46. package/dist/test-runner/config.js +1 -1
  47. package/dist/test-runner/pool.d.ts +1 -1
  48. package/dist/test-runner/pool.js +1 -1
  49. package/dist/test-runner/relay-factory.d.ts +10 -0
  50. package/dist/test-runner/relay-factory.d.ts.map +1 -1
  51. package/dist/test-runner/relay-factory.js +3 -2
  52. package/dist/test-runner/relay-factory.js.map +1 -1
  53. package/dist/test-runner/relay-worker.d.ts +1 -1
  54. package/dist/test-runner/relay-worker.js +1 -1
  55. package/dist/test-runner/report.d.ts +41 -15
  56. package/dist/test-runner/report.d.ts.map +1 -1
  57. package/dist/test-runner/report.js +33 -9
  58. package/dist/test-runner/report.js.map +1 -1
  59. package/dist/{tunnel-DUCogcQi.js → tunnel-5eBncITa.js} +2 -2
  60. package/dist/{tunnel-DUCogcQi.js.map → tunnel-5eBncITa.js.map} +1 -1
  61. package/dist/{tunnel-Cr_oTkaj.cjs → tunnel-DjtSENJU.cjs} +2 -2
  62. package/dist/{tunnel-Cr_oTkaj.cjs.map → tunnel-DjtSENJU.cjs.map} +1 -1
  63. package/dist/unplugin/index.cjs +2 -2
  64. package/dist/unplugin/index.js +2 -2
  65. package/dist/unplugin/tunnel.cjs +1 -1
  66. package/dist/unplugin/tunnel.js +1 -1
  67. package/package.json +1 -1
  68. package/dist/debug-server-B2aCwwtV.js.map +0 -1
  69. package/dist/debug-server-CBqMfcUi.js.map +0 -1
  70. package/dist/debug-server-DR84Z243.js.map +0 -1
  71. package/dist/relay-factory-CjqAJ3Y8.js.map +0 -1
  72. package/dist/relay-worker-Dw3aIzIK.d.ts.map +0 -1
@@ -122,7 +122,7 @@ function createRelayConnectionFactory(opts) {
122
122
  const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("../attach-orchestrator-CE0S09YU.js").then((n) => n.i);
123
123
  const { injectDebugIndicator, injectGlobals } = await import("../cell-BIdSQb9T.js").then((n) => n.t);
124
124
  const { loadRelaySecretReadOnly } = await import("../relay-secret-store-CLEGyHou.js");
125
- const { bootRelayFamily, buildRelayVerifyAuth } = await import("../debug-server-CBqMfcUi.js");
125
+ const { bootRelayFamily, buildRelayVerifyAuth } = await import("../debug-server-D2wpPK3_.js");
126
126
  await loadRelaySecretReadOnly({ projectRoot });
127
127
  let resolveTunnelUp;
128
128
  const tunnelReady = new Promise((resolve) => {
@@ -152,7 +152,7 @@ function createRelayConnectionFactory(opts) {
152
152
  canOpenBrowser: () => !headless
153
153
  };
154
154
  try {
155
- const { startQrHttpServer } = await import("../qr-http-server-yQ1EWTwf.js");
155
+ const { startQrHttpServer } = await import("../qr-http-server-C7q8Gn04.js");
156
156
  const getDashboardState = () => ({
157
157
  tunnel: attachDeps.getTunnelStatus(),
158
158
  pages: null,
@@ -210,6 +210,7 @@ function createRelayConnectionFactory(opts) {
210
210
  }
211
211
  await injectDebugIndicator(booted.connection);
212
212
  if (opts.cell !== void 0) await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });
213
+ if (opts.stubBlocking === true) await injectGlobals(booted.connection, { __AIT_STUB_BLOCKING__: true });
213
214
  return booted.connection;
214
215
  },
215
216
  onSessionPhase(next) {
@@ -906,6 +907,16 @@ const SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\]\\]
906
907
  *
907
908
  * If `window.__sdk` is absent (non-dog-food build), every access throws a
908
909
  * descriptive error rather than returning `undefined` silently.
910
+ *
911
+ * Bridge-stub interception (devtools#740, DT-2): the virtual module runs the
912
+ * resolved `window.__sdk` object through `wrapSdkWithStub` before exporting
913
+ * it. `wrapSdkWithStub` is a pass-through (returns the same object,
914
+ * unwrapped) unless `isStubBlockingEnabled()` reads `true` off
915
+ * `globalThis.__AIT_STUB_BLOCKING__` — an opt-in flag injected the same way
916
+ * `__AIT_CELL__` is (`cell.ts#injectGlobals`, wired from the CLI's
917
+ * `--stub-blocking` flag). Default off => zero behavior change: every existing
918
+ * (non-stub) test run resolves the exact same `window.__sdk` reference it did
919
+ * before this plugin learned about stubbing.
909
920
  */
910
921
  function sdkRedirectPlugin() {
911
922
  return {
@@ -920,16 +931,19 @@ function sdkRedirectPlugin() {
920
931
  namespace: "sdk-redirect"
921
932
  }, () => ({
922
933
  contents: `
923
- var __proxy = (typeof window !== 'undefined' && window.__sdk)
934
+ import { wrapSdkWithStub, isStubBlockingEnabled } from ${JSON.stringify(getBridgeStubPath())};
935
+ var __rawSdk = (typeof window !== 'undefined' && window.__sdk)
924
936
  ? window.__sdk
925
937
  : new Proxy({}, {
926
938
  get: function(_t, p) {
927
939
  throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
928
940
  }
929
941
  });
942
+ var __proxy = wrapSdkWithStub(__rawSdk, isStubBlockingEnabled());
930
943
  module.exports = __proxy;
931
944
  `,
932
- loader: "js"
945
+ loader: "js",
946
+ resolveDir: process.cwd()
933
947
  }));
934
948
  }
935
949
  };
@@ -1063,8 +1077,9 @@ function userFactoryPlugin(absPath) {
1063
1077
  };
1064
1078
  }
1065
1079
  /**
1066
- * Returns the absolute filesystem path to the test-runner runtime module
1067
- * (dist/test-runner/runtime.js — a fully self-contained page-side bundle).
1080
+ * Returns the absolute filesystem path to a page-side leaf module shipped
1081
+ * alongside `bundle.ts` in dist (e.g. `runtime.js`, `bridge-stub.js`) — a
1082
+ * fully self-contained module with no further page-side deps of its own.
1068
1083
  *
1069
1084
  * Rolldown code-splitting duplicates this bundling logic into shared chunks
1070
1085
  * emitted at ARBITRARY dist depths: the `devtools-test` CLI pulls it from
@@ -1074,26 +1089,30 @@ function userFactoryPlugin(absPath) {
1074
1089
  * is therefore wrong from at least one chunk — the live #697 regression.
1075
1090
  *
1076
1091
  * This resolves WITHOUT assuming chunk depth: from `import.meta.url`'s dir it
1077
- * probes the co-located `runtime.js` and the nested `test-runner/runtime.js`,
1092
+ * probes the co-located `<name>.js` and the nested `test-runner/<name>.js`,
1078
1093
  * then ascends one directory at a time (bounded) repeating both probes. The
1079
- * nested probe catches dist/test-runner/runtime.js from the dist/ root level no
1094
+ * nested probe catches dist/test-runner/<name>.js from the dist/ root level no
1080
1095
  * matter which depth the chunk was hoisted to (root, dist/mcp/, or a future
1081
- * relocation). The build always emits dist/test-runner/runtime.js (tsdown entry
1082
- * `'test-runner/runtime'`; guarded by scripts/check-test-runner-dist.sh).
1096
+ * relocation). The build always emits dist/test-runner/<name>.js (tsdown entry
1097
+ * `'test-runner/<name>'`; guarded by scripts/check-test-runner-dist.sh for
1098
+ * `runtime`).
1083
1099
  *
1084
1100
  * An ABSOLUTE path is returned deliberately: esbuild loads it as a literal file
1085
1101
  * read, bypassing Node module resolution entirely, so this works identically in
1086
1102
  * the npx-daemon context (its own dist tree) and the consumer-CLI context
1087
1103
  * (the mini-app's installed @ait-co/devtools dist) — neither needs the package
1088
1104
  * to be node-resolvable from the caller.
1105
+ *
1106
+ * @param moduleName - The leaf module's basename without extension, e.g.
1107
+ * `'runtime'` or `'bridge-stub'`.
1089
1108
  */
1090
- function getRuntimePath() {
1109
+ function getPageSideModulePath(moduleName) {
1091
1110
  const startDir = path$1.dirname(fileURLToPath(import.meta.url));
1092
1111
  const RELATIVE_PROBES = [
1093
- ["runtime.js"],
1094
- ["test-runner", "runtime.js"],
1095
- ["runtime.ts"],
1096
- ["test-runner", "runtime.ts"]
1112
+ [`${moduleName}.js`],
1113
+ ["test-runner", `${moduleName}.js`],
1114
+ [`${moduleName}.ts`],
1115
+ ["test-runner", `${moduleName}.ts`]
1097
1116
  ];
1098
1117
  let dir = startDir;
1099
1118
  for (let i = 0; i < 12; i++) {
@@ -1108,7 +1127,19 @@ function getRuntimePath() {
1108
1127
  if (parent === dir) break;
1109
1128
  dir = parent;
1110
1129
  }
1111
- return path$1.join(startDir, "runtime.js");
1130
+ return path$1.join(startDir, `${moduleName}.js`);
1131
+ }
1132
+ /** Absolute path to the test-runner page-side runtime (describe/it/test/expect). */
1133
+ function getRuntimePath() {
1134
+ return getPageSideModulePath("runtime");
1135
+ }
1136
+ /**
1137
+ * Absolute path to the bridge-stub interceptor (devtools#740, DT-2) —
1138
+ * `wrapSdkWithStub`/`isStubBlockingEnabled`, statically imported by the
1139
+ * `sdk-redirect` virtual module.
1140
+ */
1141
+ function getBridgeStubPath() {
1142
+ return getPageSideModulePath("bridge-stub");
1112
1143
  }
1113
1144
  /**
1114
1145
  * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
@@ -1382,6 +1413,7 @@ async function runTestFilesOverRelay(connection, files, opts) {
1382
1413
  const manualFiles = opts?.manualFiles;
1383
1414
  const manualTotal = manualFiles?.size ?? 0;
1384
1415
  let manualIndex = 0;
1416
+ const stubBlockingFiles = opts?.stubBlockingFiles;
1385
1417
  let preflightPermissions;
1386
1418
  if (files.length > 0) preflightPermissions = await runPermissionPreflight(connection);
1387
1419
  try {
@@ -1390,7 +1422,8 @@ async function runTestFilesOverRelay(connection, files, opts) {
1390
1422
  await attemptReconnect("next-file");
1391
1423
  pendingReconnectCheck = false;
1392
1424
  }
1393
- const isManual = manualFiles?.has(file) === true;
1425
+ const isStubbed = stubBlockingFiles?.has(file) === true;
1426
+ const isManual = !isStubbed && manualFiles?.has(file) === true;
1394
1427
  if (isManual) {
1395
1428
  manualIndex += 1;
1396
1429
  opts?.onManualFile?.(file, manualIndex, manualTotal);
@@ -1457,7 +1490,8 @@ async function runTestFilesOverRelay(connection, files, opts) {
1457
1490
  };
1458
1491
  }
1459
1492
  if ("error" in fileEntry.result && isRelayDisconnectMessage(fileEntry.result.error)) pendingReconnectCheck = true;
1460
- if (isManual) fileEntry.mode = "manual";
1493
+ if (isStubbed) fileEntry.mode = "stubbed";
1494
+ else if (isManual) fileEntry.mode = "manual";
1461
1495
  fileResults.push(fileEntry);
1462
1496
  }
1463
1497
  } finally {
@@ -1573,7 +1607,7 @@ function serializeRelayReport(report, meta) {
1573
1607
  ...report.preflight ? { preflight: report.preflight } : {},
1574
1608
  files: report.files.map((f) => {
1575
1609
  const file = relativise(meta.projectRoot, f.file);
1576
- const modeField = f.mode === "manual" ? { mode: "manual" } : {};
1610
+ const modeField = f.mode === "manual" || f.mode === "stubbed" ? { mode: f.mode } : {};
1577
1611
  if ("error" in f.result) return {
1578
1612
  file,
1579
1613
  error: f.result.error,
@@ -1605,11 +1639,21 @@ function serializeRelayReport(report, meta) {
1605
1639
  * `<dir>/<sdkLine>.<platform>.manual.json` artifact instead of the standard
1606
1640
  * one — the standard `<sdkLine>.<platform>.json` filename is reserved for the
1607
1641
  * regular (unattended) files only, so a manual run's presence never mutates
1608
- * what the unattended-baseline filename means. If ALL files in the run are
1609
- * regular, only the standard artifact is written (today's behavior,
1610
- * unchanged). If a run mixes both (regular files + `--manual-blocking`
1611
- * scheduled last), BOTH artifacts are written the manual one ALONGSIDE, not
1612
- * replacing, the standard one.
1642
+ * what the unattended-baseline filename means.
1643
+ *
1644
+ * Stub-blocking provenance (devtools#740, DT-2): `mode: 'stubbed'` files are
1645
+ * written to a THIRD, separate `<dir>/<sdkLine>.<platform>.stubbed.json`
1646
+ * artifact — never merged into the standard file (it is unattended, like the
1647
+ * standard file, so a naive filename split could conflate the two) and never
1648
+ * merged into `.manual.json` (it did NOT have a human present). Its body is
1649
+ * ALSO stamped `cell.bridgeStub: true` — the mandatory HYBRID-cell provenance
1650
+ * from the issue: a stubbed result must never be silently mixed into the
1651
+ * real-device baseline report.
1652
+ *
1653
+ * If ALL files in the run are regular, only the standard artifact is written
1654
+ * (today's behavior, unchanged). Any combination of the three modes present
1655
+ * in a single run writes ONLY the corresponding artifacts — each is additive,
1656
+ * never replacing another.
1613
1657
  *
1614
1658
  * SECRET-HANDLING: the written body contains no relay/secret fields (the schema
1615
1659
  * has none). `dir`/`projectRoot` are local filesystem paths, never logged here.
@@ -1618,14 +1662,16 @@ function serializeRelayReport(report, meta) {
1618
1662
  * @param dir - Output directory (created recursively if missing).
1619
1663
  * @param meta - Cell axes + projectRoot.
1620
1664
  * @returns The absolute path(s) written, in order: standard first (if any
1621
- * regular files ran), then manual (if any manual files ran). At least one
1622
- * path is always returned when `report.files` is non-empty.
1665
+ * regular files ran), then manual (if any manual files ran), then stubbed
1666
+ * (if any stubbed files ran). At least one path is always returned when
1667
+ * `report.files` is non-empty.
1623
1668
  */
1624
1669
  async function writeReportArtifact(report, dir, meta) {
1625
1670
  const serialised = serializeRelayReport(report, meta);
1626
1671
  await mkdir(dir, { recursive: true });
1627
- const regularFiles = serialised.files.filter((f) => f.mode !== "manual");
1672
+ const regularFiles = serialised.files.filter((f) => f.mode === void 0);
1628
1673
  const manualFiles = serialised.files.filter((f) => f.mode === "manual");
1674
+ const stubbedFiles = serialised.files.filter((f) => f.mode === "stubbed");
1629
1675
  const written = [];
1630
1676
  if (regularFiles.length > 0 || serialised.files.length === 0) {
1631
1677
  const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);
@@ -1643,6 +1689,18 @@ async function writeReportArtifact(report, dir, meta) {
1643
1689
  }, null, 2)}\n`, "utf8");
1644
1690
  written.push(manualOutFile);
1645
1691
  }
1692
+ if (stubbedFiles.length > 0) {
1693
+ const stubbedOutFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.stubbed.json`);
1694
+ await writeFile(stubbedOutFile, `${JSON.stringify({
1695
+ ...serialised,
1696
+ cell: {
1697
+ ...serialised.cell,
1698
+ bridgeStub: true
1699
+ },
1700
+ files: stubbedFiles
1701
+ }, null, 2)}\n`, "utf8");
1702
+ written.push(stubbedOutFile);
1703
+ }
1646
1704
  return written;
1647
1705
  }
1648
1706
  /**
@@ -1686,6 +1744,76 @@ async function writeCaptureArtifacts(captures, dir, cell) {
1686
1744
  return written;
1687
1745
  }
1688
1746
  //#endregion
1747
+ //#region src/test-runner/teardown.ts
1748
+ /**
1749
+ * Runs `steps` in strict order, each bounded by `perStepTimeoutMs` so a
1750
+ * single hung `close()` cannot block the rest — a later step (e.g. "close
1751
+ * the QR HTTP server") still runs even if an earlier one (e.g. "flip the
1752
+ * on-phone badge over CDP") times out.
1753
+ *
1754
+ * Order is load-bearing: `relay-factory.ts`'s existing `close()` already
1755
+ * encodes "badge over still-open channel, THEN close that channel" — this
1756
+ * function does not reorder or parallelize, it just bounds+reports whatever
1757
+ * sequence the caller provides.
1758
+ */
1759
+ async function runTeardownSteps(steps, options = {}) {
1760
+ const { perStepTimeoutMs = 5e3, setTimeoutFn = setTimeout, clearTimeoutFn = clearTimeout } = options;
1761
+ const results = [];
1762
+ for (const step of steps) results.push(await runOneStep(step, perStepTimeoutMs, setTimeoutFn, clearTimeoutFn));
1763
+ return results;
1764
+ }
1765
+ async function runOneStep(step, perStepTimeoutMs, setTimeoutFn, clearTimeoutFn) {
1766
+ let timeoutHandle;
1767
+ const timeoutPromise = new Promise((resolve) => {
1768
+ timeoutHandle = setTimeoutFn(() => resolve("timeout"), perStepTimeoutMs);
1769
+ });
1770
+ try {
1771
+ const outcome = await Promise.race([Promise.resolve().then(() => step.close()).then(() => "ok"), timeoutPromise]);
1772
+ if (timeoutHandle !== void 0) clearTimeoutFn(timeoutHandle);
1773
+ return {
1774
+ name: step.name,
1775
+ status: outcome
1776
+ };
1777
+ } catch (e) {
1778
+ if (timeoutHandle !== void 0) clearTimeoutFn(timeoutHandle);
1779
+ return {
1780
+ name: step.name,
1781
+ status: "error",
1782
+ error: e instanceof Error ? e.message : String(e)
1783
+ };
1784
+ }
1785
+ }
1786
+ /**
1787
+ * Arms a grace-period timer that force-exits the process if it has not
1788
+ * already exited on its own by the time the timer fires. The timer is
1789
+ * deliberately NOT `.unref()`'d — its entire purpose is to hold the event
1790
+ * loop open long enough to fire if nothing else does, so unref'ing it would
1791
+ * defeat the backstop. `disarm()` clears it — the CLI calls `disarm()`
1792
+ * immediately once `main()`'s own teardown (Step 6, cli.ts) has finished,
1793
+ * so on the happy path the timer never fires (Node drains the loop and
1794
+ * exits naturally at whatever `process.exitCode` was already set to).
1795
+ *
1796
+ * SECRET-HANDLING: this function never touches stdout/stderr content — the
1797
+ * caller (cli.ts) is responsible for flushing any final output before the
1798
+ * backstop's `exitFn` runs.
1799
+ */
1800
+ function armExitBackstop(options) {
1801
+ const { graceMs = 3e3, exitCode, setTimeoutFn = setTimeout, exitFn = (code) => process.exit(code) } = options;
1802
+ let fired = false;
1803
+ const handle = setTimeoutFn(() => {
1804
+ fired = true;
1805
+ exitFn(exitCode);
1806
+ }, graceMs);
1807
+ return {
1808
+ disarm() {
1809
+ clearTimeout(handle);
1810
+ },
1811
+ get fired() {
1812
+ return fired;
1813
+ }
1814
+ };
1815
+ }
1816
+ //#endregion
1689
1817
  //#region src/test-runner/cli.ts
1690
1818
  /**
1691
1819
  * `devtools-test` CLI.
@@ -1752,6 +1880,24 @@ OPTIONS
1752
1880
  each manual file's report entry is stamped
1753
1881
  mode: 'manual' — never diff a manual run against an
1754
1882
  unattended baseline as if they were equivalent.
1883
+ --stub-blocking Run manual-tagged test files (*.manual.ait.test.ts)
1884
+ UNATTENDED (devtools#740, DT-2) by intercepting a
1885
+ fixed allowlist of blocking-UI SDK calls (ads
1886
+ show*, openPermissionDialog/requestPermission,
1887
+ saveBase64Data) in the page and answering them from
1888
+ fixtures captured by a real --manual-blocking run,
1889
+ instead of forwarding them to native UI. Implies
1890
+ --manual-blocking (manual files are included in the
1891
+ run); no human presence or QR-dashboard prompt is
1892
+ needed for them. HYBRID cell, not pure env3 — every
1893
+ other SDK call in the same run still hits the real
1894
+ native bridge. With --report-dir, files that ran
1895
+ under the stub are written to a SEPARATE
1896
+ <sdkLine>.<platform>.stubbed.json artifact (never
1897
+ merged into the standard or .manual.json report) and
1898
+ the report body is stamped cell.bridgeStub: true —
1899
+ never diff a stubbed run against a real device
1900
+ baseline (manual or unattended) as if equivalent.
1755
1901
  --help, -h Show this help message
1756
1902
 
1757
1903
  DESCRIPTION
@@ -1778,6 +1924,15 @@ EXAMPLE
1778
1924
 
1779
1925
  `.trimStart();
1780
1926
  /**
1927
+ * Grace period (ms) for the exit backstop armed around Step 6 teardown
1928
+ * (devtools#755). See `teardown.ts`'s module doc for the root-cause writeup
1929
+ * — the two upstream `http.Server#close()` hangs are fixed at the source, so
1930
+ * this backstop is a last-mile safety net that should never fire in
1931
+ * practice. Exported so tests can assert the backstop is armed with the
1932
+ * expected value without hardcoding the literal twice.
1933
+ */
1934
+ const EXIT_BACKSTOP_GRACE_MS = 3e3;
1935
+ /**
1781
1936
  * Parses --timeout and --attach-timeout raw string values into the two
1782
1937
  * distinct clocks.
1783
1938
  *
@@ -1921,7 +2076,8 @@ async function main(argv = process.argv.slice(2)) {
1921
2076
  "no-qr-stdout": { type: "boolean" },
1922
2077
  headless: { type: "boolean" },
1923
2078
  "project-root": { type: "string" },
1924
- "manual-blocking": { type: "boolean" }
2079
+ "manual-blocking": { type: "boolean" },
2080
+ "stub-blocking": { type: "boolean" }
1925
2081
  },
1926
2082
  allowPositionals: true
1927
2083
  });
@@ -1958,7 +2114,8 @@ async function main(argv = process.argv.slice(2)) {
1958
2114
  return;
1959
2115
  }
1960
2116
  const suppressQr = shouldSuppressQr(vals["no-qr-stdout"] === true);
1961
- const manualBlocking = vals["manual-blocking"] === true;
2117
+ const stubBlocking = vals["stub-blocking"] === true;
2118
+ const manualBlocking = vals["manual-blocking"] === true || stubBlocking;
1962
2119
  const cellSdkLine = typeof vals["cell-sdk-line"] === "string" ? vals["cell-sdk-line"] : void 0;
1963
2120
  const cellPlatform = typeof vals["cell-platform"] === "string" ? vals["cell-platform"] : process.env.AIT_CELL_PLATFORM;
1964
2121
  const hasCell = cellSdkLine !== void 0 || cellPlatform !== void 0;
@@ -1984,6 +2141,7 @@ async function main(argv = process.argv.slice(2)) {
1984
2141
  const manualFileSet = new Set(manual);
1985
2142
  process.stderr.write(manualBlocking && manual.length > 0 ? `devtools-test: found ${regular.length} regular + ${manual.length} manual (${MANUAL_TEST_SUFFIX}) test file(s)\n` : `devtools-test: found ${files.length} test file(s)\n`);
1986
2143
  if (hasCell) process.stderr.write(`devtools-test: injecting __AIT_CELL__ = ${JSON.stringify(cell)}\n`);
2144
+ if (stubBlocking) process.stderr.write("devtools-test: --stub-blocking enabled — blocking-UI calls in manual-tagged files will be answered from fixtures (devtools#740), not forwarded to native UI\n");
1987
2145
  const factory = createRelayConnectionFactory({
1988
2146
  schemeUrl,
1989
2147
  projectRoot,
@@ -1991,6 +2149,7 @@ async function main(argv = process.argv.slice(2)) {
1991
2149
  ...dashboardPort !== void 0 ? { dashboardPort } : {},
1992
2150
  headless,
1993
2151
  cell: hasCell ? cell : void 0,
2152
+ stubBlocking,
1994
2153
  onQrContent: (chunks) => {
1995
2154
  if (suppressQr) {
1996
2155
  process.stdout.write("QR suppressed (non-interactive)\n");
@@ -2015,6 +2174,7 @@ async function main(argv = process.argv.slice(2)) {
2015
2174
  printSummary: true,
2016
2175
  collectCaptures: reportDir !== void 0,
2017
2176
  manualFiles: manualBlocking && manualFileSet.size > 0 ? manualFileSet : void 0,
2177
+ stubBlockingFiles: stubBlocking && manualFileSet.size > 0 ? manualFileSet : void 0,
2018
2178
  onManualFile: (file, index, total) => {
2019
2179
  const name = basename(file);
2020
2180
  process.stdout.write(`수동 단계: ${name} — 폰에서 네이티브 시트가 뜨면 안내에 따라 조작하세요 (${index}/${total})\n`);
@@ -2041,7 +2201,15 @@ async function main(argv = process.argv.slice(2)) {
2041
2201
  exitCode = report.totals.failed > 0 ? 1 : 0;
2042
2202
  } finally {
2043
2203
  factory.onSessionPhase?.("complete");
2044
- await factory.close(connection);
2204
+ const backstop = armExitBackstop({
2205
+ graceMs: EXIT_BACKSTOP_GRACE_MS,
2206
+ exitCode
2207
+ });
2208
+ await runTeardownSteps([{
2209
+ name: "factory.close",
2210
+ close: () => factory.close(connection)
2211
+ }]);
2212
+ backstop.disarm();
2045
2213
  process.exitCode = exitCode;
2046
2214
  }
2047
2215
  }