@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
package/dist/mcp/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { i as generateTotp, n as assertRelayAuthConfigured, r as buildRelayVerifyAuth } from "../totp-WY6l0ysP.js";
3
- import { t as loadRelaySecretReadOnly } from "../relay-secret-store-DhzAnnj-.js";
3
+ import { t as loadRelaySecretReadOnly } from "../relay-secret-store-CQenfcSL.js";
4
4
  import { createRequire } from "node:module";
5
5
  import { accessSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { argv } from "node:process";
@@ -25,33 +25,44 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
25
25
  //#endregion
26
26
  //#region src/in-app/gate.ts
27
27
  /**
28
- * The host suffix the Toss app uses to serve dogfood / private mini-apps.
29
- *
30
- * A `intoss-private://` (dogfood) entry maps to a host such as
31
- * `aitc-sdk-example.private-apps.tossmini.com`. A production `intoss://`
32
- * entry is served from `*.apps.tossmini.com` — the `.private-apps.` segment
33
- * is absent. Confirmed live over CDP for mini-app 31146; the exact production
34
- * host is to be re-confirmed once 31146 passes review (spec open question 2).
35
- */
36
- const PRIVATE_APPS_HOST_SUFFIX = ".private-apps.tossmini.com";
37
- /**
38
28
  * The host suffix Cloudflare quick-tunnels serve from — the env 2 (PWA) entry.
39
29
  * See {@link isTrycloudflareHost} for why this host kind bypasses Layer B1.
40
30
  */
41
31
  const TRYCLOUDFLARE_HOST_SUFFIX = ".trycloudflare.com";
42
32
  /**
43
- * Returns whether `hostname` is a `*.private-apps.tossmini.com` subdomain
44
- * the host the Toss app reserves for dogfood / private mini-app entries.
33
+ * The parent host suffix for the whole Toss mini-app serving family.
45
34
  *
46
- * The match is an exact suffix check, not a substring `.includes()`: a
47
- * substring test would also accept an attacker-controlled host like
48
- * `private-apps.tossmini.com.evil.example`, which ends in `.example`, not in
49
- * `.tossmini.com`. Requiring the string to END with the suffix closes that.
50
- * The leading `.` in the suffix also forces a real subdomain label, so a
51
- * bare `private-apps.tossmini.com` (no mini-app subdomain) does not match.
35
+ * The 3.0 runtime loader serves mini-app pages from tossmini.com hosts that
36
+ * are NOT `*.private-apps.tossmini.com` (observed live 2026-07-08 on mini-app
37
+ * 31146 with a 3.0-beta bundle: a 4-label host ending in `.tossmini.com`
38
+ * whose middle label is not `private-apps`, with `_deploymentId` consumed by
39
+ * the native loader and not propagated to the page URL devtools#760).
40
+ *
41
+ * Under 3.0 the hostname therefore no longer distinguishes a dogfood
42
+ * candidate from a production entry, so for these hosts Layer B is demoted
43
+ * from a stage discriminator (#665) to a "Toss-owned host family" filter,
44
+ * and the effective boundary moves to Layer C: explicit `debug=1`, a valid
45
+ * `wss:` relay, and a MANDATORY `at=` TOTP code (see Layer C3 in
46
+ * {@link evaluateDebugGate}). A production user's entry URL carries none of
47
+ * those params, so an accidentally-shipped debug build stays dormant exactly
48
+ * as #665 intended; what changes is that a deliberate operator holding the
49
+ * TOTP secret can now attach on a 3.0-family host.
50
+ *
51
+ * The match is the same exact-suffix `endsWith` check as
52
+ * {@link isPrivateAppsHost} — never a substring `.includes()`, which would
53
+ * accept an attacker-controlled `x.tossmini.com.evil.example`. The leading
54
+ * `.` forces at least one subdomain label, so a bare `tossmini.com` does not
55
+ * match.
56
+ */
57
+ const TOSSMINI_HOST_SUFFIX = ".tossmini.com";
58
+ /**
59
+ * Returns whether `hostname` is any `*.tossmini.com` subdomain — the host
60
+ * family the Toss app serves mini-app pages from. Includes the 2.x
61
+ * `*.private-apps.tossmini.com` dogfood hosts and the 3.0 unified serving
62
+ * hosts (devtools#760).
52
63
  */
53
- function isPrivateAppsHost(hostname) {
54
- return hostname.endsWith(PRIVATE_APPS_HOST_SUFFIX);
64
+ function isTossminiHost(hostname) {
65
+ return hostname.endsWith(TOSSMINI_HOST_SUFFIX);
55
66
  }
56
67
  /**
57
68
  * The host suffix Cloudflare quick-tunnels use — the env 2 (PWA) entry.
@@ -98,18 +109,27 @@ function isLocalhostHost(hostname) {
98
109
  * known debug-allowed host. The debug surface is ONLY active on:
99
110
  * - localhost / loopback (env 1 desktop dev)
100
111
  * - *.trycloudflare.com (env 2 PWA tunnel)
101
- * - *.private-apps.tossmini.com (env 3 dog-food)
102
- *
103
- * Any other host (including apps.tossmini.com — the former env 4 LIVE host)
104
- * is silently blocked. This is a positive allowlist — unlisted hosts never
105
- * had debug surface regardless, but this function makes it explicit and
106
- * auditable in a single place.
112
+ * - *.tossmini.com (env 3 dog-food — 2.x private-apps hosts AND the 3.0
113
+ * unified serving family, devtools#760)
114
+ *
115
+ * Any other host is silently blocked. This is a positive allowlist —
116
+ * unlisted hosts never had debug surface regardless, but this function makes
117
+ * it explicit and auditable in a single place.
118
+ *
119
+ * #760 note on the #665 boundary: the former env 4 LIVE host family
120
+ * (`*.apps.tossmini.com`) now passes this coarse filter because the 3.0
121
+ * loader serves dogfood candidates and production entries from the same
122
+ * host family — the hostname alone can no longer separate them. The #665
123
+ * invariant ("no naked attach on a production-family host") is preserved
124
+ * one layer down: on tossmini hosts that are not `*.private-apps.*`, Layer
125
+ * C3 makes the TOTP `at=` code MANDATORY, and production entry URLs carry
126
+ * no debug/relay/at params at all.
107
127
  *
108
128
  * SECRET-HANDLING: the hostname value MUST NOT be logged or included in any
109
129
  * error reason string — only benign labels ('host not in allowlist') are safe.
110
130
  */
111
131
  function isDebugAllowedHost(hostname) {
112
- return isLocalhostHost(hostname) || isTrycloudflareHost(hostname) || isPrivateAppsHost(hostname);
132
+ return isLocalhostHost(hostname) || isTrycloudflareHost(hostname) || isTossminiHost(hostname);
113
133
  }
114
134
  //#endregion
115
135
  //#region src/shared/parent-watcher.ts
@@ -211,6 +231,39 @@ function startMaxAgeWatchdog(onExpired, opts = {}) {
211
231
  } };
212
232
  }
