@ait-co/devtools 0.1.136 → 0.1.138

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 (126) hide show
  1. package/dist/{attach-orchestrator-Cjdhir2U.js → attach-orchestrator-6kz_ZdWL.js} +5 -3
  2. package/dist/{attach-orchestrator-Cjdhir2U.js.map → attach-orchestrator-6kz_ZdWL.js.map} +1 -1
  3. package/dist/{attach-orchestrator-CE0S09YU.js → attach-orchestrator-BEiz3wAx.js} +5 -3
  4. package/dist/{attach-orchestrator-CMoDuG2A.js.map → attach-orchestrator-BEiz3wAx.js.map} +1 -1
  5. package/dist/{attach-orchestrator-CMoDuG2A.js → attach-orchestrator-BG8TGJOZ.js} +5 -3
  6. package/dist/{attach-orchestrator-CE0S09YU.js.map → attach-orchestrator-BG8TGJOZ.js.map} +1 -1
  7. package/dist/{bundle-BKqyhEK9.d.ts → bundle-C796JIwG.d.ts} +65 -2
  8. package/dist/bundle-C796JIwG.d.ts.map +1 -0
  9. package/dist/{capture-ltuV0gZa.d.ts → capture-DsP525OZ.d.ts} +1 -1
  10. package/dist/{capture-ltuV0gZa.d.ts.map → capture-DsP525OZ.d.ts.map} +1 -1
  11. package/dist/{cdp-connection-BYE9meXe.d.ts → cdp-connection-rP1WdnH5.d.ts} +1 -1
  12. package/dist/{cdp-connection-BYE9meXe.d.ts.map → cdp-connection-rP1WdnH5.d.ts.map} +1 -1
  13. package/dist/{cell-D1y4shoV.js → cell-BMlFh08P.js} +2 -2
  14. package/dist/cell-BMlFh08P.js.map +1 -0
  15. package/dist/{cell-DHA578lX.js → cell-Dtgtusog.js} +90 -16
  16. package/dist/cell-Dtgtusog.js.map +1 -0
  17. package/dist/{cell-BIdSQb9T.js → cell-KsRXFdDs.js} +122 -16
  18. package/dist/cell-KsRXFdDs.js.map +1 -0
  19. package/dist/{debug-server-BZtH2Z5b.js → debug-server-6c22qSWa.js} +150 -9
  20. package/dist/debug-server-6c22qSWa.js.map +1 -0
  21. package/dist/{debug-server-DApo0o3t.js → debug-server-BQHbaKPT.js} +152 -11
  22. package/dist/debug-server-BQHbaKPT.js.map +1 -0
  23. package/dist/{debug-server-D2wpPK3_.js → debug-server-DWat7d-9.js} +82 -6
  24. package/dist/debug-server-DWat7d-9.js.map +1 -0
  25. package/dist/devtools-opener-3Drge_RJ.js +75 -0
  26. package/dist/devtools-opener-3Drge_RJ.js.map +1 -0
  27. package/dist/devtools-opener-CJpEsXXQ.js +76 -0
  28. package/dist/devtools-opener-CJpEsXXQ.js.map +1 -0
  29. package/dist/devtools-opener-CxtryS8c.js +75 -0
  30. package/dist/devtools-opener-CxtryS8c.js.map +1 -0
  31. package/dist/in-app/auto.js +59 -12
  32. package/dist/in-app/auto.js.map +1 -1
  33. package/dist/in-app/index.d.ts +21 -2
  34. package/dist/in-app/index.d.ts.map +1 -1
  35. package/dist/in-app/index.js +60 -13
  36. package/dist/in-app/index.js.map +1 -1
  37. package/dist/mcp/cli.js +363 -73
  38. package/dist/mcp/cli.js.map +1 -1
  39. package/dist/mcp/server.js +1 -1
  40. package/dist/mock/index.d.ts +64 -1
  41. package/dist/mock/index.d.ts.map +1 -1
  42. package/dist/mock/index.js +221 -14
  43. package/dist/mock/index.js.map +1 -1
  44. package/dist/panel/index.js +178 -23
  45. package/dist/panel/index.js.map +1 -1
  46. package/dist/{pool-C6TgrcyW.d.ts → pool-CrP5CPvU.d.ts} +3 -3
  47. package/dist/{pool-C6TgrcyW.d.ts.map → pool-CrP5CPvU.d.ts.map} +1 -1
  48. package/dist/{qr-http-server-ZkT6F7is.js → qr-http-server-D1hpoyBF.js} +1 -1
  49. package/dist/{qr-http-server-ZkT6F7is.js.map → qr-http-server-D1hpoyBF.js.map} +1 -1
  50. package/dist/qr-http-server-D4rGz8M7.js +1640 -0
  51. package/dist/qr-http-server-D4rGz8M7.js.map +1 -0
  52. package/dist/{qr-http-server-C7q8Gn04.js → qr-http-server-DIjCV7h_.js} +1 -1
  53. package/dist/{qr-http-server-C7q8Gn04.js.map → qr-http-server-DIjCV7h_.js.map} +1 -1
  54. package/dist/{relay-factory-DnuTH4v-.js → relay-factory-C1X3G3WY.js} +72 -15
  55. package/dist/relay-factory-C1X3G3WY.js.map +1 -0
  56. package/dist/{relay-secret-store-CLEGyHou.js → relay-secret-store-Bmyleu0A.js} +1 -1
  57. package/dist/{relay-secret-store-CLEGyHou.js.map → relay-secret-store-Bmyleu0A.js.map} +1 -1
  58. package/dist/{relay-secret-store-DhzAnnj-.js → relay-secret-store-CQenfcSL.js} +2 -2
  59. package/dist/{relay-secret-store-DhzAnnj-.js.map → relay-secret-store-CQenfcSL.js.map} +1 -1
  60. package/dist/{relay-secret-store-BcVrWwTq.js → relay-secret-store-DKxs7zwq.js} +1 -1
  61. package/dist/{relay-secret-store-DGduVJhs.js.map → relay-secret-store-DKxs7zwq.js.map} +1 -1
  62. package/dist/{relay-secret-store-DGduVJhs.js → relay-secret-store-WJ8EGkIl.js} +1 -1
  63. package/dist/{relay-secret-store-BcVrWwTq.js.map → relay-secret-store-WJ8EGkIl.js.map} +1 -1
  64. package/dist/{relay-url-store-CKW8RQzf.js → relay-url-store-BR2XodiO.js} +2 -2
  65. package/dist/{relay-url-store-CKW8RQzf.js.map → relay-url-store-BR2XodiO.js.map} +1 -1
  66. package/dist/{relay-url-store-CdA58fgw.js → relay-url-store-CH63fVCm.js} +2 -2
  67. package/dist/{relay-url-store-B0X8TsGr.js.map → relay-url-store-CH63fVCm.js.map} +1 -1
  68. package/dist/{relay-url-store-CwKT7i04.js → relay-url-store-DaY1QPes.js} +2 -2
  69. package/dist/{relay-url-store-CwKT7i04.js.map → relay-url-store-DaY1QPes.js.map} +1 -1
  70. package/dist/{relay-url-store-B0X8TsGr.js → relay-url-store-xmUuTjXA.js} +2 -2
  71. package/dist/{relay-url-store-CdA58fgw.js.map → relay-url-store-xmUuTjXA.js.map} +1 -1
  72. package/dist/{relay-worker-UK0nBfDX.js → relay-worker-BUXI0K0b.js} +13 -6
  73. package/dist/{relay-worker-UK0nBfDX.js.map → relay-worker-BUXI0K0b.js.map} +1 -1
  74. package/dist/{relay-worker-Dw3aIzIK.d.ts → relay-worker-DcyboK43.d.ts} +70 -11
  75. package/dist/relay-worker-DcyboK43.d.ts.map +1 -0
  76. package/dist/{runtime-DfHHZms2.d.ts → runtime-BiigOuvb.d.ts} +30 -3
  77. package/dist/runtime-BiigOuvb.d.ts.map +1 -0
  78. package/dist/test-runner/bin.js +481 -62
  79. package/dist/test-runner/bin.js.map +1 -1
  80. package/dist/test-runner/bridge-stub.d.ts +125 -0
  81. package/dist/test-runner/bridge-stub.d.ts.map +1 -0
  82. package/dist/test-runner/bridge-stub.js +92 -0
  83. package/dist/test-runner/bridge-stub.js.map +1 -0
  84. package/dist/test-runner/bundle.d.ts +2 -2
  85. package/dist/test-runner/bundle.js +107 -24
  86. package/dist/test-runner/bundle.js.map +1 -1
  87. package/dist/test-runner/capture.d.ts +1 -1
  88. package/dist/test-runner/config.d.ts +68 -5
  89. package/dist/test-runner/config.d.ts.map +1 -1
  90. package/dist/test-runner/config.js +1 -1
  91. package/dist/test-runner/method-pace.d.ts +82 -0
  92. package/dist/test-runner/method-pace.d.ts.map +1 -0
  93. package/dist/test-runner/method-pace.js +120 -0
  94. package/dist/test-runner/method-pace.js.map +1 -0
  95. package/dist/test-runner/pool.d.ts +1 -1
  96. package/dist/test-runner/pool.js +1 -1
  97. package/dist/test-runner/relay-factory.d.ts +67 -4
  98. package/dist/test-runner/relay-factory.d.ts.map +1 -1
  99. package/dist/test-runner/relay-factory.js +71 -14
  100. package/dist/test-runner/relay-factory.js.map +1 -1
  101. package/dist/test-runner/relay-worker.d.ts +1 -1
  102. package/dist/test-runner/relay-worker.js +1 -1
  103. package/dist/test-runner/report.d.ts +43 -17
  104. package/dist/test-runner/report.d.ts.map +1 -1
  105. package/dist/test-runner/report.js +33 -9
  106. package/dist/test-runner/report.js.map +1 -1
  107. package/dist/test-runner/rpc.d.ts +2 -2
  108. package/dist/test-runner/runtime.d.ts +2 -2
  109. package/dist/test-runner/runtime.js +83 -6
  110. package/dist/test-runner/runtime.js.map +1 -1
  111. package/dist/test-runner/task-graph.d.ts +1 -1
  112. package/dist/throttle-DKKzX1qC.js +59 -0
  113. package/dist/throttle-DKKzX1qC.js.map +1 -0
  114. package/dist/totp-Dwft0Kz7.js +3 -0
  115. package/package.json +1 -1
  116. package/dist/bundle-BKqyhEK9.d.ts.map +0 -1
  117. package/dist/cell-BIdSQb9T.js.map +0 -1
  118. package/dist/cell-D1y4shoV.js.map +0 -1
  119. package/dist/cell-DHA578lX.js.map +0 -1
  120. package/dist/debug-server-BZtH2Z5b.js.map +0 -1
  121. package/dist/debug-server-D2wpPK3_.js.map +0 -1
  122. package/dist/debug-server-DApo0o3t.js.map +0 -1
  123. package/dist/relay-factory-DnuTH4v-.js.map +0 -1
  124. package/dist/relay-worker-Dw3aIzIK.d.ts.map +0 -1
  125. package/dist/runtime-DfHHZms2.d.ts.map +0 -1
  126. package/dist/totp-D1pulXLa.js +0 -3
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { m as logInfo } from "../attach-orchestrator-CE0S09YU.js";
2
+ import { m as logInfo } from "../attach-orchestrator-BEiz3wAx.js";
3
3
  import { r as generateTotp } from "../totp-CZLLKfOC.js";
