@houwert/conductor 0.4.0 → 0.6.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 (53) hide show
  1. package/README.md +13 -10
  2. package/dist/commands/assert-not-visible.js +4 -0
  3. package/dist/commands/assert-visible.js +4 -0
  4. package/dist/commands/back.js +4 -0
  5. package/dist/commands/cheat-sheet.js +12 -8
  6. package/dist/commands/delete-device.js +222 -0
  7. package/dist/commands/device-pool.js +33 -22
  8. package/dist/commands/download-app.js +87 -0
  9. package/dist/commands/erase-text.js +4 -0
  10. package/dist/commands/focused.js +39 -0
  11. package/dist/commands/foreground-app.js +4 -0
  12. package/dist/commands/hide-keyboard.js +4 -0
  13. package/dist/commands/inspect.js +8 -0
  14. package/dist/commands/install-app.js +51 -0
  15. package/dist/commands/install.js +103 -27
  16. package/dist/commands/launch-app.js +6 -0
  17. package/dist/commands/list-devices.js +39 -2
  18. package/dist/commands/logs.js +193 -0
  19. package/dist/commands/press-key.js +16 -0
  20. package/dist/commands/screenshot.js +1 -1
  21. package/dist/commands/scroll-until-visible.js +11 -0
  22. package/dist/commands/scroll.js +6 -0
  23. package/dist/commands/start-device.js +38 -4
  24. package/dist/commands/stop-app.js +4 -0
  25. package/dist/commands/swipe.js +22 -0
  26. package/dist/commands/tap.js +10 -3
  27. package/dist/commands/type.js +2 -2
  28. package/dist/commands/uninstall-app.js +4 -0
  29. package/dist/daemon/client.js +72 -9
  30. package/dist/daemon/log-collector.js +408 -0
  31. package/dist/daemon/server.js +110 -32
  32. package/dist/daemon/web-server.js +812 -0
  33. package/dist/device-picker.js +7 -2
  34. package/dist/drivers/bootstrap.js +124 -1
  35. package/dist/drivers/element-resolver.js +241 -30
  36. package/dist/drivers/flow-runner.js +63 -21
  37. package/dist/drivers/log-sources/android.js +156 -0
  38. package/dist/drivers/log-sources/daemon.js +112 -0
  39. package/dist/drivers/log-sources/ios.js +106 -0
  40. package/dist/drivers/log-sources/metro.js +252 -0
  41. package/dist/drivers/log-sources/types.js +13 -0
  42. package/dist/drivers/log-sources/web.js +96 -0
  43. package/dist/drivers/wait.js +57 -0
  44. package/dist/drivers/web.js +173 -0
  45. package/dist/index.js +69 -12
  46. package/dist/runner.js +32 -2
  47. package/drivers/ios/conductor-driver-ios.zip +0 -0
  48. package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
  49. package/drivers/tvos/conductor-driver-tvos.zip +0 -0
  50. package/drivers/tvos/conductor-driver-tvosUITests-Runner.zip +0 -0
  51. package/package.json +5 -2
  52. package/skills/conductor/SKILL.md +83 -41
  53. package/skills/skills.yaml +1 -1
