@ait-co/devtools 0.1.143 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/README.en.md +65 -163
  2. package/README.md +65 -192
  3. package/dist/in-app/auto.d.ts +1 -138
  4. package/dist/in-app/auto.js +28 -1102
  5. package/dist/in-app/auto.js.map +1 -1
  6. package/dist/in-app/index.d.ts +38 -547
  7. package/dist/in-app/index.d.ts.map +1 -1
  8. package/dist/in-app/index.js +62 -939
  9. package/dist/in-app/index.js.map +1 -1
  10. package/dist/mcp/cli.d.ts +1 -54
  11. package/dist/mcp/cli.js +33 -9720
  12. package/dist/mcp/cli.js.map +1 -1
  13. package/dist/mcp/server.d.ts +1 -88
  14. package/dist/mcp/server.js +36 -1076
  15. package/dist/mcp/server.js.map +1 -1
  16. package/dist/mock/index.d.ts +19 -20
  17. package/dist/mock/index.d.ts.map +1 -1
  18. package/dist/mock/index.js.map +1 -1
  19. package/dist/optional-peers-CDEFhlhJ.cjs +131 -0
  20. package/dist/optional-peers-CDEFhlhJ.cjs.map +1 -0
  21. package/dist/optional-peers-FdVbUJst.js +96 -0
  22. package/dist/optional-peers-FdVbUJst.js.map +1 -0
  23. package/dist/panel/index.js +1 -103
  24. package/dist/panel/index.js.map +1 -1
  25. package/dist/relay-url-store-CPZAn-T5.js +107 -0
  26. package/dist/relay-url-store-CPZAn-T5.js.map +1 -0
  27. package/dist/relay-url-store-DLjlvMSA.cjs +108 -0
  28. package/dist/relay-url-store-DLjlvMSA.cjs.map +1 -0
  29. package/dist/stubs/bin-devtools-mcp.js +58 -0
  30. package/dist/stubs/bin-devtools-mcp.js.map +1 -0
  31. package/dist/stubs/bin-devtools-test.d.ts +2 -0
  32. package/dist/stubs/bin-devtools-test.js +55 -0
  33. package/dist/stubs/bin-devtools-test.js.map +1 -0
  34. package/dist/test-runner/config.d.ts +1 -231
  35. package/dist/test-runner/config.js +41 -45
  36. package/dist/test-runner/config.js.map +1 -1
  37. package/dist/{tunnel-BVSMXctM.cjs → tunnel-BKZkOyQp.cjs} +27 -75
  38. package/dist/tunnel-BKZkOyQp.cjs.map +1 -0
  39. package/dist/{tunnel-D7xkimBu.js → tunnel-CqSCIrdU.js} +27 -75
  40. package/dist/tunnel-CqSCIrdU.js.map +1 -0
  41. package/dist/unplugin/index.cjs +17 -30
  42. package/dist/unplugin/index.cjs.map +1 -1
  43. package/dist/unplugin/index.d.cts +34 -5
  44. package/dist/unplugin/index.d.cts.map +1 -1
  45. package/dist/unplugin/index.d.ts +35 -6
  46. package/dist/unplugin/index.d.ts.map +1 -1
  47. package/dist/unplugin/index.js +17 -30
  48. package/dist/unplugin/index.js.map +1 -1
  49. package/dist/unplugin/tunnel.cjs +61 -74
  50. package/dist/unplugin/tunnel.cjs.map +1 -1
  51. package/dist/unplugin/tunnel.d.cts +22 -16
  52. package/dist/unplugin/tunnel.d.cts.map +1 -1
  53. package/dist/unplugin/tunnel.d.ts +22 -16
  54. package/dist/unplugin/tunnel.d.ts.map +1 -1
  55. package/dist/unplugin/tunnel.js +61 -74
  56. package/dist/unplugin/tunnel.js.map +1 -1
  57. package/package.json +17 -22
  58. package/dist/attach-orchestrator-0F0m_UqQ.js +0 -1845
  59. package/dist/attach-orchestrator-0F0m_UqQ.js.map +0 -1
  60. package/dist/attach-orchestrator-D65KxFy_.js +0 -1831
  61. package/dist/attach-orchestrator-D65KxFy_.js.map +0 -1
  62. package/dist/attach-orchestrator-DL3NQ9ca.js +0 -1846
  63. package/dist/attach-orchestrator-DL3NQ9ca.js.map +0 -1
  64. package/dist/bundle-C796JIwG.d.ts +0 -159
  65. package/dist/bundle-C796JIwG.d.ts.map +0 -1
  66. package/dist/capture-DsP525OZ.d.ts +0 -58
  67. package/dist/capture-DsP525OZ.d.ts.map +0 -1
  68. package/dist/cdp-connection-rP1WdnH5.d.ts +0 -287
  69. package/dist/cdp-connection-rP1WdnH5.d.ts.map +0 -1
  70. package/dist/cell-BaLvusOl.js +0 -68
  71. package/dist/cell-BaLvusOl.js.map +0 -1
  72. package/dist/cell-CBUS3-nT.js +0 -274
  73. package/dist/cell-CBUS3-nT.js.map +0 -1
  74. package/dist/cell-EBKKpAAT.js +0 -307
  75. package/dist/cell-EBKKpAAT.js.map +0 -1
  76. package/dist/chii-relay-BZ3HqWL5.js +0 -304
  77. package/dist/chii-relay-BZ3HqWL5.js.map +0 -1
  78. package/dist/chii-relay-D7eK2acz.cjs +0 -304
  79. package/dist/chii-relay-D7eK2acz.cjs.map +0 -1
  80. package/dist/debug-server-B3ABDrRI.js +0 -456
  81. package/dist/debug-server-B3ABDrRI.js.map +0 -1
  82. package/dist/debug-server-BWhwrVXa.js +0 -1158
  83. package/dist/debug-server-BWhwrVXa.js.map +0 -1
  84. package/dist/debug-server-CfQNxxGW.js +0 -600
  85. package/dist/debug-server-CfQNxxGW.js.map +0 -1
  86. package/dist/deeplink-B5-Hxu0Q.js +0 -62
  87. package/dist/deeplink-B5-Hxu0Q.js.map +0 -1
  88. package/dist/deeplink-BpO9qc-D.js +0 -62
  89. package/dist/deeplink-BpO9qc-D.js.map +0 -1
  90. package/dist/deeplink-BzdbA1gV.cjs +0 -62
  91. package/dist/deeplink-BzdbA1gV.cjs.map +0 -1
  92. package/dist/deeplink-DCScMYcp.cjs +0 -62
  93. package/dist/deeplink-DCScMYcp.cjs.map +0 -1
  94. package/dist/devtools-opener-3Drge_RJ.js +0 -75
  95. package/dist/devtools-opener-3Drge_RJ.js.map +0 -1
  96. package/dist/devtools-opener-B8nxrxqu.js +0 -71
  97. package/dist/devtools-opener-B8nxrxqu.js.map +0 -1
  98. package/dist/devtools-opener-BDY0w3_0.cjs +0 -68
  99. package/dist/devtools-opener-BDY0w3_0.cjs.map +0 -1
  100. package/dist/devtools-opener-BTl5A6Cd.js +0 -71
  101. package/dist/devtools-opener-BTl5A6Cd.js.map +0 -1
  102. package/dist/devtools-opener-CJpEsXXQ.js +0 -76
  103. package/dist/devtools-opener-CJpEsXXQ.js.map +0 -1
  104. package/dist/devtools-opener-CxtryS8c.js +0 -75
  105. package/dist/devtools-opener-CxtryS8c.js.map +0 -1
  106. package/dist/devtools-opener-iv1OwfJN.cjs +0 -68
  107. package/dist/devtools-opener-iv1OwfJN.cjs.map +0 -1
  108. package/dist/in-app/auto.d.ts.map +0 -1
  109. package/dist/mcp/cli.d.ts.map +0 -1
  110. package/dist/mcp/server.d.ts.map +0 -1
  111. package/dist/pool-DcaaOwUq.d.ts +0 -14761
  112. package/dist/pool-DcaaOwUq.d.ts.map +0 -1
  113. package/dist/qr-http-server--gl2-WKc.cjs +0 -1644
  114. package/dist/qr-http-server--gl2-WKc.cjs.map +0 -1
  115. package/dist/qr-http-server-Bb7lMeqi.js +0 -1644
  116. package/dist/qr-http-server-Bb7lMeqi.js.map +0 -1
  117. package/dist/qr-http-server-C_lqOrgc.js +0 -1644
  118. package/dist/qr-http-server-C_lqOrgc.js.map +0 -1
  119. package/dist/qr-http-server-CopuMbub.js +0 -1644
  120. package/dist/qr-http-server-CopuMbub.js.map +0 -1
  121. package/dist/qr-http-server-D-Off6K1.cjs +0 -1644
  122. package/dist/qr-http-server-D-Off6K1.cjs.map +0 -1
  123. package/dist/qr-http-server-DrbIVDjO.js +0 -1645
  124. package/dist/qr-http-server-DrbIVDjO.js.map +0 -1
  125. package/dist/qr-http-server-n1twN18z.js +0 -1644
  126. package/dist/qr-http-server-n1twN18z.js.map +0 -1
  127. package/dist/relay-factory-N9QobQxG.js +0 -206
  128. package/dist/relay-factory-N9QobQxG.js.map +0 -1
  129. package/dist/relay-secret-store-BPhN1upr.js +0 -240
  130. package/dist/relay-secret-store-BPhN1upr.js.map +0 -1
  131. package/dist/relay-secret-store-Bmyleu0A.js +0 -154
  132. package/dist/relay-secret-store-Bmyleu0A.js.map +0 -1
  133. package/dist/relay-secret-store-CQenfcSL.js +0 -154
  134. package/dist/relay-secret-store-CQenfcSL.js.map +0 -1
  135. package/dist/relay-secret-store-DKxs7zwq.js +0 -153
  136. package/dist/relay-secret-store-DKxs7zwq.js.map +0 -1
  137. package/dist/relay-secret-store-DWKdV-eY.cjs +0 -241
  138. package/dist/relay-secret-store-DWKdV-eY.cjs.map +0 -1
  139. package/dist/relay-secret-store-WJ8EGkIl.js +0 -153
  140. package/dist/relay-secret-store-WJ8EGkIl.js.map +0 -1
  141. package/dist/relay-url-store-1FGuSYAn.cjs +0 -115
  142. package/dist/relay-url-store-1FGuSYAn.cjs.map +0 -1
  143. package/dist/relay-url-store-BR2XodiO.js +0 -123
  144. package/dist/relay-url-store-BR2XodiO.js.map +0 -1
  145. package/dist/relay-url-store-Bskcyeg8.js +0 -114
  146. package/dist/relay-url-store-Bskcyeg8.js.map +0 -1
  147. package/dist/relay-url-store-CH63fVCm.js +0 -122
  148. package/dist/relay-url-store-CH63fVCm.js.map +0 -1
  149. package/dist/relay-url-store-DaY1QPes.js +0 -123
  150. package/dist/relay-url-store-DaY1QPes.js.map +0 -1
  151. package/dist/relay-url-store-xmUuTjXA.js +0 -122
  152. package/dist/relay-url-store-xmUuTjXA.js.map +0 -1
  153. package/dist/relay-worker-B5HKkGUY.js +0 -832
  154. package/dist/relay-worker-B5HKkGUY.js.map +0 -1
  155. package/dist/relay-worker-YdlpZQl9.d.ts +0 -214
  156. package/dist/relay-worker-YdlpZQl9.d.ts.map +0 -1
  157. package/dist/rolldown-runtime-DGkTqVfb.js +0 -15
  158. package/dist/rolldown-runtime-DUslC3ob.js +0 -14
  159. package/dist/runtime-kn9DxOeg.d.ts +0 -249
  160. package/dist/runtime-kn9DxOeg.d.ts.map +0 -1
  161. package/dist/test-runner/bin.js +0 -2584
  162. package/dist/test-runner/bin.js.map +0 -1
  163. package/dist/test-runner/bridge-stub.d.ts +0 -125
  164. package/dist/test-runner/bridge-stub.d.ts.map +0 -1
  165. package/dist/test-runner/bridge-stub.js +0 -92
  166. package/dist/test-runner/bridge-stub.js.map +0 -1
  167. package/dist/test-runner/bundle.d.ts +0 -2
  168. package/dist/test-runner/bundle.js +0 -439
  169. package/dist/test-runner/bundle.js.map +0 -1
  170. package/dist/test-runner/capture.d.ts +0 -2
  171. package/dist/test-runner/capture.js +0 -44
  172. package/dist/test-runner/capture.js.map +0 -1
  173. package/dist/test-runner/config.d.ts.map +0 -1
  174. package/dist/test-runner/method-pace.d.ts +0 -82
  175. package/dist/test-runner/method-pace.d.ts.map +0 -1
  176. package/dist/test-runner/method-pace.js +0 -120
  177. package/dist/test-runner/method-pace.js.map +0 -1
  178. package/dist/test-runner/pool.d.ts +0 -2
  179. package/dist/test-runner/pool.js +0 -136
  180. package/dist/test-runner/pool.js.map +0 -1
  181. package/dist/test-runner/relay-factory.d.ts +0 -11245
  182. package/dist/test-runner/relay-factory.d.ts.map +0 -1
  183. package/dist/test-runner/relay-factory.js +0 -206
  184. package/dist/test-runner/relay-factory.js.map +0 -1
  185. package/dist/test-runner/relay-worker.d.ts +0 -2
  186. package/dist/test-runner/relay-worker.js +0 -2
  187. package/dist/test-runner/report.d.ts +0 -163
  188. package/dist/test-runner/report.d.ts.map +0 -1
  189. package/dist/test-runner/report.js +0 -198
  190. package/dist/test-runner/report.js.map +0 -1
  191. package/dist/test-runner/rpc.d.ts +0 -56
  192. package/dist/test-runner/rpc.d.ts.map +0 -1
  193. package/dist/test-runner/rpc.js +0 -98
  194. package/dist/test-runner/rpc.js.map +0 -1
  195. package/dist/test-runner/runtime.d.ts +0 -2
  196. package/dist/test-runner/runtime.js +0 -659
  197. package/dist/test-runner/runtime.js.map +0 -1
  198. package/dist/test-runner/task-graph.d.ts +0 -38
  199. package/dist/test-runner/task-graph.d.ts.map +0 -1
  200. package/dist/test-runner/task-graph.js +0 -182
  201. package/dist/test-runner/task-graph.js.map +0 -1
  202. package/dist/throttle-DKKzX1qC.js +0 -59
  203. package/dist/throttle-DKKzX1qC.js.map +0 -1
  204. package/dist/totp-95OAa20j.js +0 -64
  205. package/dist/totp-95OAa20j.js.map +0 -1
  206. package/dist/totp-BjtoQNfu.cjs +0 -64
  207. package/dist/totp-BjtoQNfu.cjs.map +0 -1
  208. package/dist/totp-CZLLKfOC.js +0 -200
  209. package/dist/totp-CZLLKfOC.js.map +0 -1
  210. package/dist/totp-DAxys-r0.js +0 -199
  211. package/dist/totp-DAxys-r0.js.map +0 -1
  212. package/dist/totp-DIbrZtI7.js +0 -189
  213. package/dist/totp-DIbrZtI7.js.map +0 -1
  214. package/dist/totp-Df252ZdA.cjs +0 -192
  215. package/dist/totp-Df252ZdA.cjs.map +0 -1
  216. package/dist/totp-DfekTBk3.js +0 -211
  217. package/dist/totp-DfekTBk3.js.map +0 -1
  218. package/dist/totp-Dwft0Kz7.js +0 -3
  219. package/dist/totp-WY6l0ysP.js +0 -190
  220. package/dist/totp-WY6l0ysP.js.map +0 -1
  221. package/dist/tunnel-BVSMXctM.cjs.map +0 -1
  222. package/dist/tunnel-D7xkimBu.js.map +0 -1
  223. /package/dist/{test-runner/bin.d.ts → stubs/bin-devtools-mcp.d.ts} +0 -0