4
- import { n as runPermissionPreflight } from "../cell-BIdSQb9T.js";
4
+ import { r as runPermissionPreflight, t as PERMISSION_PREFLIGHT_TIMEOUT_MS } from "../cell-KsRXFdDs.js";
5
5
  import * as path$1 from "node:path";
6
6
  import path, { basename, isAbsolute, resolve } from "node:path";
7
7
  import { parseArgs } from "node:util";
@@ -93,12 +93,13 @@ function partitionManualTests(files) {
93
93
  //#endregion
94
94
  //#region src/test-runner/relay-factory.ts
95
95
  /**
96
- * Builds a {@link RelayConnectionFactory} that opens a standalone env3 relay
97
- * connection.
96
+ * Builds a {@link RelayConnectionFactory} that opens a standalone relay
97
+ * connection for env 3 (intoss-private scheme, default) or env 2 (AITC Sandbox
98
+ * PWA launcher deep-link, when `opts.attachLauncher` is set — devtools#774).
98
99
  *
99
100
  * `open()` performs the full attach lifecycle and BLOCKS while a human scans
100
- * the rendered QR with their phone — there is no way around the manual scan
101
- * for env3. By default the wait is UNBOUNDED (`opts.timeoutMs` omitted): the
101
+ * the rendered QR with their phone — there is no way around the manual scan.
102
+ * By default the wait is UNBOUNDED (`opts.timeoutMs` omitted): the
102
103
  * runner stays up until the user stops it (Ctrl-C/SIGTERM), since QR-scan is
103
104
  * a human-paced action with no sound default bound (devtools#735). Passing an
104
105
  * explicit `timeoutMs` opts into the old bounded behavior (CI/headless
@@ -115,14 +116,17 @@ function createRelayConnectionFactory(opts) {
115
116
  const headless = opts.headless === true;
116
117
  let family;
117
118
  let qrServer;
119
+ let attachWatcher;
118
120
  let phase = "active";
119
121
  let manualPrompt = null;
120
122
  return {
121
123
  async open() {
122
- const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("../attach-orchestrator-CE0S09YU.js").then((n) => n.i);
123
- const { injectDebugIndicator, injectGlobals } = await import("../cell-BIdSQb9T.js").then((n) => n.t);
124
- const { loadRelaySecretReadOnly } = await import("../relay-secret-store-CLEGyHou.js");
125
- const { bootRelayFamily, buildRelayVerifyAuth } = await import("../debug-server-D2wpPK3_.js");
124
+ const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("../attach-orchestrator-BEiz3wAx.js").then((n) => n.i);
125
+ const { injectDebugIndicator, injectGlobals } = await import("../cell-KsRXFdDs.js").then((n) => n.n);
126
+ const { loadRelaySecretReadOnly } = await import("../relay-secret-store-Bmyleu0A.js");
127
+ const { bootRelayFamily, buildRelayVerifyAuth, startAttachWatcher } = await import("../debug-server-DWat7d-9.js");
128
+ const { buildChiiInspectorUrl } = await import("../devtools-opener-CJpEsXXQ.js");
129
+ const { generateTotp } = await import("../totp-CZLLKfOC.js").then((n) => n.i);
126
130
  await loadRelaySecretReadOnly({ projectRoot });
127
131
  let resolveTunnelUp;
128
132
  const tunnelReady = new Promise((resolve) => {
@@ -152,29 +156,73 @@ function createRelayConnectionFactory(opts) {
152
156
  canOpenBrowser: () => !headless
153
157
  };
154
158
  try {
155
- const { startQrHttpServer } = await import("../qr-http-server-C7q8Gn04.js");
159
+ const { startQrHttpServer } = await import("../qr-http-server-DIjCV7h_.js");
156
160
  const getDashboardState = () => ({
157
161
  tunnel: attachDeps.getTunnelStatus(),
158
- pages: null,
162
+ pages: booted.connection.listTargets().map((t) => ({
163
+ id: t.id,
164
+ url: t.url
165
+ })),
159
166
  attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,
160
- mode: "relay-dev",
167
+ mode: opts.attachLauncher === true ? "relay-mobile" : "relay-dev",
161
168
  phase,
162
169
  manualPrompt
163
170
  });
164
- qrServer = await startQrHttpServer(getDashboardState, { dashboardPort: opts.dashboardPort });
171
+ const getDirectInspectorUrl = () => {
172
+ if (!booted.relayHttpUrl) return {
173
+ ok: false,
174
+ reason: "relayDown"
175
+ };
176
+ const targets = booted.connection.listTargets();
177
+ if (targets.length === 0) return {
178
+ ok: false,
179
+ reason: "noTarget"
180
+ };
181
+ const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;
182
+ if (!totpSecret) return {
183
+ ok: false,
184
+ reason: "totpUnavailable"
185
+ };
186
+ const url = buildChiiInspectorUrl(booted.relayHttpUrl, targets[0].id, () => generateTotp(totpSecret, Date.now()));
187
+ if (url === null) return {
188
+ ok: false,
189
+ reason: "totpUnavailable"
190
+ };
191
+ return {
192
+ ok: true,
193
+ url
194
+ };
195
+ };
196
+ qrServer = await startQrHttpServer(getDashboardState, {
197
+ dashboardPort: opts.dashboardPort,
198
+ getDirectInspectorUrl
199
+ });
165
200
  attachDeps.qrHttpServer = qrServer;
166
201
  attachDeps.onAttachUrlBuilt = (parts) => {
167
202
  lastAttachParts = parts;
168
203
  qrServer?.notifyStateChange();
169
204
  };
205
+ attachWatcher = startAttachWatcher(booted.connection, void 0, 1e3, () => qrServer?.notifyStateChange(), () => qrServer?.notifyStateChange());
170
206
  process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\n`);
171
207
  } catch {}
172
208
  const TUNNEL_BOOT_TIMEOUT_MS = 15e3;
173
209
  await Promise.race([tunnelReady, new Promise((resolve) => setTimeout(resolve, TUNNEL_BOOT_TIMEOUT_MS))]);
174
- const prep = await prepareAttach(attachDeps, "relay-dev", { scheme_url: opts.schemeUrl }, booted.connection);
210
+ let prep;
211
+ if (opts.attachLauncher === true) {
212
+ const priorTunnelBaseUrl = process.env.AIT_TUNNEL_BASE_URL;
213
+ process.env.AIT_TUNNEL_BASE_URL = opts.appUrl ?? "";
214
+ try {
215
+ prep = await prepareAttach(attachDeps, "relay-mobile", {}, booted.connection);
216
+ } finally {
217
+ if (priorTunnelBaseUrl === void 0) delete process.env.AIT_TUNNEL_BASE_URL;
218
+ else process.env.AIT_TUNNEL_BASE_URL = priorTunnelBaseUrl;
219
+ }
220
+ } else prep = await prepareAttach(attachDeps, "relay-dev", { scheme_url: opts.schemeUrl }, booted.connection);
175
221
  if (!prep.ok) {
176
222
  booted.stop();
177
223
  family = void 0;
224
+ attachWatcher?.stop();
225
+ attachWatcher = void 0;
178
226
  await qrServer?.close();
179
227
  qrServer = void 0;
180
228
  throw new Error("createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up");
@@ -183,6 +231,8 @@ function createRelayConnectionFactory(opts) {
183
231
  if (waitResult.isError) {
184
232
  booted.stop();
185
233
  family = void 0;
234
+ attachWatcher?.stop();
235
+ attachWatcher = void 0;
186
236
  await qrServer?.close();
187
237
  qrServer = void 0;
188
238
  const timeoutSec = Math.round(timeoutMs / 1e3);
@@ -204,12 +254,17 @@ function createRelayConnectionFactory(opts) {
204
254
  if (lastEnableError !== void 0) {
205
255
  booted.stop();
206
256
  family = void 0;
257
+ attachWatcher?.stop();
258
+ attachWatcher = void 0;
207
259
  await qrServer?.close();
208
260
  qrServer = void 0;
209
261
  throw new Error(`createRelayConnectionFactory: page did not become ready after ${PAGE_READY_RETRIES} attempts (${Math.round(PAGE_READY_RETRIES * PAGE_READY_RETRY_DELAY_MS / 1e3)}s) — the mini-app page may have disconnected before enableDomains() could open the CDP websocket`);
210
262
  }
211
263
  await injectDebugIndicator(booted.connection);
212
264
  if (opts.cell !== void 0) await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });
265
+ if (opts.stubBlocking === true) await injectGlobals(booted.connection, { __AIT_STUB_BLOCKING__: true });
266
+ if (opts.paceMs !== void 0 && opts.paceMs > 0) await injectGlobals(booted.connection, { __AIT_PACE_MS__: opts.paceMs });
267
+ if (opts.paceMethodMs !== void 0 && opts.paceMethodMs > 0) await injectGlobals(booted.connection, { __AIT_PACE_METHOD_MS__: opts.paceMethodMs });
213
268
  return booted.connection;
214
269
  },
215
270
  onSessionPhase(next) {
@@ -226,11 +281,13 @@ function createRelayConnectionFactory(opts) {
226
281
  qrServer?.notifyStateChange();
227
282
  }
228
283
  try {
229
- const { injectDebugIndicator } = await import("../cell-BIdSQb9T.js").then((n) => n.t);
284
+ const { injectDebugIndicator } = await import("../cell-KsRXFdDs.js").then((n) => n.n);
230
285
  await injectDebugIndicator(connection, { state: "disconnected" });
231
286
  } catch {}
232
287
  family?.stop();
233
288
  family = void 0;
289
+ attachWatcher?.stop();
290
+ attachWatcher = void 0;
234
291
  await qrServer?.close();
235
292
  qrServer = void 0;
236
293
  }
@@ -906,7 +963,73 @@ const SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\]\\]
906
963
  *
907
964
  * If `window.__sdk` is absent (non-dog-food build), every access throws a
908
965
  * descriptive error rather than returning `undefined` silently.
966
+ *
967
+ * Bridge-stub interception (devtools#740, DT-2): the virtual module runs the
968
+ * resolved `window.__sdk` object through `wrapSdkWithStub` before exporting
969
+ * it. `wrapSdkWithStub` is a pass-through (returns the same object,
970
+ * unwrapped) unless `isStubBlockingEnabled()` reads `true` off
971
+ * `globalThis.__AIT_STUB_BLOCKING__` — an opt-in flag injected the same way
972
+ * `__AIT_CELL__` is (`cell.ts#injectGlobals`, wired from the CLI's
973
+ * `--stub-blocking` flag). Default off => zero behavior change: every existing
974
+ * (non-stub) test run resolves the exact same `window.__sdk` reference it did
975
+ * before this plugin learned about stubbing.
976
+ *
977
+ * Per-method pacing (devtools#769): BEFORE the stub wrap, `window.__sdk` is
978
+ * run through `wrapWithMethodPacing`, which enforces a minimum interval
979
+ * between calls to the SAME named SDK function (`getPaceMethodMs()` reads the
980
+ * `__AIT_PACE_METHOD_MS__` page global — see `method-pace.ts`). Composition
981
+ * order is pacing INSIDE the stub (`wrapSdkWithStub(wrapWithMethodPacing(sdk,
982
+ * gap), enabled)`): a stubbed name resolves instantly from
983
+ * `bridge-stub.ts`'s fixtures without ever reaching the paced wrapper, and
984
+ * only calls that fall through to the real bridge — the ones that can
985
+ * actually trigger `APP_BRIDGE_THROTTLED` — pay the pacing cost. `gapMs <= 0`
986
+ * (default, no `--pace-method` / non-2.x cell) is a no-op fast path — see
987
+ * `method-pace.ts#wrapWithMethodPacing`'s doc for the full contract.
909
988
  */
989
+ /**
990
+ * Builds the virtual CommonJS-style module contents the `sdk-redirect`
991
+ * esbuild plugin loads for every `@apps-in-toss/web-framework` import.
992
+ *
993
+ * Pulled out to a pure, exported function (rather than inlined at the
994
+ * `build.onLoad` call site) so unit tests can assert the exact composition
995
+ * order (pacing wraps the raw SDK; the stub wraps the paced result) as a
996
+ * string-level contract, independent of esbuild — esbuild cannot run inside
997
+ * this repo's jsdom vitest environment (see `bundleTestFile`'s module doc's
998
+ * lazy-import note), so a real bundling round-trip is out of unit-test reach
999
+ * here; `wrapSdkWithStub`/`wrapWithMethodPacing`'s own composed-call behavior
1000
+ * is covered directly (see `bundle.test.ts`).
1001
+ *
1002
+ * Generates a virtual CommonJS-style module so that esbuild does NOT perform
1003
+ * strict named-export matching. When `format:'iife'` bundles a CJS module, it
1004
+ * wraps it with its own `__toCommonJS` helper and satisfies named imports via
1005
+ * property access on the `module.exports` object — which is our Proxy. This
1006
+ * means `import { getPlatformOS } from '...'` becomes
1007
+ * `__proxy.getPlatformOS` at runtime, which correctly reads from
1008
+ * `window.__sdk`.
1009
+ *
1010
+ * The bridge-stub and method-pace imports are STATIC imports of the real
1011
+ * `bridge-stub.ts`/`method-pace.ts` modules (not inlined JS) so esbuild
1012
+ * bundles each once and the fixture registry / pacing registry have a single
1013
+ * source of truth shared with their own unit tests.
1014
+ *
1015
+ * Exported for unit testing.
1016
+ */
1017
+ function buildSdkRedirectModuleContents() {
1018
+ return `
1019
+ import { wrapSdkWithStub, isStubBlockingEnabled } from ${JSON.stringify(getBridgeStubPath())};
1020
+ import { wrapWithMethodPacing, getPaceMethodMs } from ${JSON.stringify(getMethodPacePath())};
1021
+ var __rawSdk = (typeof window !== 'undefined' && window.__sdk)
1022
+ ? window.__sdk
1023
+ : new Proxy({}, {
1024
+ get: function(_t, p) {
1025
+ throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
1026
+ }
1027
+ });
1028
+ var __pacedSdk = wrapWithMethodPacing(__rawSdk, getPaceMethodMs());
1029
+ var __proxy = wrapSdkWithStub(__pacedSdk, isStubBlockingEnabled());
1030
+ module.exports = __proxy;
1031
+ `;
1032
+ }
910
1033
  function sdkRedirectPlugin() {
911
1034
  return {
912
1035
  name: "sdk-redirect",
@@ -919,17 +1042,9 @@ function sdkRedirectPlugin() {
919
1042
  filter: /.*/,
920
1043
  namespace: "sdk-redirect"
921
1044
  }, () => ({
922
- contents: `
923
- var __proxy = (typeof window !== 'undefined' && window.__sdk)
924
- ? window.__sdk
925
- : new Proxy({}, {
926
- get: function(_t, p) {
927
- throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
928
- }
929
- });
930
- module.exports = __proxy;
931
- `,
932
- loader: "js"
1045
+ contents: buildSdkRedirectModuleContents(),
1046
+ loader: "js",
1047
+ resolveDir: process.cwd()
933
1048
  }));
934
1049
  }
935
1050
  };