@@ -0,0 +1,408 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.LogCollector = void 0;
7
+ /**
8
+ * Daemon-side log collector — manages a platform-specific log source and
9
+ * buffers entries in a circular buffer for HTTP query access.
10
+ *
11
+ * Reuses the existing IOSLogSource / AndroidLogSource classes (which spawn
12
+ * simctl / adb logcat). For web, polls the co-located web-server's
13
+ * /consoleLogs endpoint.
14
+ *
15
+ * Optionally discovers and connects to a Metro dev server for React Native
16
+ * JS-level console logs. This is opt-in: call enableMetro(port) to start
17
+ * background discovery. Metro entries are merged into the same buffer with
18
+ * source='metro'.
19
+ */
20
+ const http_1 = __importDefault(require("http"));
21
+ const child_process_1 = require("child_process");
22
+ const types_js_1 = require("../drivers/log-sources/types.js");
23
+ const ios_js_1 = require("../drivers/log-sources/ios.js");
24
+ const android_js_1 = require("../drivers/log-sources/android.js");
25
+ const metro_js_1 = require("../drivers/log-sources/metro.js");
26
+ const MAX_BUFFER = 5000;
27
+ const RESTART_DELAY_MS = 2000;
28
+ const WEB_POLL_INTERVAL_MS = 500;
29
+ const METRO_DISCOVERY_INTERVAL_MS = 3000;
30
+ const METRO_AUTO_DISCOVERY_MAX_ATTEMPTS = 10;
31
+ /** Known Metro dev server port ranges. */
32
+ const METRO_PORT_RANGES = [
33
+ [8080, 8099], // Metro default range
34
+ [19000, 19002], // Expo
35
+ ];
36
+ function isMetroPort(port) {
37
+ return METRO_PORT_RANGES.some(([lo, hi]) => port >= lo && port <= hi);
38
+ }
39
+ function spawnCapture(cmd, args) {
40
+ return new Promise((resolve, reject) => {
41
+ const proc = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
42
+ let out = '';
43
+ proc.stdout?.on('data', (chunk) => {
44
+ out += chunk.toString();
45
+ });
46
+ proc.on('close', (code) => code === 0 ? resolve(out) : reject(new Error(`${cmd} failed (${code})`)));
47
+ proc.on('error', reject);
48
+ });
49
+ }
50
+ class LogCollector {
51
+ constructor(platform, deviceId, driverPort, appId, dlog) {
52
+ this.platform = platform;
53
+ this.deviceId = deviceId;
54
+ this.driverPort = driverPort;
55
+ this.appId = appId;
56
+ this.dlog = dlog;
57
+ this.buffer = [];
58
+ this.source = null;
59
+ this.stopped = false;
60
+ this.restartTimer = null;
61
+ // Web polling state
62
+ this.webPollTimer = null;
63
+ this.webSince = new Date().toISOString();
64
+ // Metro auto-discovery state
65
+ this.metroSource = null;
66
+ this.metroDiscoveryTimer = null;
67
+ this.metroPort = null;
68
+ this.metroConnected = false;
69
+ this.metroAutoDiscovery = false;
70
+ this.metroAutoDiscoveryAttempts = 0;
71
+ }
72
+ async start() {
73
+ this.stopped = false;
74
+ if (this.platform === 'web') {
75
+ this.startWebPolling();
76
+ return;
77
+ }
78
+ await this.startSource();
79
+ }
80
+ stop() {
81
+ this.stopped = true;
82
+ if (this.restartTimer) {
83
+ clearTimeout(this.restartTimer);
84
+ this.restartTimer = null;
85
+ }
86
+ if (this.webPollTimer) {
87
+ clearTimeout(this.webPollTimer);
88
+ this.webPollTimer = null;
89
+ }
90
+ if (this.metroDiscoveryTimer) {
91
+ clearTimeout(this.metroDiscoveryTimer);
92
+ this.metroDiscoveryTimer = null;
93
+ }
94
+ if (this.source) {
95
+ this.source.disconnect();
96
+ this.source = null;
97
+ }
98
+ if (this.metroSource) {
99
+ this.metroSource.disconnect();
100
+ this.metroSource = null;
101
+ this.metroConnected = false;
102
+ }
103
+ }
104
+ /**
105
+ * Enable Metro log collection for React Native apps.
106
+ *
107
+ * When `port` is given, connects directly to that Metro port.
108
+ * When omitted, auto-discovers the Metro port by probing the device:
109
+ * - Android: parses `adb reverse --list` for forwarded Metro ports
110
+ * - iOS/tvOS: scans `lsof` for node listeners in Metro port ranges,
111
+ * then probes `/json` and matches by deviceId (strict — no appId fallback)
112
+ *
113
+ * This is opt-in — only call this for React Native apps.
114
+ */
115
+ enableMetro(port) {
116
+ if (this.platform === 'web')
117
+ return; // Web already has console logs
118
+ if (port !== undefined) {
119
+ // Explicit port — same as before
120
+ if (this.metroPort === port)
121
+ return;
122
+ this.teardownMetro();
123
+ this.metroAutoDiscovery = false;
124
+ this.metroPort = port;
125
+ this.startMetroDiscovery();
126
+ }
127
+ else {
128
+ // Auto-discover
129
+ if (this.metroAutoDiscovery || this.metroConnected)
130
+ return;
131
+ this.teardownMetro();
132
+ this.metroAutoDiscovery = true;
133
+ this.metroAutoDiscoveryAttempts = 0;
134
+ this.startAutoDiscovery();
135
+ }
136
+ }
137
+ teardownMetro() {
138
+ if (this.metroSource) {
139
+ this.metroSource.disconnect();
140
+ this.metroSource = null;
141
+ this.metroConnected = false;
142
+ }
143
+ if (this.metroDiscoveryTimer) {
144
+ clearTimeout(this.metroDiscoveryTimer);
145
+ this.metroDiscoveryTimer = null;
146
+ }
147
+ }
148
+ query(opts = {}) {
149
+ let entries = this.buffer;
150
+ if (opts.since) {
151
+ const since = opts.since;
152
+ entries = entries.filter((e) => e.timestamp > since);
153
+ }
154
+ if (opts.level) {
155
+ const minSeverity = types_js_1.LEVEL_SEVERITY[opts.level] ?? 0;
156
+ entries = entries.filter((e) => (types_js_1.LEVEL_SEVERITY[e.level] ?? 0) >= minSeverity);
157
+ }
158
+ if (opts.limit && opts.limit > 0) {
159
+ // Return the most recent N entries
160
+ entries = entries.slice(-opts.limit);
161
+ }
162
+ return entries;
163
+ }
164
+ pushEntry(entry) {
165
+ this.buffer.push(entry);
166
+ if (this.buffer.length > MAX_BUFFER) {
167
+ this.buffer.splice(0, this.buffer.length - MAX_BUFFER);
168
+ }
169
+ }
170
+ async startSource() {
171
+ if (this.stopped)
172
+ return;
173
+ try {
174
+ if (this.platform === 'ios' || this.platform === 'tvos') {
175
+ this.source = new ios_js_1.IOSLogSource(this.deviceId, this.appId);
176
+ }
177
+ else if (this.platform === 'android') {
178
+ this.source = new android_js_1.AndroidLogSource(this.deviceId, this.appId);
179
+ }
180
+ else {
181
+ return; // Unsupported platform for device log collection
182
+ }
183
+ this.source.onEntry((entry) => this.pushEntry(entry));
184
+ await this.source.connect();
185
+ }
186
+ catch {
187
+ // Source failed to start — schedule a retry
188
+ this.scheduleRestart();
189
+ }
190
+ }
191
+ scheduleRestart() {
192
+ if (this.stopped)
193
+ return;
194
+ this.restartTimer = setTimeout(() => {
195
+ this.restartTimer = null;
196
+ this.startSource().catch(() => this.scheduleRestart());
197
+ }, RESTART_DELAY_MS);
198
+ }
199
+ // ── Web polling ──────────────────────────────────────────────────────────
200
+ startWebPolling() {
201
+ const poll = async () => {
202
+ if (this.stopped)
203
+ return;
204
+ try {
205
+ const entries = await this.fetchWebLogs();
206
+ for (const entry of entries) {
207
+ this.pushEntry(entry);
208
+ }
209
+ if (entries.length > 0) {
210
+ this.webSince = entries[entries.length - 1].timestamp;
211
+ }
212
+ }
213
+ catch {
214
+ // Web driver may be restarting — keep polling
215
+ }
216
+ if (!this.stopped) {
217
+ this.webPollTimer = setTimeout(poll, WEB_POLL_INTERVAL_MS);
218
+ }
219
+ };
220
+ poll();
221
+ }
222
+ fetchWebLogs() {
223
+ return new Promise((resolve, reject) => {
224
+ const req = http_1.default.get(`http://127.0.0.1:${this.driverPort}/consoleLogs?since=${encodeURIComponent(this.webSince)}`, (res) => {
225
+ const chunks = [];
226
+ res.on('data', (chunk) => chunks.push(chunk));
227
+ res.on('end', () => {
228
+ try {
229
+ const data = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
230
+ resolve(data.entries ?? []);
231
+ }
232
+ catch {
233
+ resolve([]);
234
+ }
235
+ });
236
+ });
237
+ req.setTimeout(5000, () => {
238
+ req.destroy();
239
+ reject(new Error('Timeout polling web console logs'));
240
+ });
241
+ req.on('error', reject);
242
+ });
243
+ }
244
+ // ── Metro auto-discovery ─────────────────────────────────────────────────
245
+ async startAutoDiscovery() {
246
+ if (this.stopped || this.metroConnected)
247
+ return;
248
+ this.metroAutoDiscoveryAttempts++;
249
+ try {
250
+ const port = await this.discoverMetroPort();
251
+ if (port !== null) {
252
+ this.dlog?.(`Metro auto-discovered on port ${port} for device ${this.deviceId}`);
253
+ this.metroPort = port;
254
+ this.startMetroDiscovery();
255
+ return;
256
+ }
257
+ }
258
+ catch {
259
+ // Discovery failed — retry
260
+ }
261
+ if (this.metroAutoDiscoveryAttempts >= METRO_AUTO_DISCOVERY_MAX_ATTEMPTS) {
262
+ this.dlog?.(`Metro auto-discovery: no Metro instance found for device ${this.deviceId} after ${this.metroAutoDiscoveryAttempts} attempts. Use --metro-port to specify.`);
263
+ this.metroAutoDiscovery = false;
264
+ return;
265
+ }
266
+ // Retry — app may not have started yet
267
+ if (!this.stopped) {
268
+ this.metroDiscoveryTimer = setTimeout(() => {
269
+ this.metroDiscoveryTimer = null;
270
+ this.startAutoDiscovery();
271
+ }, METRO_DISCOVERY_INTERVAL_MS);
272
+ }
273
+ }
274
+ /**
275
+ * Discover the Metro dev server port by probing the device.
276
+ *
277
+ * Android: parse `adb reverse --list` for forwarded ports in Metro ranges.
278
+ * iOS/tvOS: scan `lsof` for node listeners in Metro ranges, probe `/json`,
279
+ * and strictly match by deviceId (no appId/single-target fallback).
280
+ */
281
+ async discoverMetroPort() {
282
+ if (this.platform === 'android') {
283
+ return this.discoverMetroPortAndroid();
284
+ }
285
+ if (this.platform === 'ios' || this.platform === 'tvos') {
286
+ return this.discoverMetroPortIOS();
287
+ }
288
+ return null;
289
+ }
290
+ async discoverMetroPortAndroid() {
291
+ try {
292
+ const output = await spawnCapture('adb', ['-s', this.deviceId, 'reverse', '--list']);
293
+ // Lines look like: host-13 tcp:8082 tcp:8082
294
+ for (const line of output.split('\n')) {
295
+ const match = line.match(/tcp:(\d+)\s+tcp:(\d+)/);
296
+ if (!match)
297
+ continue;
298
+ const hostPort = parseInt(match[2], 10);
299
+ if (isMetroPort(hostPort))
300
+ return hostPort;
301
+ }
302
+ }
303
+ catch {
304
+ // adb not available or device not connected
305
+ }
306
+ return null;
307
+ }
308
+ async discoverMetroPortIOS() {
309
+ try {
310
+ const output = await spawnCapture('lsof', ['-iTCP', '-sTCP:LISTEN', '-n', '-P']);
311
+ const ports = new Set();
312
+ for (const line of output.split('\n')) {
313
+ if (!line.startsWith('node'))
314
+ continue;
315
+ // Column 9 is NAME, e.g. "*:8082" or "[::1]:8082" or "127.0.0.1:8082"
316
+ const match = line.match(/:(\d+)\s/);
317
+ if (!match)
318
+ continue;
319
+ const port = parseInt(match[1], 10);
320
+ if (isMetroPort(port))
321
+ ports.add(port);
322
+ }
323
+ if (ports.size === 0)
324
+ return null;
325
+ // Probe all candidate ports in parallel
326
+ const results = await Promise.all([...ports].map(async (port) => {
327
+ try {
328
+ const targets = await (0, metro_js_1.fetchTargets)(port, 'localhost');
329
+ const withWs = targets.filter((t) => t.webSocketDebuggerUrl);
330
+ // Strict deviceId match only — no appId or single-target fallback
331
+ const match = withWs.find((t) => t.deviceId === this.deviceId || t.reactNative?.logicalDeviceId === this.deviceId);
332
+ return match ? port : null;
333
+ }
334
+ catch {
335
+ return null;
336
+ }
337
+ }));
338
+ return results.find((p) => p !== null) ?? null;
339
+ }
340
+ catch {
341
+ // lsof not available
342
+ }
343
+ return null;
344
+ }
345
+ startMetroDiscovery() {
346
+ if (this.stopped || this.metroPort === null)
347
+ return;
348
+ this.tryConnectMetro();
349
+ }
350
+ async tryConnectMetro() {
351
+ if (this.stopped || this.metroPort === null || this.metroConnected)
352
+ return;
353
+ try {
354
+ const targets = await (0, metro_js_1.fetchTargets)(this.metroPort, 'localhost');
355
+ const target = this.findTargetForDevice(targets);
356
+ if (!target || !target.webSocketDebuggerUrl) {
357
+ // Target not found yet — app may still be starting. Retry later.
358
+ this.scheduleMetroDiscovery();
359
+ return;
360
+ }
361
+ // Found a matching target — connect
362
+ const targetIndex = targets.filter((t) => t.webSocketDebuggerUrl).indexOf(target);
363
+ this.metroSource = new metro_js_1.MetroLogSource(this.metroPort, 'localhost', targetIndex >= 0 ? targetIndex : undefined);
364
+ this.metroSource.onEntry((entry) => this.pushEntry(entry));
365
+ await this.metroSource.connect();
366
+ this.metroConnected = true;
367
+ this.dlog?.(`Metro connected for device ${this.deviceId} on port ${this.metroPort}`);
368
+ }
369
+ catch {
370
+ // Metro not running or connection failed — retry
371
+ this.scheduleMetroDiscovery();
372
+ }
373
+ }
374
+ /**
375
+ * Find a Metro debugger target that matches this daemon's device.
376
+ * Checks deviceId (simulator UDID / emulator serial) first,
377
+ * then falls back to matching by appId if available.
378
+ */
379
+ findTargetForDevice(targets) {
380
+ const withWs = targets.filter((t) => t.webSocketDebuggerUrl);
381
+ if (withWs.length === 0)
382
+ return undefined;
383
+ // Prefer exact deviceId match (simulator UDID / emulator serial)
384
+ const byDevice = withWs.find((t) => t.deviceId === this.deviceId || t.reactNative?.logicalDeviceId === this.deviceId);
385
+ if (byDevice)
386
+ return byDevice;
387
+ // Fall back to appId match if we know the app
388
+ if (this.appId) {
389
+ const byApp = withWs.find((t) => t.appId === this.appId);
390
+ if (byApp)
391
+ return byApp;
392
+ }
393
+ // Single target — safe to use without matching
394
+ if (withWs.length === 1)
395
+ return withWs[0];
396
+ // Multiple targets, no match — don't guess
397
+ return undefined;
398
+ }
399
+ scheduleMetroDiscovery() {
400
+ if (this.stopped || this.metroConnected)
401
+ return;
402
+ this.metroDiscoveryTimer = setTimeout(() => {
403
+ this.metroDiscoveryTimer = null;
404
+ this.tryConnectMetro();
405
+ }, METRO_DISCOVERY_INTERVAL_MS);
406
+ }
407
+ }
408
+ exports.LogCollector = LogCollector;
@@ -14,12 +14,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
14
14
  *