213
233
  //#endregion
234
+ //#region src/test-runner/throttle.ts
235
+ /**
236
+ * Shared `APP_BRIDGE_THROTTLED` detection (devtools#767).
237
+ *
238
+ * 2026-07-10 real-device observation: the Toss app native side added a
239
+ * per-method rate limit to the 2.x bridge — calling the SAME bridge method
240
+ * twice within a short window rejects the second call immediately with a
241
+ * 2.x native envelope (`{name, code, userInfo, moduleName, __isError}`) whose
242
+ * `code` is `'APP_BRIDGE_THROTTLED'` and whose `message` reads
243
+ * `"Too many app bridge calls from <method>."`. The 3.x bridge path is
244
+ * unaffected (devtools#767 issue body, sdk-example#284 2026-07-10 comment).
245
+ *
246
+ * This module is the SINGLE source of the detection predicate. Today's only
247
+ * consumer is `runtime.ts` (in-page, per-test retry loop) — a plain static
248
+ * import; esbuild inlines this file into the same IIFE bundle (same
249
+ * mechanism as `bridge-stub.ts`, see `bundle.ts#getPageSideModulePath`). It
250
+ * is exported from this standalone, dependency-free module (rather than
251
+ * defined inline in `runtime.ts`) so a Node-side consumer could import the
252
+ * exact same predicate in future if whole-FILE throttle-aware retry is ever
253
+ * added on top of the per-test retry `runtime.ts` already performs — no such
254
+ * Node-side caller exists yet (whole-file retry today only triggers on an
255
+ * `injectAndRunBundle`-level timeout/exception, a distinct failure mode from
256
+ * a `RunReport` that completed normally but contains a THROTTLED-failed
257
+ * test — see `relay-worker.ts`'s `EVALUATE_TIMEOUT_MARKER` gate).
258
+ *
259
+ * Browser-compatible, dependency-free (no Node APIs) — safe to import from
260
+ * either side of the CDP boundary, mirroring `runtime.ts`'s own constraint.
261
+ */
262
+ /** The native error code the Toss app bridge returns when rate-limited. */
263
+ const THROTTLED_ERROR_CODE = "APP_BRIDGE_THROTTLED";
264
+ /** The message substring the native envelope carries when rate-limited. */
265
+ const THROTTLED_MESSAGE_SUBSTRING = "Too many app bridge calls";
266
+ //#endregion
214
267
  //#region src/mcp/deeplink.ts
215
268
  /**
216
269
  * URL of the AITC Sandbox launcher PWA.
@@ -2024,7 +2077,7 @@ async function readMcpSdkVersion() {
2024
2077
  * some test environments that skip the build step).
2025
2078
  */
2026
2079
  function readDevtoolsVersion() {
2027
- return "0.1.136";
2080
+ return "0.1.138";
2028
2081
  }