@@ -1063,8 +1178,9 @@ function userFactoryPlugin(absPath) {
1063
1178
  };
1064
1179
  }
1065
1180
  /**
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).
1181
+ * Returns the absolute filesystem path to a page-side leaf module shipped
1182
+ * alongside `bundle.ts` in dist (e.g. `runtime.js`, `bridge-stub.js`) — a
1183
+ * fully self-contained module with no further page-side deps of its own.
1068
1184
  *
1069
1185
  * Rolldown code-splitting duplicates this bundling logic into shared chunks
1070
1186
  * emitted at ARBITRARY dist depths: the `devtools-test` CLI pulls it from
@@ -1074,26 +1190,30 @@ function userFactoryPlugin(absPath) {
1074
1190
  * is therefore wrong from at least one chunk — the live #697 regression.
1075
1191
  *
1076
1192
  * 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`,
1193
+ * probes the co-located `<name>.js` and the nested `test-runner/<name>.js`,
1078
1194
  * 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
1195
+ * nested probe catches dist/test-runner/<name>.js from the dist/ root level no
1080
1196
  * 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).
1197
+ * relocation). The build always emits dist/test-runner/<name>.js (tsdown entry
1198
+ * `'test-runner/<name>'`; guarded by scripts/check-test-runner-dist.sh for
1199
+ * `runtime`).
1083
1200
  *
1084
1201
  * An ABSOLUTE path is returned deliberately: esbuild loads it as a literal file
1085
1202
  * read, bypassing Node module resolution entirely, so this works identically in
1086
1203
  * the npx-daemon context (its own dist tree) and the consumer-CLI context
1087
1204
  * (the mini-app's installed @ait-co/devtools dist) — neither needs the package
1088
1205
  * to be node-resolvable from the caller.
1206
+ *
1207
+ * @param moduleName - The leaf module's basename without extension, e.g.
1208
+ * `'runtime'` or `'bridge-stub'`.
1089
1209
  */