15
15
  * Spawned by: node dist/daemon/server.js [sessionName]
16
16
  */
17
- const net_1 = __importDefault(require("net"));
17
+ const http_1 = __importDefault(require("http"));
18
+ const url_1 = __importDefault(require("url"));
18
19
  const fs_1 = __importDefault(require("fs"));
19
20
  const path_1 = __importDefault(require("path"));
20
21
  const protocol_js_1 = require("./protocol.js");
21
22
  const bootstrap_js_1 = require("../drivers/bootstrap.js");
22
23
  const android_js_1 = require("../drivers/android.js");
24
+ const web_server_js_1 = require("./web-server.js");
25
+ const log_collector_js_1 = require("./log-collector.js");
26
+ const session_js_1 = require("../session.js");
23
27
  const sessionName = process.argv[2] ?? 'default';
24
28
  const SOCKET_PATH = (0, protocol_js_1.socketPath)(sessionName);
25
29
  const PID_FILE = (0, protocol_js_1.pidFile)(sessionName);
@@ -36,6 +40,7 @@ function dlog(msg) {
36
40
  // ── Driver lifecycle ──────────────────────────────────────────────────────────
37
41
  let driverPort = 1075;
38
42
  let driverPlatform = 'ios';
43
+ let logCollector = null;
39
44
  const DRIVER_HEALTH_INTERVAL_MS = 10000; // Check driver health every 10s
40
45
  let _restartInProgress = false;
41
46
  let _driverStarted = false;
@@ -50,7 +55,7 @@ async function ensureDriverRunning() {
50
55
  probe.close();
51
56
  }
52
57
  else {
53
- // Both 'ios' and 'tvos' use an HTTP server — port open = alive
58
+ // 'ios', 'tvos', and 'web' all use an HTTP server — port open = alive
54
59
  alive = await (0, bootstrap_js_1.isPortOpen)(driverPort);
55
60
  }
56
61
  if (!alive) {
@@ -69,6 +74,9 @@ async function ensureDriverRunning() {
69
74
  // Health-check restart — don't dismiss, to avoid disrupting user's app
70
75
  await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ false);
71
76
  }
77
+ else if (driverPlatform === 'web') {
78
+ await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog);
79
+ }
72
80
  else {
73
81
  await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
74
82
  }