2029
2082
  /**
2030
2083
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -2617,7 +2670,7 @@ async function prepareAttach(deps, env, args, conn) {
2617
2670
  const buildProjectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
2618
2671
  let tunnelHttpUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? "";
2619
2672
  if (tunnelHttpUrl === "" && buildProjectRoot !== void 0) {
2620
- const { readRelayUrls } = await import("../relay-url-store-CwKT7i04.js");
2673
+ const { readRelayUrls } = await import("../relay-url-store-DaY1QPes.js");
2621
2674
  tunnelHttpUrl = (await readRelayUrls({ projectRoot: buildProjectRoot }))?.tunnelBaseUrl ?? "";
2622
2675
  }
2623
2676
  if (tunnelHttpUrl === "") return {
@@ -2694,10 +2747,12 @@ async function prepareAttach(deps, env, args, conn) {
2694
2747
  tool: "start_attach",
2695
2748
  msg: "no _deploymentId in scheme_url; matching on presence only"
2696
2749
  });
2750
+ const wssForMatch = tunnelForBuild.wssUrl;
2751
+ const matchesRelay = (url) => url.includes(wssForMatch) || url.includes(encodeURIComponent(wssForMatch));
2697
2752
  const isMatchingPage = (pages) => {
2698
2753
  if (pages.length === 0) return false;
2699
2754
  if (deploymentId === null) return true;
2700
- return pages.some((p) => p.url.includes(deploymentId));
2755
+ return pages.some((p) => p.url.includes(deploymentId) || matchesRelay(p.url));
2701
2756
  };
2702
2757
  const buildTimeoutError = (baseText, timeoutSec, observed) => {
2703
2758
  const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
@@ -2956,8 +3011,43 @@ const PERMISSION_PROBE_MAP = {
2956
3011
  contacts: "fetchContacts",
2957
3012
  location: "getCurrentLocation"
2958
3013
  };
2959
- /** Non-fatal bound for the permission preflight `Runtime.evaluate` round-trip. */
2960
- const PERMISSION_PREFLIGHT_TIMEOUT_MS = 1e4;
3014
+ /**
3015
+ * Inter-probe spacing (ms) applied BEFORE every probe after the first
3016
+ * (devtools#767). The native per-method bridge rate limit observed
3017
+ * 2026-07-10 rejects a SECOND call to the same method within a short window
3018
+ * with `APP_BRIDGE_THROTTLED` — the permission preflight's un-paced burst
3019
+ * (6 probes fired back-to-back) was the FIRST trigger of that limiter in the
3020
+ * wild (only the first probe passed; the remaining 5 came back throttled →
3021
+ * `unavailable`, cascading into skipped `__AIT_PERMS__`-branching tests).
3022
+ * Sequential-with-spacing is cheap insurance even though each probe targets a
3023
+ * DIFFERENT method — the limiter's exact scope (per-method vs. a shared
3024
+ * bridge-wide window) was not reverse-engineered (explicitly out of scope,
3025
+ * devtools#767) so spacing every probe is the conservative choice.
3026
+ */
3027
+ const PROBE_INTER_CALL_DELAY_MS = 250;
3028
+ /**
3029
+ * Backoff delays (ms) between retry attempts when a probe is throttled. Its
3030
+ * `.length` IS the max retry count (devtools#767: "최대 2회 재시도") — the
3031
+ * generated page-side expression below derives `attempt < backoff.length`
3032
+ * from this same array (via `backoffArray`), so there is a single source for
3033
+ * both the delay ladder and the retry count; no separate max-retries
3034
+ * constant is kept in this module (unlike `runtime.ts`'s `TEST_MAX_RETRIES`,
3035
+ * which IS consumed directly by a Node-side loop condition).
3036
+ */
3037
+ const PROBE_RETRY_BACKOFF_MS = [500, 1e3];
3038
+ /**
3039
+ * Non-fatal bound for the permission preflight `Runtime.evaluate` round-trip
3040
+ * (devtools#767: raised from the pre-pacing 10s to cover the new sequential +
3041
+ * backoff time budget).
3042
+ *
3043
+ * Worst-case budget with 6 probes, each requiring the full retry ladder:
3044
+ * - inter-probe spacing: only BEFORE probes 2‑6 (none before the first) =
3045
+ * 5 × {@link PROBE_INTER_CALL_DELAY_MS} = 1 250ms
3046
+ * - per-probe backoff (both retries taken): 6 × (500 + 1000) = 9 000ms
3047
+ * - probe round-trips themselves (headroom, not just the sleeps): ~4 500ms
3048
+ * total ≈ 14 750ms, rounded up to a clean 20 000ms for headroom.
3049
+ */
3050
+ const PERMISSION_PREFLIGHT_TIMEOUT_MS = 2e4;
2961
3051
  /**
2962
3052
  * Builds the `Runtime.evaluate` expression for the permission-state preflight
2963
3053
  * (devtools#739). Evaluated ONCE per relay session, before the first test
@@ -2972,6 +3062,29 @@ const PERMISSION_PREFLIGHT_TIMEOUT_MS = 1e4;
2972
3062
  * caller, and a single slow/broken permission must not fail the others (each
2973
3063
  * probe is awaited independently, not short-circuited).
2974
3064
  *
3065
+ * devtools#767: probes run SEQUENTIALLY (not a parallel burst) with a
3066
+ * {@link PROBE_INTER_CALL_DELAY_MS} gap before each probe after the first, and
3067
+ * a probe that comes back `APP_BRIDGE_THROTTLED` (native per-method bridge
3068
+ * rate limit) is retried up to {@link PROBE_RETRY_BACKOFF_MS}`.length` times
3069
+ * with the same {@link PROBE_RETRY_BACKOFF_MS} ladder before falling back to
3070
+ * `'unavailable'`.
3071
+ * The THROTTLED detection mirrors `throttle.ts#isThrottledError` inline (this
3072
+ * expression is raw JS text evaluated via CDP, not an esbuild-bundled module,
3073
+ * so it cannot statically `import` that helper) — kept in sync by checking
3074
+ * BOTH `e.code === 'APP_BRIDGE_THROTTLED'` and a message substring match, the
3075
+ * same two signals `isThrottledError` checks.
3076
+ *
3077
+ * devtools#767 acceptance criteria 2 ("3.x run duration은 유의미하게 늘지 않음,
3078
+ * pacing은 opt-in 또는 throttle-adaptive") requires this NOT be an unconditional
3079
+ * cost — `pace` (default `true`, matching the pre-existing sequential+spacing
3080
+ * behavior for 2.x/unknown cells) lets a 3.x caller skip BOTH the inter-probe
3081
+ * `sleep(250)` AND the retry backoff ladder, since `APP_BRIDGE_THROTTLED` is a
3082
+ * 2.x-bridge-only limiter (see `--pace`'s own doc in `cli.ts`: "3.x cells are
3083
+ * unaffected by that limiter"). When `pace` is `false`, probes still run
3084
+ * sequentially (never a parallel burst) but back-to-back with no added delay,
3085
+ * and a THROTTLED result resolves to `'unavailable'` immediately (no retry) —
3086
+ * this matches the 3.x runtime, which never emits `APP_BRIDGE_THROTTLED`.
3087
+ *
2975
3088
  * Tests (sdk-example#265) read `globalThis.__AIT_PERMS__` to branch
2976
3089
  * deterministically per permission state instead of blanket outcome-branching.
2977
3090
  *
@@ -2982,9 +3095,18 @@ const PERMISSION_PREFLIGHT_TIMEOUT_MS = 1e4;
2982
3095
  *
2983
3096
  * Pure function — no I/O, no CDP call. Callers pass the returned string to
2984
3097
  * `Runtime.evaluate`.
3098
+ *
3099
+ * @param pace - Defaults to `true` (today's sequential+spacing+backoff
3100
+ * behavior). Pass `false` for a 3.x cell to skip the inter-probe delay and
3101
+ * the THROTTLED retry ladder entirely.
2985
3102
  */