1090
- function getRuntimePath() {
1210
+ function getPageSideModulePath(moduleName) {
1091
1211
  const startDir = path$1.dirname(fileURLToPath(import.meta.url));
1092
1212
  const RELATIVE_PROBES = [
1093
- ["runtime.js"],
1094
- ["test-runner", "runtime.js"],
1095
- ["runtime.ts"],
1096
- ["test-runner", "runtime.ts"]
1213
+ [`${moduleName}.js`],
1214
+ ["test-runner", `${moduleName}.js`],
1215
+ [`${moduleName}.ts`],
1216
+ ["test-runner", `${moduleName}.ts`]
1097
1217
  ];
1098
1218
  let dir = startDir;
1099
1219
  for (let i = 0; i < 12; i++) {
@@ -1108,7 +1228,27 @@ function getRuntimePath() {
1108
1228
  if (parent === dir) break;
1109
1229
  dir = parent;
1110
1230
  }
1111
- return path$1.join(startDir, "runtime.js");
1231
+ return path$1.join(startDir, `${moduleName}.js`);
1232
+ }
1233
+ /** Absolute path to the test-runner page-side runtime (describe/it/test/expect). */
1234
+ function getRuntimePath() {
1235
+ return getPageSideModulePath("runtime");
1236
+ }
1237
+ /**
1238
+ * Absolute path to the bridge-stub interceptor (devtools#740, DT-2) —
1239
+ * `wrapSdkWithStub`/`isStubBlockingEnabled`, statically imported by the
1240
+ * `sdk-redirect` virtual module.
1241
+ */
1242
+ function getBridgeStubPath() {
1243
+ return getPageSideModulePath("bridge-stub");
1244
+ }
1245
+ /**
1246
+ * Absolute path to the per-method pacing wrapper (devtools#769) —
1247
+ * `wrapWithMethodPacing`/`getPaceMethodMs`, statically imported by the
1248
+ * `sdk-redirect` virtual module.
1249
+ */
1250
+ function getMethodPacePath() {
1251
+ return getPageSideModulePath("method-pace");
1112
1252
  }
1113
1253
  /**
1114
1254
  * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
@@ -1382,15 +1522,21 @@ async function runTestFilesOverRelay(connection, files, opts) {
1382
1522
  const manualFiles = opts?.manualFiles;
1383
1523
  const manualTotal = manualFiles?.size ?? 0;
1384
1524
  let manualIndex = 0;
1525
+ const stubBlockingFiles = opts?.stubBlockingFiles;
1385
1526
  let preflightPermissions;
1386
- if (files.length > 0) preflightPermissions = await runPermissionPreflight(connection);
1527
+ if (files.length > 0) preflightPermissions = await runPermissionPreflight(connection, PERMISSION_PREFLIGHT_TIMEOUT_MS, opts?.preflightSdkLine !== "3.x");
1528
+ const paceMs = opts?.paceMs;
1529
+ let ranFirstFile = false;
1387
1530
  try {
1388
1531
  for (const file of files) {
1389
1532
  if (pendingReconnectCheck) {
1390
1533
  await attemptReconnect("next-file");
1391
1534
  pendingReconnectCheck = false;
1392
1535
  }
1393
- const isManual = manualFiles?.has(file) === true;
1536
+ if (paceMs !== void 0 && paceMs > 0 && ranFirstFile) await new Promise((resolve) => setTimeout(resolve, paceMs));
1537
+ ranFirstFile = true;
1538
+ const isStubbed = stubBlockingFiles?.has(file) === true;
1539
+ const isManual = !isStubbed && manualFiles?.has(file) === true;
1394
1540
  if (isManual) {
1395
1541
  manualIndex += 1;
1396
1542
  opts?.onManualFile?.(file, manualIndex, manualTotal);
@@ -1457,7 +1603,8 @@ async function runTestFilesOverRelay(connection, files, opts) {
1457
1603
  };
1458
1604
  }
1459
1605
  if ("error" in fileEntry.result && isRelayDisconnectMessage(fileEntry.result.error)) pendingReconnectCheck = true;
1460
- if (isManual) fileEntry.mode = "manual";
1606
+ if (isStubbed) fileEntry.mode = "stubbed";
1607
+ else if (isManual) fileEntry.mode = "manual";
1461
1608
  fileResults.push(fileEntry);
1462
1609
  }
1463
1610
  } finally {
@@ -1573,7 +1720,7 @@ function serializeRelayReport(report, meta) {
1573
1720
  ...report.preflight ? { preflight: report.preflight } : {},
1574
1721
  files: report.files.map((f) => {
1575
1722
  const file = relativise(meta.projectRoot, f.file);
1576
- const modeField = f.mode === "manual" ? { mode: "manual" } : {};
1723
+ const modeField = f.mode === "manual" || f.mode === "stubbed" ? { mode: f.mode } : {};
1577
1724
  if ("error" in f.result) return {
1578
1725
  file,
1579
1726
  error: f.result.error,
@@ -1605,11 +1752,21 @@ function serializeRelayReport(report, meta) {
1605
1752
  * `<dir>/<sdkLine>.<platform>.manual.json` artifact instead of the standard
1606
1753
  * one — the standard `<sdkLine>.<platform>.json` filename is reserved for the
1607
1754
  * 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.
1755
+ * what the unattended-baseline filename means.
1756
+ *
1757
+ * Stub-blocking provenance (devtools#740, DT-2): `mode: 'stubbed'` files are
1758
+ * written to a THIRD, separate `<dir>/<sdkLine>.<platform>.stubbed.json`
1759
+ * artifact — never merged into the standard file (it is unattended, like the
1760
+ * standard file, so a naive filename split could conflate the two) and never
1761
+ * merged into `.manual.json` (it did NOT have a human present). Its body is
1762
+ * ALSO stamped `cell.bridgeStub: true` — the mandatory HYBRID-cell provenance
1763
+ * from the issue: a stubbed result must never be silently mixed into the
1764
+ * real-device baseline report.
1765
+ *
1766
+ * If ALL files in the run are regular, only the standard artifact is written
1767
+ * (today's behavior, unchanged). Any combination of the three modes present
1768
+ * in a single run writes ONLY the corresponding artifacts — each is additive,
1769
+ * never replacing another.
1613
1770
  *
1614
1771
  * SECRET-HANDLING: the written body contains no relay/secret fields (the schema
1615
1772
  * has none). `dir`/`projectRoot` are local filesystem paths, never logged here.
@@ -1618,14 +1775,16 @@ function serializeRelayReport(report, meta) {
1618
1775
  * @param dir - Output directory (created recursively if missing).
1619
1776
  * @param meta - Cell axes + projectRoot.
1620
1777
  * @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.
1778
+ * regular files ran), then manual (if any manual files ran), then stubbed
1779
+ * (if any stubbed files ran). At least one path is always returned when
1780
+ * `report.files` is non-empty.
1623
1781
  */
1624
1782
  async function writeReportArtifact(report, dir, meta) {
1625
1783
  const serialised = serializeRelayReport(report, meta);
1626
1784
  await mkdir(dir, { recursive: true });
1627
- const regularFiles = serialised.files.filter((f) => f.mode !== "manual");
1785
+ const regularFiles = serialised.files.filter((f) => f.mode === void 0);
1628
1786
  const manualFiles = serialised.files.filter((f) => f.mode === "manual");
1787
+ const stubbedFiles = serialised.files.filter((f) => f.mode === "stubbed");
1629
1788
  const written = [];
1630
1789
  if (regularFiles.length > 0 || serialised.files.length === 0) {
1631
1790
  const outFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.json`);
@@ -1643,6 +1802,18 @@ async function writeReportArtifact(report, dir, meta) {
1643
1802
  }, null, 2)}\n`, "utf8");
1644
1803
  written.push(manualOutFile);
1645
1804
  }
1805
+ if (stubbedFiles.length > 0) {
1806
+ const stubbedOutFile = path.join(dir, `${meta.sdkLine}.${meta.platform}.stubbed.json`);
1807
+ await writeFile(stubbedOutFile, `${JSON.stringify({
1808
+ ...serialised,
1809
+ cell: {
1810
+ ...serialised.cell,
1811
+ bridgeStub: true
1812
+ },
1813
+ files: stubbedFiles
1814
+ }, null, 2)}\n`, "utf8");
1815
+ written.push(stubbedOutFile);
1816
+ }
1646
1817
  return written;
1647
1818
  }
1648
1819
  /**
@@ -1777,7 +1948,21 @@ USAGE
1777
1948
 
1778
1949
  OPTIONS
1779
1950
  --scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
1780
- (required for standalone relay attach / env3)
1951
+ (required for standalone relay attach / env3). Unused
1952
+ and ignored when --attach-launcher is set.
1953
+ --attach-launcher Attach over the env-2 AITC Sandbox PWA launcher
1954
+ (real-device WebKit) instead of the env-3 intoss
1955
+ scheme deep-link. The relay/QR/dashboard/attach-wait
1956
+ are identical; only the QR is a launcher deep-link.
1957
+ Requires --app-url. The SDK still hits the mock in
1958
+ env 2, so this measures the mock + standard Web API
1959
+ layer's engine-attributable behavior (env1↔env2
1960
+ equivalence), NOT native-bridge fidelity (that is env3).
1961
+ --app-url <url> The consumer dev server's HTTP tunnel URL (e.g. the
1962
+ *.trycloudflare.com URL from \`pnpm dev:phone:cdp\`)
1963
+ that the launcher PWA frames. REQUIRED with
1964
+ --attach-launcher; ignored otherwise. SECRET: a tunnel
1965
+ host — never printed; it rides only inside the QR.
1781
1966
  --timeout <ms> Per-file evaluate timeout in ms (default: 60000).
1782
1967
  Controls how long a single test file is allowed to run
1783
1968
  before it is considered hung. Does NOT affect how long
@@ -1789,7 +1974,10 @@ OPTIONS
1789
1974
  Pass a value to bound the wait for CI/headless runs.
1790
1975
  --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
1791
1976
  --cell-platform <plat> Platform to inject as __AIT_CELL__.platform
1792
- (mock|ios|android, default: AIT_CELL_PLATFORM env)
1977
+ (mock|ios|android|ios-pwa, default: AIT_CELL_PLATFORM
1978
+ env). Use ios-pwa for env-2 (--attach-launcher) runs
1979
+ so env1(mock@desktop) and env2(mock@WebKit) captures
1980
+ stay distinguishable. An unknown value is rejected.
1793
1981
  --report-dir <dir> Persist a runner-agnostic report + captures to <dir>
1794
1982
  (report: <sdkLine>.<platform>.json; captures:
1795
1983
  <dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
@@ -1804,6 +1992,28 @@ OPTIONS
1804
1992
  --headless Disable browser auto-open (text QR only)
1805
1993
  --project-root <dir> Project root for .ait_relay secret lookup
1806
1994
  (default: current working directory)
1995
+ --pace <ms> Minimum delay in ms between test-to-test AND
1996
+ file-to-file bridge calls (default: 0, i.e. no
1997
+ added delay — today's behavior byte-for-byte).
1998
+ Falls back to the AIT_PACE env var when omitted
1999
+ (--pace takes precedence over the env var when both
2000
+ are given). Use on a 2.x cell scan when the native
2001
+ per-method bridge rate limit (APP_BRIDGE_THROTTLED,
2002
+ devtools#767) is rejecting rapid same-method calls —
2003
+ 3.x cells are unaffected by that limiter and do not
2004
+ need this flag.
2005
+ --pace-method <ms> Minimum delay in ms BETWEEN calls to the SAME named
2006
+ SDK function — paces a same-method burst WITHIN a
2007
+ single test body (e.g. a clipboard happy-path loop
2008
+ calling setClipboardText/getClipboardText 8 times
2009
+ back to back), which --pace's test/file spacing
2010
+ cannot reach (devtools#769). Falls back to the
2011
+ AIT_PACE_METHOD env var when omitted (--pace-method
2012
+ takes precedence over the env var when both are
2013
+ given). Default: 250ms when --cell-sdk-line is 2.x
2014
+ (unset --cell-sdk-line also defaults to 2.x — see
2015
+ --cell-sdk-line), 0 (no added delay) otherwise. Pass
2016
+ --pace-method 0 to opt out even on a 2.x cell.
1807
2017
  --manual-blocking Run manual-tagged test files (*.manual.ait.test.ts)
1808
2018
  LAST, after all regular files, with a human present.
1809
2019
  Before each manual file, the QR dashboard is pushed
@@ -1822,6 +2032,24 @@ OPTIONS
1822
2032
  each manual file's report entry is stamped
1823
2033
  mode: 'manual' — never diff a manual run against an
1824
2034
  unattended baseline as if they were equivalent.
2035
+ --stub-blocking Run manual-tagged test files (*.manual.ait.test.ts)
2036
+ UNATTENDED (devtools#740, DT-2) by intercepting a
2037
+ fixed allowlist of blocking-UI SDK calls (ads
2038
+ show*, openPermissionDialog/requestPermission,
2039
+ saveBase64Data) in the page and answering them from
2040
+ fixtures captured by a real --manual-blocking run,
2041
+ instead of forwarding them to native UI. Implies
2042
+ --manual-blocking (manual files are included in the
2043
+ run); no human presence or QR-dashboard prompt is
2044
+ needed for them. HYBRID cell, not pure env3 — every
2045
+ other SDK call in the same run still hits the real
2046
+ native bridge. With --report-dir, files that ran
2047
+ under the stub are written to a SEPARATE
2048
+ <sdkLine>.<platform>.stubbed.json artifact (never
2049
+ merged into the standard or .manual.json report) and
2050
+ the report body is stamped cell.bridgeStub: true —
2051
+ never diff a stubbed run against a real device
2052
+ baseline (manual or unattended) as if equivalent.
1825
2053
  --help, -h Show this help message
1826
2054
 
1827
2055
  DESCRIPTION
@@ -1838,7 +2066,7 @@ DESCRIPTION
1838
2066
  The test files run against the live relay connection started by this process;
1839
2067
  no separate MCP daemon is required.
1840
2068
 
1841
- EXAMPLE
2069
+ EXAMPLE (env 3 — intoss-private scheme)
1842
2070
  devtools-test 'src/**/*.ait.test.ts' \\
1843
2071
  --scheme-url "intoss-private://..." \\
1844
2072
  --cell-sdk-line 3.x \\
@@ -1846,6 +2074,14 @@ EXAMPLE
1846
2074
  --report-dir .ait-report \\
1847
2075
  --timeout 60000
1848
2076
 
2077
+ EXAMPLE (env 2 — AITC Sandbox PWA launcher)
2078
+ devtools-test 'src/**/*.ait.test.ts' \\
2079
+ --attach-launcher \\
2080
+ --app-url "https://<subdomain>.trycloudflare.com" \\
2081
+ --cell-sdk-line 3.x \\
2082
+ --cell-platform ios-pwa \\
2083
+ --report-dir .ait-report
2084
+
1849
2085
  `.trimStart();
1850
2086
  /**
1851
2087
  * Grace period (ms) for the exit backstop armed around Step 6 teardown
@@ -1894,6 +2130,144 @@ function resolveDashboardPort(raw) {
1894
2130
  return port;
1895
2131
  }
1896
2132
  /**
2133
+ * Rewrites a lone `--pace <value>` or `--pace-method <value>` pair from space
2134
+ * syntax to `=` syntax (`--pace=<value>` / `--pace-method=<value>`) when
2135
+ * `<value>` starts with `-` (devtools#768 review; extended to `--pace-method`
2136
+ * in devtools#769).
2137
+ *
2138
+ * Node's `util.parseArgs` treats a `type: 'string'` option's value as
2139
+ * "ambiguous" and throws its OWN parser error whenever that value starts with
2140
+ * a dash and was passed via the space form (`--pace -1`) — it never reaches
2141
+ * this module's `resolvePace`/`resolvePaceMethod`, so the friendly
2142
+ * `'--pace must be a non-negative integer'` message (and its unit-tested path)
2143
+ * was unreachable from the real CLI entry point for the single most natural
2144
+ * way a user would try a negative value, even though every other flag in
2145
+ * {@link USAGE} is documented in space syntax.
2146
+ *
2147
+ * Scoped narrowly to `--pace`/`--pace=...` and `--pace-method`/
2148
+ * `--pace-method=...` tokens only — every other flag is untouched, and
2149
+ * positionals/other options keep flowing through `parseArgs` unchanged. Only
2150
+ * rewrites the space form; `--pace=-1`/`--pace-method=-1` already parse fine
2151
+ * and are passed through untouched.
2152
+ *
2153
+ * Exported for unit testing.
2154
+ */
2155
+ function normalizePaceArgv(argv) {
2156
+ const out = [];
2157
+ for (let i = 0; i < argv.length; i++) {
2158
+ const arg = argv[i];
2159
+ const next = argv[i + 1];
2160
+ if ((arg === "--pace" || arg === "--pace-method") && next !== void 0 && next.startsWith("-") && next !== "--") {
2161
+ out.push(`${arg}=${next}`);
2162
+ i++;
2163
+ continue;
2164
+ }
2165
+ out.push(arg);
2166
+ }
2167
+ return out;
2168
+ }
2169
+ /**
2170
+ * Parses `--pace` (falling back to the `AIT_PACE` env var when the flag is
2171
+ * omitted) into a validated millisecond delay (devtools#767).
2172
+ *
2173
+ * Opt-in, zero-diff-when-absent: BOTH omitted resolves to `0` — no pacing —
2174
+ * which is byte-for-byte today's behavior (`runtime.ts` treats `0`/absent
2175
+ * `__AIT_PACE_MS__` identically, and `relay-worker.ts`'s file-to-file gap is
2176
+ * skipped entirely when the resolved value is 0). `--pace` takes precedence
2177
+ * over `AIT_PACE` when both are present, mirroring `--cell-platform` /
2178
+ * `AIT_CELL_PLATFORM`'s existing flag-over-env precedent.
2179
+ *
2180
+ * Returns an error string on invalid input (non-integer or negative), or the
2181
+ * resolved delay (`>= 0`) on success. Exported for unit testing.
2182
+ */
2183
+ function resolvePace(rawFlag, rawEnv) {
2184
+ const raw = rawFlag ?? rawEnv;
2185
+ if (raw === void 0) return 0;
2186
+ const ms = parseInt(raw, 10);
2187
+ if (Number.isNaN(ms) || ms < 0) return "--pace must be a non-negative integer";
2188
+ return ms;
2189
+ }
2190
+ /**
2191
+ * Parses `--pace-method` (falling back to the `AIT_PACE_METHOD` env var when
2192
+ * the flag is omitted) into a validated millisecond per-method minimum
2193
+ * interval (devtools#769).
2194
+ *
2195
+ * Precedence, highest first: `--pace-method` flag > `AIT_PACE_METHOD` env >
2196
+ * sdkLine-aware default (`{@link DEFAULT_PACE_METHOD_MS_2X}` for a 2.x cell,
2197
+ * `0` otherwise) — an EXPLICIT flag or env value always wins, including
2198
+ * `--pace-method 0` to opt out on a 2.x cell. Mirrors `resolvePace`'s
2199
+ * flag-over-env precedent, with the sdkLine default spliced in only when
2200
+ * BOTH are absent.
2201
+ *
2202
+ * Returns an error string on invalid input (non-integer or negative), or the
2203
+ * resolved delay (`>= 0`) on success. Exported for unit testing.
2204
+ *
2205
+ * @param rawFlag - The raw `--pace-method` string value, if passed.
2206
+ * @param rawEnv - The raw `AIT_PACE_METHOD` env value, if set.
2207
+ * @param resolvedCellSdkLine - The effective `cell.sdkLine` value (already
2208
+ * defaulted to '2.x' by the caller when `--cell-sdk-line` was omitted — see
2209
+ * `main()`'s `cell` construction) — used ONLY to pick the sdkLine-aware
2210
+ * default when both `rawFlag` and `rawEnv` are absent.
2211
+ */
2212
+ function resolvePaceMethod(rawFlag, rawEnv, resolvedCellSdkLine) {
2213
+ const raw = rawFlag ?? rawEnv;
2214
+ if (raw === void 0) return resolvedCellSdkLine === "2.x" ? 250 : 0;
2215
+ const ms = parseInt(raw, 10);
2216
+ if (Number.isNaN(ms) || ms < 0) return "--pace-method must be a non-negative integer";
2217
+ return ms;
2218
+ }
2219
+ /**
2220
+ * The set of `__AIT_CELL__.platform` values the runner accepts (devtools#774).
2221
+ *
2222
+ * `mock`/`ios`/`android` mirror sdk-example `aitCapture`'s existing `Platform`
2223
+ * union; `ios-pwa` is the new env-2 axis value (issue #774) — env 1 is
2224
+ * mock@desktop-Chromium and env 2 is mock@real-device-WebKit, so the two share
2225
+ * `sdkLine`/`ios` but must be DISTINGUISHABLE in the capture cell to diff
2226
+ * engine-attributable differences. `ios-pwa` names "mock SDK, real-device iOS
2227
+ * WebKit via the launcher PWA" — it is NOT `ios` (which is env 3's native
2228
+ * bridge). The consuming `aitCapture` type extension is a separate sdk-example
2229
+ * PR (out of scope here); the runner only needs to inject + validate the label.
2230
+ *
2231
+ * Kept as a validated allowlist (rather than a free string) purely as a
2232
+ * typo guard: `platform` flows into report/capture FILENAMES
2233
+ * (`<sdkLine>.<platform>.json`), so a silent typo would fork the artifact set
2234
+ * and break offline diffing. This is the first validation on `--cell-platform`
2235
+ * — before #774 it was an unvalidated free string defaulting to `mock`.
2236
+ */
2237
+ const ALLOWED_CELL_PLATFORMS = [
2238
+ "mock",
2239
+ "ios",
2240
+ "android",
2241
+ "ios-pwa"
2242
+ ];
2243
+ /**
2244
+ * Validates a resolved `--cell-platform` value against {@link ALLOWED_CELL_PLATFORMS}.
2245
+ *
2246
+ * `undefined` (flag + `AIT_CELL_PLATFORM` env both absent) passes through as
2247
+ * `{ ok: true, value: undefined }` — the caller applies its own `'mock'`
2248
+ * default, so an omitted platform is never an error. A present-but-unknown
2249
+ * value returns `{ ok: false, error }` naming the allowed set.
2250
+ *
2251
+ * Uses a tagged result (not the `string`-is-error convention of
2252
+ * `resolveDashboardPort`/`resolvePace`) because BOTH the success value and the
2253
+ * error message are strings here — a bare `string` return would be ambiguous.
2254
+ * Exported for unit testing.
2255
+ */
2256
+ function resolveCellPlatform(raw) {
2257
+ if (raw === void 0) return {
2258
+ ok: true,
2259
+ value: void 0
2260
+ };
2261
+ if (ALLOWED_CELL_PLATFORMS.includes(raw)) return {
2262
+ ok: true,
2263
+ value: raw
2264
+ };
2265
+ return {
2266
+ ok: false,
2267
+ error: `--cell-platform must be one of ${ALLOWED_CELL_PLATFORMS.join("|")} (got "${raw}")`
2268
+ };
2269
+ }
2270
+ /**
1897
2271
  * Renders per-file result lines and the aggregate totals line to a string.
1898
2272
  *
1899
2273
  * Each file gets one line:
@@ -1962,8 +2336,8 @@ function shouldSuppressQr(noQrFlag) {
1962
2336
  *
1963
2337
  * 1. Parse args: globs, --timeout (per-file evaluate), --attach-timeout (QR
1964
2338
  * scan wait), --cell-sdk-line, --cell-platform, --scheme-url (required for
1965
- * env3), --report-dir, --dashboard-port, --no-qr-stdout, --headless,
1966
- * --project-root.
2339
+ * env3) OR --attach-launcher + --app-url (env2, devtools#774), --report-dir,
2340
+ * --dashboard-port, --no-qr-stdout, --headless, --project-root.
1967
2341
  * 2. Discover test files; exit 1 if none.
1968
2342
  * 3. factory.open() — boot relay → render QR (suppressed on non-interactive
1969
2343
  * stdout) → wait for phone (up to attachTimeoutMs) → inject cell →
@@ -1984,7 +2358,7 @@ async function main(argv = process.argv.slice(2)) {
1984
2358
  let parsed;
1985
2359
  try {
1986
2360
  parsed = parseArgs({
1987
- args: argv,
2361
+ args: normalizePaceArgv(argv),
1988
2362
  options: {
1989
2363
  help: {
1990
2364
  type: "boolean",
@@ -1993,6 +2367,8 @@ async function main(argv = process.argv.slice(2)) {
1993
2367
  timeout: { type: "string" },
1994
2368
  "attach-timeout": { type: "string" },
1995
2369
  "scheme-url": { type: "string" },
2370
+ "attach-launcher": { type: "boolean" },
2371
+ "app-url": { type: "string" },
1996
2372
  "cell-sdk-line": { type: "string" },
1997
2373
  "cell-platform": { type: "string" },
1998
2374
  "report-dir": { type: "string" },
@@ -2000,7 +2376,10 @@ async function main(argv = process.argv.slice(2)) {
2000
2376
  "no-qr-stdout": { type: "boolean" },
2001
2377
  headless: { type: "boolean" },
2002
2378
  "project-root": { type: "string" },
2003
- "manual-blocking": { type: "boolean" }
2379
+ pace: { type: "string" },
2380
+ "pace-method": { type: "string" },
2381
+ "manual-blocking": { type: "boolean" },
2382
+ "stub-blocking": { type: "boolean" }
2004
2383
  },
2005
2384
  allowPositionals: true
2006
2385
  });
@@ -2021,9 +2400,17 @@ async function main(argv = process.argv.slice(2)) {
2021
2400
  return;
2022
2401
  }
2023
2402
  const { evaluateTimeoutMs, attachTimeoutMs } = timeouts;
2403
+ const attachLauncher = vals["attach-launcher"] === true;
2024
2404
  const schemeUrl = typeof vals["scheme-url"] === "string" ? vals["scheme-url"] : "";
2025
- if (schemeUrl === "") {
2026
- process.stderr.write("devtools-test: --scheme-url is required for standalone relay attach.\n Pass the intoss-private:// URL from `ait deploy --scheme-only`.\n");
2405
+ const appUrl = typeof vals["app-url"] === "string" ? vals["app-url"] : "";
2406
+ if (attachLauncher) {
2407
+ if (appUrl === "") {
2408
+ process.stderr.write("devtools-test: --app-url is required with --attach-launcher (env 2).\n Pass the consumer dev server's tunnel URL (e.g. the HTTP *.trycloudflare.com\n URL printed by `pnpm dev:phone:cdp`). --scheme-url is not used in this mode.\n");
2409
+ process.exitCode = 1;
2410
+ return;
2411
+ }
2412
+ } else if (schemeUrl === "") {
2413
+ process.stderr.write("devtools-test: --scheme-url is required for standalone relay attach.\n Pass the intoss-private:// URL from `ait deploy --scheme-only`.\n (For env 2 / AITC Sandbox PWA, use --attach-launcher --app-url <tunnel-url> instead.)\n");
2027
2414
  process.exitCode = 1;
2028
2415
  return;
2029
2416
  }
@@ -2036,15 +2423,34 @@ async function main(argv = process.argv.slice(2)) {
2036
2423
  process.exitCode = 1;
2037
2424
  return;
2038
2425
  }
2426
+ const paceMs = resolvePace(typeof vals.pace === "string" ? vals.pace : void 0, process.env.AIT_PACE);
2427
+ if (typeof paceMs === "string") {
2428
+ process.stderr.write(`devtools-test: ${paceMs}\n`);
2429
+ process.exitCode = 1;
2430
+ return;
2431
+ }
2039
2432
  const suppressQr = shouldSuppressQr(vals["no-qr-stdout"] === true);
2040
- const manualBlocking = vals["manual-blocking"] === true;
2433
+ const stubBlocking = vals["stub-blocking"] === true;
2434
+ const manualBlocking = vals["manual-blocking"] === true || stubBlocking;
2041
2435
  const cellSdkLine = typeof vals["cell-sdk-line"] === "string" ? vals["cell-sdk-line"] : void 0;
2042
- const cellPlatform = typeof vals["cell-platform"] === "string" ? vals["cell-platform"] : process.env.AIT_CELL_PLATFORM;
2436
+ const cellPlatformResult = resolveCellPlatform(typeof vals["cell-platform"] === "string" ? vals["cell-platform"] : process.env.AIT_CELL_PLATFORM);
2437
+ if (!cellPlatformResult.ok) {
2438
+ process.stderr.write(`devtools-test: ${cellPlatformResult.error}\n`);
2439
+ process.exitCode = 1;
2440
+ return;
2441
+ }
2442
+ const cellPlatform = cellPlatformResult.value;
2043
2443
  const hasCell = cellSdkLine !== void 0 || cellPlatform !== void 0;
2044
2444
  const cell = {
2045
2445
  sdkLine: cellSdkLine ?? "2.x",
2046
2446
  platform: cellPlatform ?? "mock"
2047
2447
  };
2448
+ const paceMethodMs = resolvePaceMethod(typeof vals["pace-method"] === "string" ? vals["pace-method"] : void 0, process.env.AIT_PACE_METHOD, cell.sdkLine);
2449
+ if (typeof paceMethodMs === "string") {
2450
+ process.stderr.write(`devtools-test: ${paceMethodMs}\n`);
2451
+ process.exitCode = 1;
2452
+ return;
2453
+ }
2048
2454
  const globs = parsed.positionals;
2049
2455
  if (globs.length === 0) {
2050
2456
  process.stderr.write(`devtools-test: at least one glob pattern is required\n`);
@@ -2063,13 +2469,23 @@ async function main(argv = process.argv.slice(2)) {
2063
2469
  const manualFileSet = new Set(manual);
2064
2470
  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`);