@@ -109,6 +117,10 @@ async function main() {
109
117
  async function cleanup() {
110
118
  if (healthTimer)
111
119
  clearInterval(healthTimer);
120
+ if (logCollector) {
121
+ logCollector.stop();
122
+ logCollector = null;
123
+ }
112
124
  try {
113
125
  fs_1.default.unlinkSync(SOCKET_PATH);
114
126
  }
@@ -140,6 +152,15 @@ async function main() {
140
152
  if (driverPlatform === 'tvos') {
141
153
  dlog('tvOS: leaving driver running to preserve app state');
142
154
  }
155
+ else if (driverPlatform === 'web') {
156
+ dlog('Stopping web driver');
157
+ try {
158
+ await (0, web_server_js_1.stopWebServer)();
159
+ }
160
+ catch (err) {
161
+ dlog(`Stop web driver error: ${err instanceof Error ? err.message : String(err)}`);
162
+ }
163
+ }
143
164
  else {
144
165
  dlog(`Stopping driver on port ${driverPort}`);
145
166
  try {
@@ -176,17 +197,53 @@ async function main() {
176
197
  }, DRIVER_HEALTH_INTERVAL_MS);
177
198
  healthTimer.unref(); // Don't keep the process alive just for health checks
178
199
  }
179
- // Create socket accept connections as aliveness pings (no message exchange needed)
180
- const server = net_1.default.createServer({ allowHalfOpen: true }, (socket) => {
181
- resetIdleTimer();
182
- // Close immediately the connect/accept is enough for daemonStatus()
183
- socket.end();
184
- socket.on('error', () => {
185
- /* ignore */
200
+ // ── HTTP server on Unix socket ─────────────────────────────────────────────
201
+ // Replaces the old raw-TCP accept-and-close with a proper HTTP server so we
202
+ // can serve /status (aliveness) and /logs (buffered log entries).
203
+ function jsonResponse(res, body, status = 200) {
204
+ const json = JSON.stringify(body);
205
+ res.writeHead(status, {
206
+ 'Content-Type': 'application/json',
207
+ 'Content-Length': Buffer.byteLength(json),
186
208
  });
209
+ res.end(json);
210
+ }
211
+ const server = http_1.default.createServer((req, res) => {
212
+ resetIdleTimer();
213
+ const parsed = url_1.default.parse(req.url ?? '/', true);
214
+ if (req.method === 'GET' && parsed.pathname === '/status') {
215
+ jsonResponse(res, { ok: true, platform: driverPlatform, driverPort });
216
+ return;
217
+ }
218
+ if (req.method === 'GET' && parsed.pathname === '/logs') {
219
+ if (!logCollector) {
220
+ jsonResponse(res, { entries: [] });
221
+ return;
222
+ }
223
+ const q = parsed.query;
224
+ // Opt-in Metro discovery: ?metro=8081 uses that port directly,
225
+ // ?metro (no value) or ?metro=auto triggers auto-discovery.
226
+ if (q.metro !== undefined) {
227
+ const metroPort = typeof q.metro === 'string' ? parseInt(q.metro, 10) : NaN;
228
+ if (metroPort > 0) {
229
+ logCollector.enableMetro(metroPort);
230
+ }
231
+ else {
232
+ logCollector.enableMetro(); // auto-discover
233
+ }
234
+ }
235
+ const entries = logCollector.query({
236
+ since: typeof q.since === 'string' ? q.since : undefined,
237
+ level: typeof q.level === 'string' ? q.level : undefined,
238
+ limit: typeof q.limit === 'string' ? parseInt(q.limit, 10) || undefined : undefined,
239
+ });
240
+ jsonResponse(res, { entries });
241
+ return;
242
+ }
243
+ jsonResponse(res, { error: 'not found' }, 404);
187
244
  });
188
245
  server.listen(SOCKET_PATH, () => {
189
- dlog(`socket ready at ${SOCKET_PATH}`);
246
+ dlog(`HTTP socket ready at ${SOCKET_PATH}`);
190
247
  resetIdleTimer();
191
248
  // Start driver in the background after the socket is ready (so the client
192
249
  // doesn't time out waiting for the socket while the driver is starting).
@@ -204,38 +261,59 @@ async function main() {
204
261
  probe.close();
205
262
  }
206
263
  else {
207
- // Both 'ios' and 'tvos' use an HTTP server — port open = alive
264
+ // 'ios', 'tvos', and 'web' all use an HTTP server — port open = alive
208
265
  driverAlive = await (0, bootstrap_js_1.isPortOpen)(driverPort);
209
266
  }
210
267
  if (driverAlive) {
211
268
  _driverStarted = true;
212
269
  dlog(`Driver already running on port ${driverPort}`);
213
- return;
214
- }
215
- // Android: install APKs before starting the driver.
216
- // iOS/tvOS: xcodebuild installs silently via DependentProductPaths.
217
- if (platform === 'android') {
218
- dlog(`Installing Android driver on ${sessionName}`);
219
- await (0, bootstrap_js_1.installDriver)(sessionName);
220
- dlog(`Driver installation complete`);
221
270
  }
222
- dlog(`Starting ${platform} driver on port ${driverPort}`);
223
- try {
224
- if (platform === 'ios') {
225
- await (0, bootstrap_js_1.startIOSDriver)(sessionName, driverPort);
271
+ else {
272
+ // Android: install APKs before starting the driver.
273
+ // iOS/tvOS: xcodebuild installs silently via DependentProductPaths.
274
+ // Web: ensure Playwright browser binary is installed.
275
+ if (platform === 'android') {
276
+ dlog(`Installing Android driver on ${sessionName}`);
277
+ await (0, bootstrap_js_1.installDriver)(sessionName);
278
+ dlog(`Driver installation complete`);
226
279
  }
227
- else if (platform === 'tvos') {
228
- // First install dismiss the runner app to return to homescreen
229
- await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ true);
280
+ else if (platform === 'web') {
281
+ const browser = (0, bootstrap_js_1.webBrowserName)(sessionName);
282
+ await (0, bootstrap_js_1.ensurePlaywrightBrowser)(browser, dlog);
230
283
  }
231
- else {
232
- await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
284
+ dlog(`Starting ${platform} driver on port ${driverPort}`);
285
+ try {
286
+ if (platform === 'ios') {
287
+ await (0, bootstrap_js_1.startIOSDriver)(sessionName, driverPort);
288
+ }
289
+ else if (platform === 'tvos') {
290
+ // First install — dismiss the runner app to return to homescreen
291
+ await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ true);
292
+ }
293
+ else if (platform === 'web') {
294
+ await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog);
295
+ }
296
+ else {
297
+ await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
298
+ }
299
+ _driverStarted = true;
300
+ dlog(`Driver started successfully`);
301
+ }
302
+ catch (err) {
303
+ dlog(`Driver startup error: ${err instanceof Error ? err.message : String(err)}`);
233
304
  }
234
- _driverStarted = true;
235
- dlog(`Driver started successfully`);
236
305
  }
237
- catch (err) {
238
- dlog(`Driver startup error: ${err instanceof Error ? err.message : String(err)}`);
306
+ // Start collecting logs once the driver is (or was already) running.
307
+ if (_driverStarted) {
308
+ try {
309
+ const session = await (0, session_js_1.getSession)(sessionName);
310
+ logCollector = new log_collector_js_1.LogCollector(platform, sessionName, driverPort, session.appId, dlog);
311
+ await logCollector.start();
312
+ dlog(`Log collector started${session.appId ? ` (appId=${session.appId})` : ''}`);
313
+ }
314
+ catch (err) {
315
+ dlog(`Log collector startup error: ${err instanceof Error ? err.message : String(err)}`);
316
+ }
239
317
  }
240
318
  })
241
319
  .catch((err) => {