@@ -1,1158 +0,0 @@
1
- import { n as buildRelayVerifyAuth, r as generateTotp, t as assertRelayAuthConfigured } from "./totp-DfekTBk3.js";
2
- import "./test-runner/relay-factory.js";
3
- import { a as startTunnelHealthProbe, i as startQuickTunnel, n as makeTunnelStatus, o as logError, r as printAttachBanner, s as logInfo, t as generateAttachToken } from "./attach-orchestrator-D65KxFy_.js";
4
- import "./cell-BaLvusOl.js";
5
- import "./qr-http-server-C_lqOrgc.js";
6
- import "./relay-secret-store-DKxs7zwq.js";
7
- import { createRequire } from "node:module";
8
- import "@modelcontextprotocol/sdk/server/index.js";
9
- import "@modelcontextprotocol/sdk/server/stdio.js";
10
- import "@modelcontextprotocol/sdk/types.js";
11
- import { EventEmitter } from "node:events";
12
- import { WebSocket, WebSocketServer } from "ws";
13
- import { createServer } from "node:http";
14
- //#region src/shared/relay-auth-close.ts
15
- /**
16
- * Shared constants for the relay's named TOTP-auth rejection (issue #478).
17
- *
18
- * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
19
- * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
20
- * indistinguishable from a network failure on the browser side — the
21
- * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
22
- * could not tell "stale TOTP code" apart from "tunnel down" and stayed
23
- * silent. The fix is accept-then-close: complete the handshake, then close
24
- * with an application close code that NAMES the rejection.
25
- *
26
- * Three parties share this contract:
27
- * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
28
- * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
29
- * surfaces the code to the launcher shell;
30
- * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
31
- * as an auth failure on its own `/client` dial (defensive — #439's fresh
32
- * code mint means it should not normally hit this).
33
- *
34
- * This module is intentionally dependency-free (no Node, no DOM) so it is
35
- * safe to import from both the browser in-app bundle and the MCP daemon
36
- * bundle.
37
- *
38
- * SECRET-HANDLING: these are fixed enum values. The close reason / error body
39
- * must never grow to carry a secret, a TOTP code, or a host.
40
- */
41
- /**
42
- * WebSocket close code sent by the relay when TOTP auth is rejected.
43
- *
44
- * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
45
- * HTTP 401 so it reads as "unauthorized" at a glance.
46
- */
47
- const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
48
- /**
49
- * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
50
- * the `error` value of the relay's HTTP 401 JSON body. Enum string only —
51
- * never interpolated with request data.
52
- */
53
- const RELAY_AUTH_REJECT_REASON = "totp-rejected";
54
- //#endregion
55
- //#region src/mcp/chii-connection.ts
56
- /**
57
- * Production `CdpConnection` backed by the local Chii relay.
58
- *
59
- * Topology (debug mode):
60
- * phone target.js --WS--> Chii relay :9100 <--WS-- this connection
61
- *
62
- * The phone connects to the relay as a `target`; this module connects as a
63
- * `client` (the role a CDP frontend would take) so CDP events the page emits
64
- * (`Runtime.consoleAPICalled`, `Network.*`) flow back here. We buffer recent
65
- * events in ring buffers the tool layer reads via `getBufferedEvents`.
66
- *
67
- * Node-only: imports `ws`. Never bundled into the browser/in-app entries.
68
- *
69
- * Attach reliability (#281):
70
- * `refreshTargets()` emits an internal 'target:attached' event whenever a
71
- * new target is added to the relay. `waitForFirstTarget()` awaits that event
72
- * (with a polling-interval fallback) so `start_attach`'s attach wait
73
- * resolves deterministically rather than racing between polling rounds.
74
- */
75
- /** Max events retained per domain ring buffer. */
76
- const DEFAULT_BUFFER_SIZE = 500;
77
- function isObject(value) {
78
- return typeof value === "object" && value !== null;
79
- }
80
- function parseInbound(raw) {
81
- let parsed;
82
- try {
83
- parsed = JSON.parse(raw);
84
- } catch {
85
- return null;
86
- }
87
- if (!isObject(parsed)) return null;
88
- const message = {};
89
- if (typeof parsed.id === "number") message.id = parsed.id;
90
- if (typeof parsed.method === "string") message.method = parsed.method;
91
- if ("params" in parsed) message.params = parsed.params;
92
- if ("result" in parsed) message.result = parsed.result;
93
- if (isObject(parsed.error) && typeof parsed.error.message === "string") message.error = { message: parsed.error.message };
94
- return message;
95
- }
96
- const PHASE_1_EVENTS = [
97
- "Runtime.consoleAPICalled",
98
- "Network.requestWillBeSent",
99
- "Network.responseReceived"
100
- ];
101
- /**
102
- * Ring buffer size for `Runtime.exceptionThrown`.
103
- *
104
- * Exceptions are rarer than console messages but each is heavier (stack
105
- * trace). 50 is generous enough to cover a crash scenario while keeping
106
- * memory bounded.
107
- *
108
- * **Lifecycle note**: the exception buffer intentionally survives `replaced` /
109
- * `crashed` / `destroyed` lifecycle events — it is NOT cleared on target
110
- * transitions. Rationale: an exception fired just before a crash is exactly
111
- * the signal we want to preserve for root-cause analysis. The buffer
112
- * represents "exceptions seen in this MCP session", not "exceptions in the
113
- * current page".
114
- */
115
- const EXCEPTION_BUFFER_SIZE = 50;
116
- /** Default per-command timeout if neither option nor env var is set. */
117
- const DEFAULT_COMMAND_TIMEOUT_MS = 3e4;
118
- /**
119
- * Production CDP connection. Polls the relay for the first attached target,
120
- * opens a client websocket to it, enables Phase 1 domains, and buffers events.
121
- */
122
- var ChiiCdpConnection = class {
123
- /** Authoritative connection kind (issue #348) — relay-backed. */
124
- kind = "relay";
125
- relayBaseUrl;
126
- bufferSize;
127
- commandTimeoutMs;
128
- totpSecret;
129
- emitter = new EventEmitter();
130
- buffers = /* @__PURE__ */ new Map();
131
- targets = /* @__PURE__ */ new Map();
132
- ws = null;
133
- connectionState = "idle";
134
- nextCommandId = 1;
135
- /**
136
- * The single active target id under the single-attach model.
137
- * Updated by `refreshTargets()` whenever a non-null target is present.
138
- * Used to detect a new (different) target attach and evict the previous one.
139
- */
140
- activeTargetId = null;
141
- /** In-flight enableDomains() promise — concurrent callers share it. */
142
- enablingPromise = null;
143
- /** Pending request→response commands keyed by CDP message id. */
144
- pending = /* @__PURE__ */ new Map();
145
- /**
146
- * Timestamp (ms since epoch) of the most recent crash/destroy/detach event,
147
- * or `null` if no crash has been detected since the last `enableDomains()`.
148
- */
149
- lastCrashDetectedAt = null;
150
- /**
151
- * Per-target last-seen timestamp (ms since epoch). Updated on any inbound
152
- * CDP message carrying data from a target. Keyed by target id.
153
- */
154
- targetLastSeenAt = /* @__PURE__ */ new Map();
155
- /** Active heartbeat interval handle (only when `AIT_CDP_HEARTBEAT_MS` is set). */
156
- heartbeatHandle = null;
157
- /** Lifecycle event listeners (crash / destroyed / detached). */
158
- lifecycleListeners = [];
159
- constructor(options) {
160
- this.relayBaseUrl = options.relayBaseUrl.replace(/\/$/, "");
161
- this.bufferSize = options.bufferSize ?? DEFAULT_BUFFER_SIZE;
162
- this.totpSecret = options.totpSecret;
163
- const envMs = process.env.AIT_CDP_COMMAND_TIMEOUT_MS ? Number(process.env.AIT_CDP_COMMAND_TIMEOUT_MS) : void 0;
164
- this.commandTimeoutMs = (envMs !== void 0 && Number.isFinite(envMs) && envMs > 0 ? envMs : void 0) ?? options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
165
- for (const event of PHASE_1_EVENTS) this.buffers.set(event, []);
166
- this.buffers.set("Runtime.exceptionThrown", []);
167
- this.emitter.setMaxListeners(0);
168
- }
169
- /** Refresh the attached-target list from the relay's `GET /targets`. */
170
- async refreshTargets() {
171
- let targetsUrl = `${this.relayBaseUrl}/targets`;
172
- if (this.totpSecret) {
173
- const code = generateTotp(this.totpSecret);
174
- targetsUrl += `?at=${encodeURIComponent(code)}`;
175
- }
176
- const res = await fetch(targetsUrl);
177
- if (!res.ok) throw new Error(`Chii relay /targets returned HTTP ${res.status} ${res.statusText}`);
178
- const body = await res.json();
179
- const list = isObject(body) && Array.isArray(body.targets) ? body.targets : [];
180
- let newestTargetId = null;
181
- for (const item of list) {
182
- if (!isObject(item) || typeof item.id !== "string") continue;
183
- newestTargetId = item.id;
184
- }
185
- if (newestTargetId !== null && this.activeTargetId !== null && newestTargetId !== this.activeTargetId) {
186
- const prevId = this.activeTargetId;
187
- logInfo("page.detached", { prevTargetId: prevId });
188
- this.evictTarget(prevId);
189
- }
190
- this.targets.clear();
191
- for (const item of list) {
192
- if (!isObject(item) || typeof item.id !== "string") continue;
193
- if (item.id !== newestTargetId) continue;
194
- this.targets.set(item.id, {
195
- id: item.id,
196
- title: typeof item.title === "string" ? item.title : "",
197
- url: typeof item.url === "string" ? item.url : ""
198
- });
199
- }
200
- if (newestTargetId !== null) this.activeTargetId = newestTargetId;
201
- else this.activeTargetId = null;
202
- const result = [...this.targets.values()];
203
- if (newestTargetId !== null) this.emitter.emit("target:attached", result);
204
- return result;
205
- }
206
- listTargets() {
207
- return [...this.targets.values()];
208
- }
209
- /**
210
- * Waits until at least one target matching `filterFn` is attached, then
211
- * resolves with the full target list at that moment.
212
- *
213
- * Resolution happens on whichever comes first:
214
- * (a) a `'target:attached'` event from `refreshTargets()` (triggered by
215
- * the /targets poll finding a new target), OR
216
- * (b) a `'target:attached'` event from `handleMessage()` (triggered by
217
- * the first inbound CDP message from a target — confirms the relay
218
- * websocket has data from the phone, not just a target entry in the map).
219
- *
220
- * This dual-signal approach eliminates the polling race that previously
221
- * caused `wait_for_attach` to resolve before the first CDP message arrived.
222
- *
223
- * Falls back to checking `listTargets()` every `pollIntervalMs` in case the
224
- * EventEmitter is missed (defensive belt-and-suspenders).
225
- *
226
- * @param filterFn - Predicate that the returned targets must satisfy.
227
- * @param timeoutMs - Reject after this many ms (default 90 000). Pass a
228
- * non-finite value (`Infinity`) to disable the rejection timer entirely —
229
- * used by the test-runner's unbounded QR-attach wait (devtools#735). Node
230
- * clamps `setTimeout(fn, Infinity)` to ~1ms, so a non-finite value MUST
231
- * skip arming the timer rather than pass it through.
232
- * @param pollIntervalMs - Fallback poll interval (default 500ms).
233
- */
234
- waitForFirstTarget(filterFn, timeoutMs = 9e4, pollIntervalMs = 500) {
235
- const current = this.listTargets();
236
- if (filterFn(current)) return Promise.resolve(current);
237
- return new Promise((resolve, reject) => {
238
- let settled = false;
239
- let pollHandle = null;
240
- const settle = (targets) => {
241
- if (settled) return;
242
- settled = true;
243
- if (timeoutHandle !== null) clearTimeout(timeoutHandle);
244
- if (pollHandle !== null) {
245
- clearInterval(pollHandle);
246
- pollHandle = null;
247
- }
248
- this.emitter.off("target:attached", onAttach);
249
- resolve(targets);
250
- };
251
- const onAttach = (targets) => {
252
- if (filterFn(targets)) settle(targets);
253
- };
254
- const timeoutHandle = Number.isFinite(timeoutMs) ? setTimeout(() => {
255
- if (settled) return;
256
- settled = true;
257
- if (pollHandle !== null) {
258
- clearInterval(pollHandle);
259
- pollHandle = null;
260
- }
261
- this.emitter.off("target:attached", onAttach);
262
- reject(/* @__PURE__ */ new Error(`waitForFirstTarget: 타임아웃 (${timeoutMs}ms) — 폰이 relay에 attach되지 않았습니다.`));
263
- }, timeoutMs) : null;
264
- this.emitter.on("target:attached", onAttach);
265
- pollHandle = setInterval(() => {
266
- this.refreshTargets().then((targets) => {
267
- if (filterFn(targets)) settle(targets);
268
- }, () => {});
269
- }, pollIntervalMs);
270
- });
271
- }
272
- /**
273
- * Timestamp (ms since epoch) of the most recent crash/destroy/detach event
274
- * detected since the last `enableDomains()` call, or `null` if none.
275
- */
276
- getLastCrashDetectedAt() {
277
- return this.lastCrashDetectedAt;
278
- }
279
- /**
280
- * Last-seen timestamp (ms since epoch) for a given target id, or `null` if
281
- * the target is unknown / no message has been received from it yet.
282
- */
283
- getTargetLastSeenAt(targetId) {
284
- return this.targetLastSeenAt.get(targetId) ?? null;
285
- }
286
- /** Subscribe to target lifecycle events (crash / destroyed / detached). */
287
- onLifecycle(listener) {
288
- this.lifecycleListeners.push(listener);
289
- return () => {
290
- const idx = this.lifecycleListeners.indexOf(listener);
291
- if (idx !== -1) this.lifecycleListeners.splice(idx, 1);
292
- };
293
- }
294
- /**
295
- * Connect a client websocket to the first attached target and enable Phase 1
296
- * domains. Resolves once the socket is open and enable commands are sent.
297
- */
298
- async enableDomains() {
299
- if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
300
- if (this.enablingPromise) return this.enablingPromise;
301
- this.enablingPromise = this._doEnableDomains().finally(() => {
302
- this.enablingPromise = null;
303
- });
304
- return this.enablingPromise;
305
- }
306
- async _doEnableDomains() {
307
- const target = (await this.refreshTargets())[0];
308
- if (!target) throw new Error("No mini-app page attached to the Chii relay yet.");
309
- let clientUrl = `${this.relayBaseUrl.replace(/^http/, "ws")}/client/${`devtools-mcp-${Date.now()}`}?target=${encodeURIComponent(target.id)}`;
310
- if (this.totpSecret) {
311
- const code = generateTotp(this.totpSecret);
312
- clientUrl += `&at=${encodeURIComponent(code)}`;
313
- }
314
- const ws = new WebSocket(clientUrl);
315
- this.ws = ws;
316
- await new Promise((resolve, reject) => {
317
- ws.once("open", () => resolve());
318
- ws.once("error", (err) => reject(err));
319
- ws.once("close", (code) => {
320
- if (code === 4401) reject(/* @__PURE__ */ new Error("relay 인증(TOTP)이 거부됐습니다 (close 4401). 코드가 만료됐을 수 있습니다 — 재연결 시 새 코드가 발급됩니다."));
321
- });
322
- });
323
- this.lastCrashDetectedAt = null;
324
- this.targetLastSeenAt.clear();
325
- this.connectionState = "connected";
326
- ws.on("message", (data) => this.handleMessage(data.toString()));
327
- ws.on("close", (code) => this.handleDisconnect(code === 4401 ? "relay 인증(TOTP)이 거부돼 연결이 종료됐습니다 (close 4401)" : "relay WebSocket 연결이 끊겼습니다"));
328
- ws.on("error", (err) => this.handleDisconnect(`relay WebSocket 오류: ${err.message}`));
329
- this.sendFireAndForget("Runtime.enable");
330
- this.sendFireAndForget("Network.enable");
331
- this.sendFireAndForget("DOM.enable");
332
- this.sendFireAndForget("Page.enable");
333
- this.sendFireAndForget("Inspector.enable");
334
- this.sendFireAndForget("Target.setDiscoverTargets", { discover: true });
335
- this.startHeartbeat(target.id);
336
- }
337
- /** Fire-and-forget CDP message (used for `*.enable`, no result awaited). */
338
- sendFireAndForget(method, params = {}) {
339
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
340
- const id = this.nextCommandId++;
341
- this.ws.send(JSON.stringify({
342
- id,
343
- method,
344
- params
345
- }));
346
- }
347
- /**
348
- * Issue a CDP command and resolve with its result (Phase 2). Rejects on a CDP
349
- * error frame or when no websocket is open (no page attached yet).
350
- *
351
- * @param opts.timeoutMs - Per-call override for this connection's command
352
- * watchdog (devtools#747) — see the `CdpConnection.send` docblock for the
353
- * contract callers racing their own longer timeout must follow.
354
- */
355
- send(method, params, opts) {
356
- return this.sendCommand(method, params ?? {}, opts);
357
- }
358
- /**
359
- * Issue an arbitrary request→response command over the relay and resolve with
360
- * its raw result. Both the typed CDP {@link send} and the AIT domain (Phase 3
361
- * `AIT.*` methods, forwarded over the same Chii channel) build on this.
362
- *
363
- * Rejects immediately if the connection is disconnected (fail-fast — no
364
- * auto-reconnect). Caller should re-run `list_pages` or `enableDomains` to
365
- * reattach.
366
- *
367
- * Times out after `opts.timeoutMs` when given, else `commandTimeoutMs`
368
- * (default 30s, env `AIT_CDP_COMMAND_TIMEOUT_MS`) — see devtools#747: the
369
- * default 30s watchdog used to undercut the test-runner's own longer
370
- * file-evaluate race no matter what `--timeout` the caller asked for. On
371
- * timeout the pending entry is cleaned up and the promise rejects with a
372
- * descriptive Korean error. `Number.isFinite` guards against a non-finite
373
- * override (e.g. `Infinity`, mirroring `waitForFirstTarget`'s convention)
374
- * so an intentional "no watchdog" override doesn't get clamped by
375
- * `setTimeout`.
376
- */
377
- sendCommand(method, params = {}, opts) {
378
- if (this.connectionState === "disconnected") return Promise.reject(/* @__PURE__ */ new Error(`relay에 연결되어 있지 않습니다 (${method}). list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`));
379
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return Promise.reject(/* @__PURE__ */ new Error("No mini-app page attached to the Chii relay yet. Call enableDomains() first."));
380
- const id = this.nextCommandId++;
381
- const ws = this.ws;
382
- const timeoutMs = opts?.timeoutMs !== void 0 && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0 ? opts.timeoutMs : this.commandTimeoutMs;
383
- return new Promise((resolve, reject) => {
384
- const handle = setTimeout(() => {
385
- this.pending.delete(id);
386
- reject(/* @__PURE__ */ new Error(`CDP 명령이 타임아웃됐습니다 (${method}, ${timeoutMs}ms). 폰 측 토스 앱이 백그라운드로 내려갔거나 미니앱이 unload됐을 수 있습니다. list_pages로 attach 상태를 확인하세요.`));
387
- }, timeoutMs);
388
- this.pending.set(id, {
389
- resolve: (v) => {
390
- clearTimeout(handle);
391
- resolve(v);
392
- },
393
- reject: (e) => {
394
- clearTimeout(handle);
395
- reject(e);
396
- }
397
- });
398
- ws.send(JSON.stringify({
399
- id,
400
- method,
401
- params
402
- }));
403
- });
404
- }
405
- /**
406
- * Called on WebSocket `close` or `error` after a successful connection.
407
- * Rejects all pending commands and marks the connection as disconnected so
408
- * subsequent `sendCommand` calls fail fast (no auto-reconnect).
409
- */
410
- handleDisconnect(reason) {
411
- if (this.connectionState === "disconnected") return;
412
- this.connectionState = "disconnected";
413
- this.ws = null;
414
- this.stopHeartbeat();
415
- const err = /* @__PURE__ */ new Error(`${reason}. list_pages로 attach 상태를 확인하고 enableDomains()로 재연결하세요.`);
416
- for (const waiter of this.pending.values()) waiter.reject(err);
417
- this.pending.clear();
418
- }
419
- /**
420
- * Evict a previously active target under the single-attach model.
421
- * Rejects pending commands with a 'replaced-by-new-attach' reason and emits
422
- * a 'replaced' lifecycle event. Does NOT clear all targets — only the specific
423
- * targetId. The caller is responsible for rebuilding the targets map afterwards.
424
- *
425
- * The error message uses 'replaced-by-new-attach' so test assertions can match it.
426
- */
427
- evictTarget(targetId) {
428
- const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
429
- this.targets.delete(targetId);
430
- this.targetLastSeenAt.delete(targetId);
431
- const err = /* @__PURE__ */ new Error(`[ait-debug] replaced-by-new-attach — 이전 page 세션이 새 attach로 교체됐습니다 (targetId=${targetId}). list_pages로 현재 attach 상태를 확인하세요.`);
432
- for (const waiter of this.pending.values()) waiter.reject(err);
433
- this.pending.clear();
434
- const event = {
435
- kind: "replaced",
436
- targetId,
437
- detectedAt
438
- };
439
- for (const listener of this.lifecycleListeners) try {
440
- listener(event);
441
- } catch {}
442
- }
443
- /**
444
- * Handle a page-level crash or target destruction event.
445
- * Removes the target from the in-memory map, rejects all pending commands,
446
- * and emits a lifecycle event.
447
- *
448
- * @param kind - Event kind: 'crashed' | 'destroyed' | 'detached'
449
- * @param targetId - The target ID from the event params (may be null for
450
- * Inspector.targetCrashed which has no targetId in the params).
451
- */
452
- handleTargetGone(kind, targetId) {
453
- const detectedAt = (/* @__PURE__ */ new Date()).toISOString();
454
- this.lastCrashDetectedAt = Date.now();
455
- if (targetId !== null) {
456
- this.targets.delete(targetId);
457
- this.targetLastSeenAt.delete(targetId);
458
- if (this.activeTargetId === targetId) this.activeTargetId = null;
459
- } else {
460
- this.targets.clear();
461
- this.targetLastSeenAt.clear();
462
- this.activeTargetId = null;
463
- }
464
- const err = /* @__PURE__ */ new Error(`[ait-debug] ${kind === "crashed" ? "page crash (Inspector.targetCrashed)" : kind === "destroyed" ? "target 종료 (Target.targetDestroyed)" : "target detach (Target.detachedFromTarget)"} 감지됨 — relay에서 제거됐습니다. 새 attach가 필요합니다 (list_pages로 확인 → enableDomains()로 재연결).`);
465
- for (const waiter of this.pending.values()) waiter.reject(err);
466
- this.pending.clear();
467
- const event = {
468
- kind,
469
- targetId,
470
- detectedAt
471
- };
472
- for (const listener of this.lifecycleListeners) try {
473
- listener(event);
474
- } catch {}
475
- }
476
- /**
477
- * Start the optional CDP heartbeat loop.
478
- *
479
- * When `AIT_CDP_HEARTBEAT_MS` is set to a positive integer, every interval
480
- * we send `Runtime.evaluate({expression: '1'})` to each active target. If
481
- * the command times out (2 s hard deadline) or errors, we treat the target
482
- * as dead and call `handleTargetGone`.
483
- *
484
- * This is a zombie-detector fallback: cloudflared keeps-alive the tunnel ws
485
- * even when the phone app has crashed, so the ws-level disconnect (#252) won't
486
- * fire. The heartbeat catches this gap.
487
- *
488
- * Default: OFF. Only activates when `AIT_CDP_HEARTBEAT_MS` is set.
489
- */
490
- startHeartbeat(initialTargetId) {
491
- this.stopHeartbeat();
492
- const envMs = process.env.AIT_CDP_HEARTBEAT_MS ? Number(process.env.AIT_CDP_HEARTBEAT_MS) : void 0;
493
- if (envMs === void 0 || !Number.isFinite(envMs) || envMs <= 0) return;
494
- const PING_TIMEOUT_MS = 2e3;
495
- this.heartbeatHandle = setInterval(() => {
496
- const targetIds = this.targets.size > 0 ? [...this.targets.keys()] : [initialTargetId];
497
- for (const targetId of targetIds) {
498
- const pingPromise = this.sendCommand("Runtime.evaluate", {
499
- expression: "1",
500
- returnByValue: true,
501
- timeout: PING_TIMEOUT_MS
502
- });
503
- const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("heartbeat timeout")), PING_TIMEOUT_MS + 500));
504
- Promise.race([pingPromise, timeoutPromise]).catch(() => {
505
- if (this.targets.has(targetId)) this.handleTargetGone("destroyed", targetId);
506
- });
507
- }
508
- }, envMs);
509
- }
510
- stopHeartbeat() {
511
- if (this.heartbeatHandle !== null) {
512
- clearInterval(this.heartbeatHandle);
513
- this.heartbeatHandle = null;
514
- }
515
- }
516
- handleMessage(raw) {
517
- const message = parseInbound(raw);
518
- if (!message) return;
519
- if (typeof message.id === "number" && this.pending.has(message.id)) {
520
- const waiter = this.pending.get(message.id);
521
- this.pending.delete(message.id);
522
- if (waiter) if (message.error) waiter.reject(new Error(message.error.message));
523
- else waiter.resolve(message.result);
524
- return;
525
- }
526
- const now = Date.now();
527
- let firstMessageSeen = false;
528
- for (const targetId of this.targets.keys()) {
529
- if (!this.targetLastSeenAt.has(targetId)) firstMessageSeen = true;
530
- this.targetLastSeenAt.set(targetId, now);
531
- }
532
- if (firstMessageSeen && this.targets.size > 0) this.emitter.emit("target:attached", [...this.targets.values()]);
533
- if (typeof message.method !== "string") return;
534
- if (message.method === "Inspector.targetCrashed") {
535
- this.handleTargetGone("crashed", null);
536
- return;
537
- }
538
- if (message.method === "Target.targetDestroyed") {
539
- const targetId = isObject(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
540
- this.handleTargetGone("destroyed", targetId);
541
- return;
542
- }
543
- if (message.method === "Target.detachedFromTarget") {
544
- const targetId = isObject(message.params) && typeof message.params.targetId === "string" ? message.params.targetId : null;
545
- this.handleTargetGone("detached", targetId);
546
- return;
547
- }
548
- if (!this.buffers.has(message.method)) return;
549
- const event = message.method;
550
- const buffer = this.buffers.get(event);
551
- if (!buffer) return;
552
- buffer.push(message.params);
553
- const cap = event === "Runtime.exceptionThrown" ? EXCEPTION_BUFFER_SIZE : this.bufferSize;
554
- if (buffer.length > cap) buffer.shift();
555
- this.emitter.emit(event, message.params);
556
- }
557
- getBufferedEvents(event) {
558
- return this.buffers.get(event) ?? [];
559
- }
560
- on(event, listener) {
561
- this.emitter.on(event, listener);
562
- return () => this.emitter.off(event, listener);
563
- }
564
- /** Close the relay client websocket and reject any in-flight commands. */
565
- close() {
566
- const ws = this.ws;
567
- this.stopHeartbeat();
568
- this.handleDisconnect("Chii relay connection closed");
569
- ws?.close();
570
- }
571
- };
572
- new RegExp(`^${"@apps-in-toss/web-framework".replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`);
573
- `
574
- devtools-test — run mini-app tests on a real device WebView over the CDP relay
575
-
576
- USAGE
577
- devtools-test <glob> [<glob> ...] [options]
578
-
579
- OPTIONS
580
- --scheme-url <url> intoss-private:// URL from \`ait deploy --scheme-only\`
581
- (required for standalone relay attach / env3). Unused
582
- and ignored when --attach-launcher is set.
583
- --attach-launcher Attach over the env-2 AITC Sandbox PWA launcher
584
- (real-device WebKit) instead of the env-3 intoss
585
- scheme deep-link. The relay/QR/dashboard/attach-wait
586
- are identical; only the QR is a launcher deep-link.
587
- Requires --app-url. The SDK still hits the mock in
588
- env 2, so this measures the mock + standard Web API
589
- layer's engine-attributable behavior (env1↔env2
590
- equivalence), NOT native-bridge fidelity (that is env3).
591
- --app-url <url> The consumer dev server's HTTP tunnel URL (e.g. the
592
- *.trycloudflare.com URL from \`pnpm dev:phone:cdp\`)
593
- that the launcher PWA frames. REQUIRED with
594
- --attach-launcher; ignored otherwise. SECRET: a tunnel
595
- host — never printed; it rides only inside the QR.
596
- --timeout <ms> Per-file evaluate timeout in ms (default: 60000).
597
- Controls how long a single test file is allowed to run
598
- before it is considered hung. Does NOT affect how long
599
- the CLI waits for a human to scan the QR code — use
600
- --attach-timeout for that.
601
- --attach-timeout <ms> How long to wait for a human to scan the QR code with
602
- their phone. Omit (default) to wait indefinitely — the
603
- runner stays up until you stop it (Ctrl-C/SIGTERM).
604
- Pass a value to bound the wait for CI/headless runs.
605
- --cell-sdk-line <line> SDK line to inject as __AIT_CELL__.sdkLine (2.x|3.x)
606
- --cell-platform <plat> Platform to inject as __AIT_CELL__.platform
607
- (mock|ios|android|ios-pwa, default: AIT_CELL_PLATFORM
608
- env). Use ios-pwa for env-2 (--attach-launcher) runs
609
- so env1(mock@desktop) and env2(mock@WebKit) captures
610
- stay distinguishable. An unknown value is rejected.
611
- --report-dir <dir> Persist a runner-agnostic report + captures to <dir>
612
- (report: <sdkLine>.<platform>.json; captures:
613
- <dir>/.ait-capture/<category>.<sdkLine>.<platform>.json).
614
- Omitted = nothing saved. Enables console capture.
615
- --dashboard-port <port> Base port for the QR dashboard HTTP server. On
616
- EADDRINUSE it increments (+1, up to 20 tries) before
617
- falling back to an ephemeral port. Omit to use
618
- AIT_DEBUG_HTTP_PORT env or the built-in default
619
- (8317) — pass 0 to force a random ephemeral port.
620
- --no-qr-stdout Suppress the QR/attach block on stdout (auto-on for
621
- non-interactive stdout / CI / AIT_NO_QR_STDOUT)
622
- --headless Disable browser auto-open (text QR only)
623
- --project-root <dir> Project root for .ait_relay secret lookup
624
- (default: current working directory)
625
- --pace <ms> Minimum delay in ms between test-to-test AND
626
- file-to-file bridge calls (default: 0, i.e. no
627
- added delay — today's behavior byte-for-byte).
628
- Falls back to the AIT_PACE env var when omitted
629
- (--pace takes precedence over the env var when both
630
- are given). Use on a 2.x cell scan when the native
631
- per-method bridge rate limit (APP_BRIDGE_THROTTLED,
632
- devtools#767) is rejecting rapid same-method calls —
633
- 3.x cells are unaffected by that limiter and do not
634
- need this flag.
635
- --pace-method <ms> Minimum delay in ms BETWEEN calls to the SAME named
636
- SDK function — paces a same-method burst WITHIN a
637
- single test body (e.g. a clipboard happy-path loop
638
- calling setClipboardText/getClipboardText 8 times
639
- back to back), which --pace's test/file spacing
640
- cannot reach (devtools#769). Falls back to the
641
- AIT_PACE_METHOD env var when omitted (--pace-method
642
- takes precedence over the env var when both are
643
- given). Default: 250ms when --cell-sdk-line is 2.x
644
- (unset --cell-sdk-line also defaults to 2.x — see
645
- --cell-sdk-line), 0 (no added delay) otherwise. Pass
646
- --pace-method 0 to opt out even on a 2.x cell.
647
- --manual-blocking Run manual-tagged test files (*.manual.ait.test.ts)
648
- LAST, after all regular files, with a human present.
649
- Before each manual file, the QR dashboard is pushed
650
- a step-by-step Korean prompt naming the file + its
651
- progress (k/n), and the same line is printed to
652
- stdout. Manual files get a 5-minute per-file evaluate
653
- timeout (vs. --timeout for everything else) since a
654
- human is expected to tap through a native sheet
655
- (photo picker, permission dialog, fullscreen ad).
656
- Without this flag (default off), *.manual.ait.test.ts
657
- files are EXCLUDED from the glob expansion entirely —
658
- existing unattended runs are byte-for-byte unaffected.
659
- With --report-dir, a run that included manual files
660
- ALSO writes <sdkLine>.<platform>.manual.json
661
- alongside (never replacing) the standard report, and
662
- each manual file's report entry is stamped
663
- mode: 'manual' — never diff a manual run against an
664
- unattended baseline as if they were equivalent.
665
- --stub-blocking Run manual-tagged test files (*.manual.ait.test.ts)
666
- UNATTENDED (devtools#740, DT-2) by intercepting a
667
- fixed allowlist of blocking-UI SDK calls (ads
668
- show*, openPermissionDialog/requestPermission,
669
- saveBase64Data) in the page and answering them from
670
- fixtures captured by a real --manual-blocking run,
671
- instead of forwarding them to native UI. Implies
672
- --manual-blocking (manual files are included in the
673
- run); no human presence or QR-dashboard prompt is
674
- needed for them. HYBRID cell, not pure env3 — every
675
- other SDK call in the same run still hits the real
676
- native bridge. With --report-dir, files that ran
677
- under the stub are written to a SEPARATE
678
- <sdkLine>.<platform>.stubbed.json artifact (never
679
- merged into the standard or .manual.json report) and
680
- the report body is stamped cell.bridgeStub: true —
681
- never diff a stubbed run against a real device
682
- baseline (manual or unattended) as if equivalent.
683
- --help, -h Show this help message
684
-
685
- DESCRIPTION
686
- Boots a Chii relay + cloudflared tunnel, renders a QR code, waits for a real
687
- device to scan and attach, injects the cell globals (__AIT_CELL__), bundles
688
- each matched test file with esbuild (SDK imports redirected to window.__sdk),
689
- injects the bundle into the attached WebView via Runtime.evaluate, and prints
690
- a summary.
691
-
692
- With --report-dir, also harvests __AIT_CAPTURE__ console lines and writes a
693
- runner-agnostic report + per-category capture files so 2.x↔3.0 runs can be
694
- compared offline.
695
-
696
- The test files run against the live relay connection started by this process;
697
- no separate MCP daemon is required.
698
-
699
- EXAMPLE (env 3 — intoss-private scheme)
700
- devtools-test 'src/**/*.ait.test.ts' \\
701
- --scheme-url "intoss-private://..." \\
702
- --cell-sdk-line 3.x \\
703
- --cell-platform ios \\
704
- --report-dir .ait-report \\
705
- --timeout 60000
706
-
707
- EXAMPLE (env 2 — AITC Sandbox PWA launcher)
708
- devtools-test 'src/**/*.ait.test.ts' \\
709
- --attach-launcher \\
710
- --app-url "https://<subdomain>.trycloudflare.com" \\
711
- --cell-sdk-line 3.x \\
712
- --cell-platform ios-pwa \\
713
- --report-dir .ait-report
714
-
715
- `.trimStart();
716
- //#endregion
717
- //#region src/mcp/chii-relay.ts
718
- /**
719
- * Boots the local Chii relay server.
720
- *
721
- * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome
722
- * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.
723
- * The relay accepts a `target` websocket from the phone's injected `target.js`
724
- * and `client` websockets from CDP frontends (our MCP connection).
725
- *
726
- * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app
727
- * entries.
728
- *
729
- * TOTP auth (relay-side, authoritative gate):
730
- * When `verifyAuth` is provided, this module gates both inbound surfaces:
731
- *
732
- * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.
733
- * Node's `http.Server` calls listeners in registration order; the first
734
- * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny
735
- * JSON body (`{"error":"totp-rejected"}`) so a cross-origin script
736
- * `fetch()` probe can READ the status (issue #478). Valid auth → return
737
- * without side-effect (chii's Koa handler serves it).
738
- *
739
- * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade
740
- * listener, we take over the upgrade chain (remove chii's listeners,
741
- * re-dispatch manually). Invalid auth → accept-then-close: complete the
742
- * handshake via a `noServer` WebSocketServer, then immediately close
743
- * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +
744
- * `socket.destroy()` only ever surfaced as close code 1006 in the
745
- * browser — indistinguishable from a tunnel failure, which left the
746
- * env-2 phone UI silent. The explicit dispatch (not listener ordering)
747
- * is what keeps chii away from rejected sockets: accept-then-close
748
- * leaves the socket alive, so an order-based early-return would let
749
- * chii's later listener complete a SECOND handshake on the same socket
750
- * — an auth bypass. Valid auth → forward to chii's captured listeners.
751
- *
752
- * TOTP code transports (issue #466) — two equivalent ways to carry the code:
753
- * 1. Query param `at=<code>` — used by the daemon-side `/client` connection
754
- * (`chii-connection.ts` appends it; it holds the secret).
755
- * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's
756
- * stock `target.js` derives its WS endpoint from the script `src`
757
- * (`scriptEl.src.replace('target.js','')`), so the only way for the
758
- * phone to carry a code is to embed it in the script URL path. The
759
- * in-app attach injects `https://<host>/at/<code>/target.js`; both the
760
- * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS
761
- * dial then carry the prefix. The listeners below rewrite the prefix
762
- * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`
763
- * before chii's own handlers (registered later) parse it — chii only
764
- * ever sees the stripped URL.
765
- *
766
- * Threat model: "URL leak" — someone obtains the tunnel URL (Slack paste, QR
767
- * screenshot, shoulder-surfing) but does not have the shared TOTP secret.
768
- * Rotating 6-digit code makes the URL stale after 30 s.
769
- * A determined attacker who extracts the secret from the dogfood bundle can
770
- * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).
771
- *
772
- * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear
773
- * in any log, error message, or process output. `verifyAuth` is a black-box
774
- * predicate from the caller's perspective; this module only forwards pass/fail.
775
- */
776
- const require = createRequire(import.meta.url);
777
- /**
778
- * WS keepalive ping interval (ms).
779
- *
780
- * Cloudflare proxied connections are dropped after ~100 s of no traffic.
781
- * 45 s comfortably fits inside that window and lets both the phone-target leg
782
- * and the daemon-client leg survive idle CDP sessions.
783
- */
784
- const DEFAULT_KEEPALIVE_INTERVAL_MS = 45e3;
785
- /**
786
- * Loads chii's internal WebSocketServer class and returns it together with a
787
- * flag indicating whether the real class was found.
788
- *
789
- * Returns `null` if the internal path is not resolvable (future chii release
790
- * changes the layout) — callers skip keepalive gracefully.
791
- */
792
- function tryLoadChiiWssClass() {
793
- try {
794
- const mod = require("chii/server/lib/WebSocketServer");
795
- if (typeof mod === "function") return mod;
796
- } catch {}
797
- return null;
798
- }
799
- /**
800
- * Calls `chii.start()` and returns the chii `WebSocketServer` instance that
801
- * was constructed during the call.
802
- *
803
- * How: `chii/server/index.js`'s `start()` creates `new WebSocketServer()`
804
- * where `WebSocketServer` is captured from `require('./lib/WebSocketServer')`
805
- * at module load time. The class reference is stable, so we can temporarily
806
- * patch `ChiiWssClass.prototype.start` — which runs *on the instance* —
807
- * to record `this` before the original `start` runs.
808
- *
809
- * The patch is installed before `chii.start()` and removed (via `finally`)
810
- * immediately after, so concurrent `startChiiRelay` calls nest correctly: each
811
- * call's patch overrides the previous in the prototype chain for the duration
812
- * of its own `chii.start()` call, restoring the prior descriptor on exit.
813
- *
814
- * If `ChiiWssClass` is null (internal path changed in a future chii release),
815
- * `chii.start()` runs unpatched and the function returns null — callers skip
816
- * keepalive gracefully without affecting relay correctness.
817
- */
818
- async function startChiiWithCapture(chii, startOptions, ChiiWssClass) {
819
- if (ChiiWssClass === null) {
820
- await chii.start(startOptions);
821
- return null;
822
- }
823
- let captured = null;
824
- const proto = ChiiWssClass.prototype;
825
- const originalStart = proto.start;
826
- proto.start = function(server) {
827
- captured = this;
828
- return originalStart.call(this, server);
829
- };
830
- try {
831
- await chii.start(startOptions);
832
- } finally {
833
- proto.start = originalStart;
834
- }
835
- return captured;
836
- }
837
- function loadChiiServer() {
838
- const mod = require("chii");
839
- if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
840
- throw new Error("chii server module did not expose start()");
841
- }
842
- /**
843
- * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent
844
- * query-based form, e.g.:
845
- *
846
- * `/at/123456/target.js` → `/target.js?at=123456`
847
- * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`
848
- * `/at/123456/` → `/?at=123456`
849
- *
850
- * Returns `null` when the URL does not carry the prefix (including an empty
851
- * code segment) — callers fall back to the unmodified URL and the existing
852
- * query-based auth path.
853
- *
854
- * Pure string surgery — this function knows nothing about secrets or code
855
- * validity; verification stays inside the caller-provided `verifyAuth`
856
- * predicate (which parses the query). The raw path segment is appended
857
- * verbatim to the query: both path segments and query values are
858
- * percent-decoded exactly once by their consumers, so no re-encoding is
859
- * needed (TOTP codes are 6 digits and never percent-encoded in practice).
860
- */
861
- function rewriteAtPathPrefix(rawUrl) {
862
- const match = /^\/at\/([^/?]+)(\/[^?]*)?(\?.*)?$/.exec(rawUrl);
863
- if (match === null) return null;
864
- const code = match[1];
865
- const path = match[2] === void 0 || match[2] === "" ? "/" : match[2];
866
- const query = match[3] ?? "";
867
- return `${path}${query}${query === "" ? "?" : "&"}at=${code}`;
868
- }
869
- /**
870
- * Starts the Chii relay and resolves once listening.
871
- *
872
- * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral
873
- * port on every start, so a stale cloudflared orphan holding any particular
874
- * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`
875
- * always reflect the actual bound port.
876
- *
877
- * chii.start() is called with `server` (our pre-created httpServer) BEFORE
878
- * httpServer.listen(). This is intentional: chii attaches its Koa handler and
879
- * WS upgrade listener to the server object, but the actual TCP bind is
880
- * performed by our httpServer.listen() call below. The `port`/`domain` values
881
- * passed to chii.start() are used for display/banner purposes inside chii and
882
- * do not affect which port the server binds. The connection path (clients
883
- * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.
884
- */
885
- async function startChiiRelay(options = {}) {
886
- const requestedPort = options.port ?? 0;
887
- const host = options.host ?? "127.0.0.1";
888
- const { verifyAuth, onAuthReject } = options;
889
- const keepaliveIntervalMs = options.keepaliveIntervalMs !== void 0 ? options.keepaliveIntervalMs : DEFAULT_KEEPALIVE_INTERVAL_MS;
890
- const httpServer = createServer();
891
- const notifyAuthReject = (kind) => {
892
- if (onAuthReject === void 0) return;
893
- try {
894
- onAuthReject({ kind });
895
- } catch {}
896
- };
897
- if (verifyAuth) httpServer.on("request", (req, res) => {
898
- const rewritten = rewriteAtPathPrefix(req.url ?? "");
899
- if (rewritten !== null) {
900
- req.url = rewritten;
901
- if (!verifyAuth(req)) {
902
- res.statusCode = 401;
903
- res.setHeader("Access-Control-Allow-Origin", "*");
904
- res.setHeader("Content-Type", "application/json");
905
- res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));
906
- notifyAuthReject("http-request");
907
- }
908
- return;
909
- }
910
- const pathname = (req.url ?? "").split("?")[0];
911
- if (pathname === "/targets" || pathname === "/targets/") {
912
- if (!verifyAuth(req)) {
913
- res.statusCode = 401;
914
- res.setHeader("Access-Control-Allow-Origin", "*");
915
- res.setHeader("Content-Type", "application/json");
916
- res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));
917
- notifyAuthReject("http-request");
918
- return;
919
- }
920
- return;
921
- }
922
- });
923
- const chiiWssClass = keepaliveIntervalMs > 0 ? tryLoadChiiWssClass() : null;
924
- const capturedChiiWss = await startChiiWithCapture(loadChiiServer(), {
925
- server: httpServer,
926
- domain: `${host}:${requestedPort}`,
927
- port: requestedPort
928
- }, chiiWssClass);
929
- if (verifyAuth) {
930
- const chiiUpgradeListeners = httpServer.listeners("upgrade");
931
- httpServer.removeAllListeners("upgrade");
932
- const rejectWss = new WebSocketServer({ noServer: true });
933
- httpServer.on("upgrade", (req, socket, head) => {
934
- const rewritten = rewriteAtPathPrefix(req.url ?? "");
935
- if (rewritten !== null) req.url = rewritten;
936
- if (!verifyAuth(req)) {
937
- rejectWss.handleUpgrade(req, socket, head, (ws) => {
938
- ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);
939
- });
940
- notifyAuthReject("ws-upgrade");
941
- return;
942
- }
943
- for (const listener of chiiUpgradeListeners) listener(req, socket, head);
944
- });
945
- }
946
- const actualPort = await new Promise((resolve, reject) => {
947
- httpServer.once("error", reject);
948
- httpServer.listen(requestedPort, host, () => {
949
- httpServer.off("error", reject);
950
- resolve(httpServer.address().port);
951
- });
952
- });
953
- let keepaliveHandle = null;
954
- if (keepaliveIntervalMs > 0 && capturedChiiWss !== null) {
955
- const chiiWss = capturedChiiWss;
956
- keepaliveHandle = setInterval(() => {
957
- for (const client of chiiWss._wss.clients) if (client.readyState === 1) client.ping();
958
- }, keepaliveIntervalMs);
959
- }
960
- return {
961
- port: actualPort,
962
- baseUrl: `http://${host}:${actualPort}`,
963
- close: () => new Promise((resolve) => {
964
- if (keepaliveHandle !== null) {
965
- clearInterval(keepaliveHandle);
966
- keepaliveHandle = null;
967
- }
968
- if (capturedChiiWss !== null) for (const client of capturedChiiWss._wss.clients) client.terminate();
969
- httpServer.close(() => resolve());
970
- })
971
- };
972
- }
973
- //#endregion
974
- //#region src/mcp/debug-server.ts
975
- /**
976
- * Starts a polling watcher that detects target-set changes on
977
- * `connection.listTargets()` and sends a `notifications/tools/list_changed`
978
- * notification on the given server.
979
- *
980
- * The watcher polls every `intervalMs` (default 1 000 ms). On each tick it
981
- * calls `connection.refreshTargets?.()` first (fix #705-B) so that silent
982
- * disconnects (no CDP event, phone backgrounded / tunnel quiet) are picked up
983
- * before the signature is read. If `refreshTargets` throws — e.g. a transient
984
- * relay error — the tick is skipped entirely to avoid a spurious detach signal.
985
- *
986
- * After the refresh, it fires `server.sendToolListChanged()` + `onAttach()`
987
- * whenever the sorted target-id signature changes AND the new target set is
988
- * non-empty. This covers:
989
- * - 0→N first attach
990
- * - 1→1 target replacement (same count, different id — e.g. rescan)
991
- * - N→M any change where the result is still non-empty
992
- *
993
- * Full detach (→ empty) fires `onDetach()` (fix #705-A) on the exact
994
- * non-empty→empty edge — i.e. only when the previous signature was non-empty.
995
- * This lets callers push an immediate "disconnected" SSE update to the
996
- * dashboard without waiting for the next periodic interval.
997
- *
998
- * The interval is **never cleared automatically** — it keeps running until
999
- * `stop()` is called during shutdown. This ensures that a target replacement
1000
- * after the first attach is always detected.
1001
- *
1002
- * `onAttach` is called on every non-empty signature change (or immediately when
1003
- * already attached). Use this to trigger side-effects such as pushing a fresh
1004
- * SSE state to open dashboard tabs (issue #509). Both callbacks are optional;
1005
- * omitting them preserves the previous behaviour exactly.
1006
- *
1007
- * SECRET-HANDLING: target `id`/`title`/`url` are not written to any log here.
1008
- * Only an attach-detected stderr line is emitted (no target details).
1009
- *
1010
- * `server` is optional (devtools#772): the standalone test-runner path
1011
- * (`test-runner/relay-factory.ts`) has no MCP `Server` instance to notify —
1012
- * it only needs the `onAttach`/`onDetach` dashboard-push side effects. When
1013
- * omitted, `sendToolListChanged()` is simply skipped; the signature-diff and
1014
- * callback logic is byte-for-byte the same. Existing MCP daemon call sites
1015
- * always pass a real `Server`, so their behavior is unchanged.
1016
- *
1017
- * @returns `stop` — call this during shutdown to clear the interval.
1018
- */
1019
- function startAttachWatcher(connection, server, intervalMs = 1e3, onAttach, onDetach) {
1020
- /** Sorted, comma-joined target-id string — '' means no targets attached. */
1021
- function signature() {
1022
- return connection.listTargets().map((t) => t.id).sort().join(",");
1023
- }
1024
- let lastSignature = signature();
1025
- if (lastSignature !== "") {
1026
- server?.sendToolListChanged();
1027
- onAttach?.();
1028
- }
1029
- /** Compare current vs last signature and fire the appropriate callback. */
1030
- function tick() {
1031
- const current = signature();
1032
- if (current !== lastSignature) {
1033
- const wasNonEmpty = lastSignature !== "";
1034
- lastSignature = current;
1035
- if (current !== "") {
1036
- server?.sendToolListChanged();
1037
- onAttach?.();
1038
- } else if (wasNonEmpty) onDetach?.();
1039
- }
1040
- }
1041
- const handle = setInterval(() => {
1042
- if (connection.refreshTargets) connection.refreshTargets().then(() => {
1043
- tick();
1044
- }, (_err) => {});
1045
- else tick();
1046
- }, intervalMs);
1047
- return { stop() {
1048
- clearInterval(handle);
1049
- } };
1050
- }
1051
- /**
1052
- * Factory that constructs a `ChiiCdpConnection` for the given relay base URL.
1053
- *
1054
- * Introduced as a named seam so PR-2 (dual-connection, #348) can defer
1055
- * construction to first-activation time by moving or replacing this call. Since
1056
- * #396 every family (relay included) is constructed lazily on its first
1057
- * `start_debug`, so this is always called from the lazy boot path.
1058
- *
1059
- * The relay base URL is only available after `startChiiRelay()` resolves, so
1060
- * the factory is called right after that point (same as before this refactor).
1061
- */
1062
- function createRelayConnection(relayBaseUrl) {
1063
- return new ChiiCdpConnection({
1064
- relayBaseUrl,
1065
- totpSecret: process.env.AIT_DEBUG_TOTP_SECRET
1066
- });
1067
- }
1068
- /**
1069
- * Boots the relay family (issues #348, #356): starts the Chii relay on an
1070
- * OS-assigned port (with optional TOTP gate), opens a cloudflared quick tunnel
1071
- * to the relay's confirmed port in the background, prints the attach banner,
1072
- * and arms the tunnel health probe. Returns a {@link BootedFamily} whose
1073
- * `getTunnelStatus()` reflects the live tunnel (it flips up once the background
1074
- * tunnel resolves and follows reissues).
1075
- *
1076
- * Booted lazily via the dual router's `bootLazyFor('relay-intoss')` callback
1077
- * (symmetry with {@link bootLocalFamily}), at most once on the first
1078
- * `start_debug({ mode: 'relay-staging' })` (all-lazy, #396 — every relay boot now
1079
- * flows through `switchMode` after the project-local secret load). `relay-live`
1080
- * removed (#665).
1081
- *
1082
- * The relay base URL is only known after `startChiiRelay()` resolves, so the
1083
- * `ChiiCdpConnection` (via {@link createRelayConnection}) is constructed inside
1084
- * this function, after the relay port is confirmed.
1085
- *
1086
- * SECRET-HANDLING: the TOTP secret rides only inside `verifyAuth`; the wssUrl
1087
- * (relay host) is never logged here directly.
1088
- */
1089
- async function bootRelayFamily(options = {}) {
1090
- assertRelayAuthConfigured();
1091
- const relayPort = options.relayPort ?? 0;
1092
- const totpEnabled = options.verifyAuth !== void 0;
1093
- const relay = await startChiiRelay({
1094
- port: relayPort,
1095
- verifyAuth: options.verifyAuth,
1096
- onAuthReject: options.onAuthReject
1097
- });
1098
- logInfo("server.start", {
1099
- port: relay.port,
1100
- totpEnabled
1101
- });
1102
- let tunnel = null;
1103
- let tunnelStatus = makeTunnelStatus(false, null);
1104
- let tunnelProbe = null;
1105
- generateAttachToken();
1106
- startQuickTunnel(relay.port).then((t) => {
1107
- tunnel = t;
1108
- tunnelStatus = makeTunnelStatus(true, t.wssUrl);
1109
- options.onWssUrl?.(t.wssUrl);
1110
- if (t.childPid !== void 0) options.onTunnelChildPid?.(t.childPid);
1111
- logInfo("tunnel.up", { totpEnabled });
1112
- tunnelProbe = startTunnelHealthProbe(t, relay.port, {
1113
- onReissue: (newTunnel) => {
1114
- tunnel = newTunnel;
1115
- tunnelStatus = makeTunnelStatus(true, newTunnel.wssUrl, null, 0);
1116
- options.onWssUrl?.(newTunnel.wssUrl);
1117
- if (newTunnel.childPid !== void 0) options.onTunnelChildPid?.(newTunnel.childPid);
1118
- printAttachBanner({
1119
- wssUrl: newTunnel.wssUrl,
1120
- totpEnabled
1121
- }).then(() => {
1122
- logInfo("tunnel.up", {
1123
- totpEnabled,
1124
- reissued: true
1125
- });
1126
- });
1127
- },
1128
- onPermanentDrop: (droppedAt) => {
1129
- tunnelStatus = makeTunnelStatus(false, null, droppedAt, 3);
1130
- logError("tunnel.down", { msg: `tunnel permanently dropped (${droppedAt}). Restart: npx @ait-co/devtools devtools-mcp` });
1131
- options.onTunnelDown?.();
1132
- }
1133
- });
1134
- return printAttachBanner({
1135
- wssUrl: t.wssUrl,
1136
- totpEnabled
1137
- });
1138
- }, (err) => {
1139
- logError("tunnel.down", { msg: `Failed to open cloudflared quick tunnel: ${err instanceof Error ? err.message : String(err)}. The relay is up locally; attach over the public URL is unavailable until the tunnel starts.` });
1140
- });
1141
- const connection = createRelayConnection(relay.baseUrl);
1142
- return {
1143
- connection,
1144
- relayOrigin: "intoss-webview",
1145
- relayHttpUrl: relay.baseUrl,
1146
- getTunnelStatus: () => tunnelStatus,
1147
- stop() {
1148
- tunnelProbe?.stop();
1149
- tunnel?.stop();
1150
- connection.close();
1151
- relay.close();
1152
- }
1153
- };
1154
- }
1155
- //#endregion
1156
- export { bootRelayFamily, buildRelayVerifyAuth, startAttachWatcher };
1157
-
1158
- //# sourceMappingURL=debug-server-BWhwrVXa.js.map