2986
- function buildPermissionPreflightExpression() {
2987
- return `(async () => { const probe = async (fnName) => { try { const fn = globalThis.__sdk && globalThis.__sdk[fnName]; if (!fn || typeof fn.getPermission !== 'function') return 'unavailable'; const status = await fn.getPermission(); if (status === 'allowed' || status === 'denied' || status === 'notDetermined') return status; return 'unavailable'; } catch (e) { return 'unavailable'; } }; const result = {};${Object.entries(PERMISSION_PROBE_MAP).map(([key, fnName]) => ` result[${JSON.stringify(key)}] = await probe(${JSON.stringify(fnName)});`).join("\n")}\n globalThis.__AIT_PERMS__ = result; return JSON.stringify(result);})()`;
3103
+ function buildPermissionPreflightExpression(pace = true) {
3104
+ const probeCalls = Object.keys(PERMISSION_PROBE_MAP).map((key, i) => {
3105
+ const fnName = PERMISSION_PROBE_MAP[key];
3106
+ return `${i === 0 || !pace ? "" : ` await sleep(${PROBE_INTER_CALL_DELAY_MS});\n`} result[${JSON.stringify(key)}] = await probeWithRetry(${JSON.stringify(fnName)});`;
3107
+ }).join("\n");
3108
+ const backoffArray = pace ? `[${PROBE_RETRY_BACKOFF_MS.join(",")}]` : "[]";
3109
+ return `(async () => { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const isThrottled = (e) => { if (!e) return false; if (e.code === ${JSON.stringify(THROTTLED_ERROR_CODE)}) return true; const msg = typeof e.message === 'string' ? e.message : ''; return msg.indexOf(${JSON.stringify(THROTTLED_MESSAGE_SUBSTRING)}) !== -1; }; const probe = async (fnName) => { const fn = globalThis.__sdk && globalThis.__sdk[fnName]; if (!fn || typeof fn.getPermission !== 'function') return { ok: true, value: 'unavailable' }; const status = await fn.getPermission(); if (status === 'allowed' || status === 'denied' || status === 'notDetermined') return { ok: true, value: status }; return { ok: true, value: 'unavailable' }; }; const backoff = ${backoffArray}; const probeWithRetry = async (fnName) => { for (let attempt = 0; attempt <= backoff.length; attempt++) { try { const r = await probe(fnName); return r.value; } catch (e) { if (isThrottled(e) && attempt < backoff.length) { await sleep(backoff[attempt]); continue; } return 'unavailable'; } } return 'unavailable'; }; const result = {};${probeCalls}\n globalThis.__AIT_PERMS__ = result; return JSON.stringify(result);})()`;
2988
3110
  }
2989
3111
  /**
2990
3112
  * Runs the permission-state preflight (devtools#739) via `Runtime.evaluate`
@@ -3003,13 +3125,19 @@ function buildPermissionPreflightExpression() {
3003
3125
  *
3004
3126
  * @param conn - The live CDP connection (relay-attached page).
3005
3127
  * @param timeoutMs - Round-trip bound. Defaults to {@link PERMISSION_PREFLIGHT_TIMEOUT_MS}.
3006
- */
3007
- async function runPermissionPreflight(conn, timeoutMs = PERMISSION_PREFLIGHT_TIMEOUT_MS) {
3128
+ * @param pace - Forwarded to {@link buildPermissionPreflightExpression}.
3129
+ * Defaults to `true` (today's sequential+spacing+backoff behavior, correct
3130
+ * for 2.x/unknown cells). Callers that know the cell is 3.x should pass
3131
+ * `false` — devtools#767 acceptance criteria 2 requires 3.x run duration not
3132
+ * grow from this preflight, and 3.x never hits the 2.x-only
3133
+ * `APP_BRIDGE_THROTTLED` limiter this pacing exists to dodge.
3134
+ */
3135
+ async function runPermissionPreflight(conn, timeoutMs = PERMISSION_PREFLIGHT_TIMEOUT_MS, pace = true) {
3008
3136
  const TIMEOUT_SENTINEL = Symbol("permission-preflight-timeout");
3009
3137
  const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(TIMEOUT_SENTINEL), timeoutMs));
3010
3138
  try {
3011
3139
  const evalPromise = conn.send("Runtime.evaluate", {
3012
- expression: buildPermissionPreflightExpression(),
3140
+ expression: buildPermissionPreflightExpression(pace),
3013
3141
  returnByValue: true,
3014
3142
  awaitPromise: true
3015
3143
  });
@@ -3827,7 +3955,73 @@ const SDK_IMPORT_FILTER = new RegExp(`^${SDK_PACKAGE.replace(/[.*+?^${}()|[\]\\]
3827
3955
  *
3828
3956
  * If `window.__sdk` is absent (non-dog-food build), every access throws a
3829
3957
  * descriptive error rather than returning `undefined` silently.
3958
+ *
3959
+ * Bridge-stub interception (devtools#740, DT-2): the virtual module runs the
3960
+ * resolved `window.__sdk` object through `wrapSdkWithStub` before exporting
3961
+ * it. `wrapSdkWithStub` is a pass-through (returns the same object,
3962
+ * unwrapped) unless `isStubBlockingEnabled()` reads `true` off
3963
+ * `globalThis.__AIT_STUB_BLOCKING__` — an opt-in flag injected the same way
3964
+ * `__AIT_CELL__` is (`cell.ts#injectGlobals`, wired from the CLI's
3965
+ * `--stub-blocking` flag). Default off => zero behavior change: every existing
3966
+ * (non-stub) test run resolves the exact same `window.__sdk` reference it did
3967
+ * before this plugin learned about stubbing.
3968
+ *
3969
+ * Per-method pacing (devtools#769): BEFORE the stub wrap, `window.__sdk` is
3970
+ * run through `wrapWithMethodPacing`, which enforces a minimum interval
3971
+ * between calls to the SAME named SDK function (`getPaceMethodMs()` reads the
3972
+ * `__AIT_PACE_METHOD_MS__` page global — see `method-pace.ts`). Composition
3973
+ * order is pacing INSIDE the stub (`wrapSdkWithStub(wrapWithMethodPacing(sdk,
3974
+ * gap), enabled)`): a stubbed name resolves instantly from
3975
+ * `bridge-stub.ts`'s fixtures without ever reaching the paced wrapper, and
3976
+ * only calls that fall through to the real bridge — the ones that can
3977
+ * actually trigger `APP_BRIDGE_THROTTLED` — pay the pacing cost. `gapMs <= 0`
3978
+ * (default, no `--pace-method` / non-2.x cell) is a no-op fast path — see
3979
+ * `method-pace.ts#wrapWithMethodPacing`'s doc for the full contract.
3980
+ */
3981
+ /**
3982
+ * Builds the virtual CommonJS-style module contents the `sdk-redirect`
3983
+ * esbuild plugin loads for every `@apps-in-toss/web-framework` import.
3984
+ *
3985
+ * Pulled out to a pure, exported function (rather than inlined at the
3986
+ * `build.onLoad` call site) so unit tests can assert the exact composition
3987
+ * order (pacing wraps the raw SDK; the stub wraps the paced result) as a
3988
+ * string-level contract, independent of esbuild — esbuild cannot run inside
3989
+ * this repo's jsdom vitest environment (see `bundleTestFile`'s module doc's
3990
+ * lazy-import note), so a real bundling round-trip is out of unit-test reach
3991
+ * here; `wrapSdkWithStub`/`wrapWithMethodPacing`'s own composed-call behavior
3992
+ * is covered directly (see `bundle.test.ts`).
3993
+ *
3994
+ * Generates a virtual CommonJS-style module so that esbuild does NOT perform
3995
+ * strict named-export matching. When `format:'iife'` bundles a CJS module, it
3996
+ * wraps it with its own `__toCommonJS` helper and satisfies named imports via
3997
+ * property access on the `module.exports` object — which is our Proxy. This
3998
+ * means `import { getPlatformOS } from '...'` becomes
3999
+ * `__proxy.getPlatformOS` at runtime, which correctly reads from
4000
+ * `window.__sdk`.
4001
+ *
4002
+ * The bridge-stub and method-pace imports are STATIC imports of the real
4003
+ * `bridge-stub.ts`/`method-pace.ts` modules (not inlined JS) so esbuild
4004
+ * bundles each once and the fixture registry / pacing registry have a single
4005
+ * source of truth shared with their own unit tests.
4006
+ *
4007
+ * Exported for unit testing.
3830
4008
  */
4009
+ function buildSdkRedirectModuleContents() {
4010
+ return `
4011
+ import { wrapSdkWithStub, isStubBlockingEnabled } from ${JSON.stringify(getBridgeStubPath())};
4012
+ import { wrapWithMethodPacing, getPaceMethodMs } from ${JSON.stringify(getMethodPacePath())};
4013
+ var __rawSdk = (typeof window !== 'undefined' && window.__sdk)
4014
+ ? window.__sdk
4015
+ : new Proxy({}, {
4016
+ get: function(_t, p) {
4017
+ throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
4018
+ }
4019
+ });
4020
+ var __pacedSdk = wrapWithMethodPacing(__rawSdk, getPaceMethodMs());
4021
+ var __proxy = wrapSdkWithStub(__pacedSdk, isStubBlockingEnabled());
4022
+ module.exports = __proxy;
4023
+ `;
4024
+ }
3831
4025
  function sdkRedirectPlugin() {
3832
4026
  return {
3833
4027
  name: "sdk-redirect",
@@ -3840,17 +4034,9 @@ function sdkRedirectPlugin() {
3840
4034
  filter: /.*/,
3841
4035
  namespace: "sdk-redirect"
3842
4036
  }, () => ({
3843
- contents: `
3844
- var __proxy = (typeof window !== 'undefined' && window.__sdk)
3845
- ? window.__sdk
3846
- : new Proxy({}, {
3847
- get: function(_t, p) {
3848
- throw new Error('window.__sdk is not installed — run in a dog-food build. Missing: ' + String(p));
3849
- }
3850
- });
3851
- module.exports = __proxy;
3852
- `,
3853
- loader: "js"
4037
+ contents: buildSdkRedirectModuleContents(),
4038
+ loader: "js",
4039
+ resolveDir: process.cwd()
3854
4040
  }));
3855
4041
  }
3856
4042
  };
@@ -3984,8 +4170,9 @@ function userFactoryPlugin(absPath) {
3984
4170
  };
3985
4171
  }
3986
4172
  /**
3987
- * Returns the absolute filesystem path to the test-runner runtime module
3988
- * (dist/test-runner/runtime.js — a fully self-contained page-side bundle).
4173
+ * Returns the absolute filesystem path to a page-side leaf module shipped
4174
+ * alongside `bundle.ts` in dist (e.g. `runtime.js`, `bridge-stub.js`) — a
4175
+ * fully self-contained module with no further page-side deps of its own.
3989
4176
  *
3990
4177
  * Rolldown code-splitting duplicates this bundling logic into shared chunks
3991
4178
  * emitted at ARBITRARY dist depths: the `devtools-test` CLI pulls it from
@@ -3995,26 +4182,30 @@ function userFactoryPlugin(absPath) {
3995
4182
  * is therefore wrong from at least one chunk — the live #697 regression.
3996
4183
  *
3997
4184
  * This resolves WITHOUT assuming chunk depth: from `import.meta.url`'s dir it
3998
- * probes the co-located `runtime.js` and the nested `test-runner/runtime.js`,
4185
+ * probes the co-located `<name>.js` and the nested `test-runner/<name>.js`,
3999
4186
  * then ascends one directory at a time (bounded) repeating both probes. The
4000
- * nested probe catches dist/test-runner/runtime.js from the dist/ root level no
4187
+ * nested probe catches dist/test-runner/<name>.js from the dist/ root level no
4001
4188
  * matter which depth the chunk was hoisted to (root, dist/mcp/, or a future
4002
- * relocation). The build always emits dist/test-runner/runtime.js (tsdown entry
4003
- * `'test-runner/runtime'`; guarded by scripts/check-test-runner-dist.sh).
4189
+ * relocation). The build always emits dist/test-runner/<name>.js (tsdown entry
4190
+ * `'test-runner/<name>'`; guarded by scripts/check-test-runner-dist.sh for
4191
+ * `runtime`).
4004
4192
  *
4005
4193
  * An ABSOLUTE path is returned deliberately: esbuild loads it as a literal file
4006
4194
  * read, bypassing Node module resolution entirely, so this works identically in
4007
4195
  * the npx-daemon context (its own dist tree) and the consumer-CLI context
4008
4196
  * (the mini-app's installed @ait-co/devtools dist) — neither needs the package
4009
4197
  * to be node-resolvable from the caller.
4198
+ *
4199
+ * @param moduleName - The leaf module's basename without extension, e.g.
4200
+ * `'runtime'` or `'bridge-stub'`.
4010
4201
  */
4011
- function getRuntimePath() {
4202
+ function getPageSideModulePath(moduleName) {
4012
4203
  const startDir = path.dirname(fileURLToPath(import.meta.url));
4013
4204
  const RELATIVE_PROBES = [
4014
- ["runtime.js"],
4015
- ["test-runner", "runtime.js"],
4016
- ["runtime.ts"],
4017
- ["test-runner", "runtime.ts"]
4205
+ [`${moduleName}.js`],
4206
+ ["test-runner", `${moduleName}.js`],
4207
+ [`${moduleName}.ts`],
4208
+ ["test-runner", `${moduleName}.ts`]
4018
4209
  ];
4019
4210
  let dir = startDir;
4020
4211
  for (let i = 0; i < 12; i++) {
@@ -4029,7 +4220,27 @@ function getRuntimePath() {
4029
4220
  if (parent === dir) break;
4030
4221
  dir = parent;
4031
4222
  }
4032
- return path.join(startDir, "runtime.js");
4223
+ return path.join(startDir, `${moduleName}.js`);
4224
+ }
4225
+ /** Absolute path to the test-runner page-side runtime (describe/it/test/expect). */
4226
+ function getRuntimePath() {
4227
+ return getPageSideModulePath("runtime");
4228
+ }
4229
+ /**
4230
+ * Absolute path to the bridge-stub interceptor (devtools#740, DT-2) —
4231
+ * `wrapSdkWithStub`/`isStubBlockingEnabled`, statically imported by the
4232
+ * `sdk-redirect` virtual module.
4233
+ */
4234
+ function getBridgeStubPath() {
4235
+ return getPageSideModulePath("bridge-stub");
4236
+ }
4237
+ /**
4238
+ * Absolute path to the per-method pacing wrapper (devtools#769) —
4239
+ * `wrapWithMethodPacing`/`getPaceMethodMs`, statically imported by the
4240
+ * `sdk-redirect` virtual module.
4241
+ */
4242
+ function getMethodPacePath() {
4243
+ return getPageSideModulePath("method-pace");
4033
4244
  }
4034
4245
  /**
4035
4246
  * Bundles `absPath` into a single IIFE string suitable for `Runtime.evaluate`.
@@ -4303,15 +4514,21 @@ async function runTestFilesOverRelay(connection, files, opts) {
4303
4514
  const manualFiles = opts?.manualFiles;
4304
4515
  const manualTotal = manualFiles?.size ?? 0;
4305
4516
  let manualIndex = 0;
4517
+ const stubBlockingFiles = opts?.stubBlockingFiles;
4306
4518
  let preflightPermissions;
4307
- if (files.length > 0) preflightPermissions = await runPermissionPreflight(connection);
4519
+ if (files.length > 0) preflightPermissions = await runPermissionPreflight(connection, PERMISSION_PREFLIGHT_TIMEOUT_MS, opts?.preflightSdkLine !== "3.x");
4520
+ const paceMs = opts?.paceMs;
4521
+ let ranFirstFile = false;
4308
4522
  try {
4309
4523
  for (const file of files) {
4310
4524
  if (pendingReconnectCheck) {
4311
4525
  await attemptReconnect("next-file");
4312
4526
  pendingReconnectCheck = false;
4313
4527
  }
4314
- const isManual = manualFiles?.has(file) === true;
4528
+ if (paceMs !== void 0 && paceMs > 0 && ranFirstFile) await new Promise((resolve) => setTimeout(resolve, paceMs));
4529
+ ranFirstFile = true;
4530
+ const isStubbed = stubBlockingFiles?.has(file) === true;
4531
+ const isManual = !isStubbed && manualFiles?.has(file) === true;
4315
4532
  if (isManual) {
4316
4533
  manualIndex += 1;
4317
4534
  opts?.onManualFile?.(file, manualIndex, manualTotal);
@@ -4378,7 +4595,8 @@ async function runTestFilesOverRelay(connection, files, opts) {
4378
4595
  };
4379
4596
  }
4380
4597
  if ("error" in fileEntry.result && isRelayDisconnectMessage(fileEntry.result.error)) pendingReconnectCheck = true;
4381
- if (isManual) fileEntry.mode = "manual";
4598
+ if (isStubbed) fileEntry.mode = "stubbed";
4599
+ else if (isManual) fileEntry.mode = "manual";
4382
4600
  fileResults.push(fileEntry);
4383
4601
  }
4384
4602
  } finally {
@@ -4445,7 +4663,21 @@ USAGE
4445
4663
 
4446
4664
  OPTIONS
4447
4665
  --scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
4448
- (required for standalone relay attach / env3)
4666
+ (required for standalone relay attach / env3). Unused
4667
+ and ignored when --attach-launcher is set.
4668
+ --attach-launcher Attach over the env-2 AITC Sandbox PWA launcher
4669
+ (real-device WebKit) instead of the env-3 intoss
4670
+ scheme deep-link. The relay/QR/dashboard/attach-wait
4671
+ are identical; only the QR is a launcher deep-link.
4672
+ Requires --app-url. The SDK still hits the mock in
4673
+ env 2, so this measures the mock + standard Web API
4674
+ layer's engine-attributable behavior (env1↔env2
4675
+ equivalence), NOT native-bridge fidelity (that is env3).
4676
+ --app-url <url> The consumer dev server's HTTP tunnel URL (e.g. the
4677
+ *.trycloudflare.com URL from \`pnpm dev:phone:cdp\`)
4678
+ that the launcher PWA frames. REQUIRED with
4679
+ --attach-launcher; ignored otherwise. SECRET: a tunnel
4680
+ host — never printed; it rides only inside the QR.
4449
4681
  --timeout <ms> Per-file evaluate timeout in ms (default: 60000).
4450
4682
  Controls how long a single test file is allowed to run
4451
4683
  before it is considered hung. Does NOT affect how long
@@ -4457,7 +4689,10 @@ OPTIONS
4457
4689
  Pass a value to bound the wait for CI/headless runs.
4458
4690
  --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
4459
4691
  --cell-platform <plat> Platform to inject as __AIT_CELL__.platform
4460
- (mock|ios|android, default: AIT_CELL_PLATFORM env)
4692
+ (mock|ios|android|ios-pwa, default: AIT_CELL_PLATFORM
4693
+ env). Use ios-pwa for env-2 (--attach-launcher) runs
4694
+ so env1(mock@desktop) and env2(mock@WebKit) captures
4695
+ stay distinguishable. An unknown value is rejected.
4461
4696
  --report-dir <dir> Persist a runner-agnostic report + captures to <dir>
4462
4697
  (report: <sdkLine>.<platform>.json; captures:
4463
4698
  <dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
@@ -4472,6 +4707,28 @@ OPTIONS
4472
4707
  --headless Disable browser auto-open (text QR only)
4473
4708
  --project-root <dir> Project root for .ait_relay secret lookup
4474
4709
  (default: current working directory)
4710
+ --pace <ms> Minimum delay in ms between test-to-test AND
4711
+ file-to-file bridge calls (default: 0, i.e. no
4712
+ added delay — today's behavior byte-for-byte).
4713
+ Falls back to the AIT_PACE env var when omitted
4714
+ (--pace takes precedence over the env var when both
4715
+ are given). Use on a 2.x cell scan when the native
4716
+ per-method bridge rate limit (APP_BRIDGE_THROTTLED,
4717
+ devtools#767) is rejecting rapid same-method calls —
4718
+ 3.x cells are unaffected by that limiter and do not
4719
+ need this flag.
4720
+ --pace-method <ms> Minimum delay in ms BETWEEN calls to the SAME named
4721
+ SDK function — paces a same-method burst WITHIN a
4722
+ single test body (e.g. a clipboard happy-path loop
4723
+ calling setClipboardText/getClipboardText 8 times
4724
+ back to back), which --pace's test/file spacing
4725
+ cannot reach (devtools#769). Falls back to the
4726
+ AIT_PACE_METHOD env var when omitted (--pace-method
4727
+ takes precedence over the env var when both are
4728
+ given). Default: 250ms when --cell-sdk-line is 2.x
4729
+ (unset --cell-sdk-line also defaults to 2.x — see
4730
+ --cell-sdk-line), 0 (no added delay) otherwise. Pass
4731
+ --pace-method 0 to opt out even on a 2.x cell.
4475
4732
  --manual-blocking Run manual-tagged test files (*.manual.ait.test.ts)
4476
4733
  LAST, after all regular files, with a human present.
4477
4734
  Before each manual file, the QR dashboard is pushed
@@ -4490,6 +4747,24 @@ OPTIONS
4490
4747
  each manual file's report entry is stamped
4491
4748
  mode: 'manual' — never diff a manual run against an
4492
4749
  unattended baseline as if they were equivalent.
4750
+ --stub-blocking Run manual-tagged test files (*.manual.ait.test.ts)
4751
+ UNATTENDED (devtools#740, DT-2) by intercepting a
4752
+ fixed allowlist of blocking-UI SDK calls (ads
4753
+ show*, openPermissionDialog/requestPermission,
4754
+ saveBase64Data) in the page and answering them from
4755
+ fixtures captured by a real --manual-blocking run,
4756
+ instead of forwarding them to native UI. Implies
4757
+ --manual-blocking (manual files are included in the
4758
+ run); no human presence or QR-dashboard prompt is
4759
+ needed for them. HYBRID cell, not pure env3 — every
4760
+ other SDK call in the same run still hits the real
4761
+ native bridge. With --report-dir, files that ran
4762
+ under the stub are written to a SEPARATE
4763
+ <sdkLine>.<platform>.stubbed.json artifact (never
4764
+ merged into the standard or .manual.json report) and
4765
+ the report body is stamped cell.bridgeStub: true —
4766
+ never diff a stubbed run against a real device
4767
+ baseline (manual or unattended) as if equivalent.
4493
4768
  --help, -h Show this help message
4494
4769
 
4495
4770
  DESCRIPTION
@@ -4506,7 +4781,7 @@ DESCRIPTION
4506
4781
  The test files run against the live relay connection started by this process;
4507
4782
  no separate MCP daemon is required.
4508
4783
 
4509
- EXAMPLE
4784
+ EXAMPLE (env 3 — intoss-private scheme)
4510
4785
  devtools-test 'src/**/*.ait.test.ts' \\
4511
4786
  --scheme-url "intoss-private://..." \\
4512
4787
  --cell-sdk-line 3.x \\
@@ -4514,6 +4789,14 @@ EXAMPLE
4514
4789
  --report-dir .ait-report \\
4515
4790
  --timeout 60000
4516
4791
 
4792
+ EXAMPLE (env 2 — AITC Sandbox PWA launcher)
4793
+ devtools-test 'src/**/*.ait.test.ts' \\
4794
+ --attach-launcher \\
4795
+ --app-url "https://<subdomain>.trycloudflare.com" \\
4796
+ --cell-sdk-line 3.x \\
4797
+ --cell-platform ios-pwa \\
4798
+ --report-dir .ait-report
4799
+
4517
4800
  `.trimStart();
4518
4801
  /**
4519
4802
  * Renders per-file result lines and the aggregate totals line to a string.
@@ -7205,7 +7488,7 @@ function createDebugServer(deps) {
7205
7488
  };
7206
7489
  const server = new Server({
7207
7490
  name: "ait-debug",
7208
- version: "0.1.136"
7491
+ version: "0.1.138"
7209
7492
  }, { capabilities: { tools: { listChanged: true } } });
7210
7493
  server.setRequestHandler(ListToolsRequestSchema, () => {
7211
7494
  const conn = router.active;
@@ -7641,6 +7924,13 @@ function errorResult(err, name, isLocal = false) {
7641
7924
  * SECRET-HANDLING: target `id`/`title`/`url` are not written to any log here.
7642
7925
  * Only an attach-detected stderr line is emitted (no target details).
7643
7926
  *
7927
+ * `server` is optional (devtools#772): the standalone test-runner path
7928
+ * (`test-runner/relay-factory.ts`) has no MCP `Server` instance to notify —
7929
+ * it only needs the `onAttach`/`onDetach` dashboard-push side effects. When
7930
+ * omitted, `sendToolListChanged()` is simply skipped; the signature-diff and
7931
+ * callback logic is byte-for-byte the same. Existing MCP daemon call sites
7932
+ * always pass a real `Server`, so their behavior is unchanged.
7933
+ *
7644
7934
  * @returns `stop` — call this during shutdown to clear the interval.
7645
7935
  */
7646
7936
  function startAttachWatcher(connection, server, intervalMs = 1e3, onAttach, onDetach) {
@@ -7650,7 +7940,7 @@ function startAttachWatcher(connection, server, intervalMs = 1e3, onAttach, onDe
7650
7940
  }
7651
7941
  let lastSignature = signature();
7652
7942
  if (lastSignature !== "") {
7653
- server.sendToolListChanged();
7943
+ server?.sendToolListChanged();
7654
7944
  onAttach?.();
7655
7945
  }
7656
7946
  /** Compare current vs last signature and fire the appropriate callback. */
@@ -7660,7 +7950,7 @@ function startAttachWatcher(connection, server, intervalMs = 1e3, onAttach, onDe
7660
7950
  const wasNonEmpty = lastSignature !== "";
7661
7951
  lastSignature = current;
7662
7952
  if (current !== "") {
7663
- server.sendToolListChanged();
7953
+ server?.sendToolListChanged();
7664
7954
  onAttach?.();
7665
7955
  } else if (wasNonEmpty) onDetach?.();
7666
7956
  }
@@ -7852,7 +8142,7 @@ async function readRelayLocalUrl(env = process.env, projectRoot) {
7852
8142
  const envValue = (env.AIT_RELAY_LOCAL_URL ?? "").trim();
7853
8143
  if (envValue !== "") return envValue;
7854
8144
  if (projectRoot !== void 0) try {
7855
- const { readRelayUrls } = await import("../relay-url-store-CwKT7i04.js");
8145
+ const { readRelayUrls } = await import("../relay-url-store-DaY1QPes.js");
7856
8146
  const stored = await readRelayUrls({ projectRoot });
7857
8147
  if (stored?.relayLocalUrl) return stored.relayLocalUrl;
7858
8148
  } catch {}
@@ -7906,7 +8196,7 @@ async function readMobileRelayBaseUrl(env = process.env, projectRoot) {
7906
8196
  const envValue = typeof raw === "string" ? raw.trim() : "";
7907
8197
  if (envValue !== "") return envValue;
7908
8198
  if (projectRoot !== void 0) {
7909
- const { readRelayUrls } = await import("../relay-url-store-CwKT7i04.js");
8199
+ const { readRelayUrls } = await import("../relay-url-store-DaY1QPes.js");
7910
8200
  const stored = await readRelayUrls({ projectRoot });
7911
8201
  if (stored?.relayBaseUrl !== void 0) return stored.relayBaseUrl;
7912
8202
  }
@@ -9189,7 +9479,7 @@ function createDevServer(deps = {}) {
9189
9479
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
9190
9480
  const server = new Server({
9191
9481
  name: "ait-devtools",
9192
- version: "0.1.136"
9482
+ version: "0.1.138"
9193
9483
  }, { capabilities: { tools: {} } });
9194
9484
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
9195
9485
  server.setRequestHandler(CallToolRequestSchema, async (request) => {