2065
2471
  if (hasCell) process.stderr.write(`devtools-test: injecting __AIT_CELL__ = ${JSON.stringify(cell)}\n`);
2472
+ 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");
2473
+ if (paceMs > 0) process.stderr.write(`devtools-test: --pace ${paceMs}ms enabled (devtools#767)\n`);
2474
+ if (paceMethodMs > 0) process.stderr.write(`devtools-test: --pace-method ${paceMethodMs}ms enabled (devtools#769)\n`);
2066
2475
  const factory = createRelayConnectionFactory({
2067
2476
  schemeUrl,
2477
+ ...attachLauncher ? {
2478
+ attachLauncher: true,
2479
+ appUrl
2480
+ } : {},
2068
2481
  projectRoot,
2069
2482
  ...attachTimeoutMs !== void 0 ? { timeoutMs: attachTimeoutMs } : {},
2070
2483
  ...dashboardPort !== void 0 ? { dashboardPort } : {},
2071
2484
  headless,
2072
2485
  cell: hasCell ? cell : void 0,
2486
+ stubBlocking,
2487
+ paceMs,
2488
+ paceMethodMs,
2073
2489
  onQrContent: (chunks) => {
2074
2490
  if (suppressQr) {
2075
2491
  process.stdout.write("QR suppressed (non-interactive)\n");
@@ -2093,7 +2509,10 @@ async function main(argv = process.argv.slice(2)) {
2093
2509
  timeoutMs: evaluateTimeoutMs,
2094
2510
  printSummary: true,
2095
2511
  collectCaptures: reportDir !== void 0,
2512
+ paceMs,
2513
+ preflightSdkLine: cell.sdkLine,
2096
2514
  manualFiles: manualBlocking && manualFileSet.size > 0 ? manualFileSet : void 0,
2515
+ stubBlockingFiles: stubBlocking && manualFileSet.size > 0 ? manualFileSet : void 0,
2097
2516
  onManualFile: (file, index, total) => {
2098
2517
  const name = basename(file);
2099
2518
  process.stdout.write(`수동 단계: ${name} — 폰에서 네이티브 시트가 뜨면 안내에 따라 조작하세요 (${index}/${total})\n`);