@houwert/conductor 0.5.0 → 0.7.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.
- package/README.md +12 -9
- package/dist/commands/assert-not-visible.js +4 -0
- package/dist/commands/assert-visible.js +4 -0
- package/dist/commands/back.js +4 -0
- package/dist/commands/cheat-sheet.js +11 -8
- package/dist/commands/delete-device.js +222 -0
- package/dist/commands/device-pool.js +33 -22
- package/dist/commands/download-app.js +87 -0
- package/dist/commands/erase-text.js +4 -0
- package/dist/commands/focused.js +39 -0
- package/dist/commands/foreground-app.js +4 -0
- package/dist/commands/hide-keyboard.js +4 -0
- package/dist/commands/inspect.js +8 -0
- package/dist/commands/install.js +103 -27
- package/dist/commands/launch-app.js +6 -0
- package/dist/commands/list-devices.js +39 -2
- package/dist/commands/logs.js +193 -0
- package/dist/commands/press-key.js +16 -0
- package/dist/commands/screenshot.js +1 -1
- package/dist/commands/scroll-until-visible.js +11 -0
- package/dist/commands/scroll.js +6 -0
- package/dist/commands/start-device.js +38 -4
- package/dist/commands/stop-app.js +4 -0
- package/dist/commands/swipe.js +22 -0
- package/dist/commands/tap.js +10 -3
- package/dist/commands/type.js +2 -2
- package/dist/commands/uninstall-app.js +4 -0
- package/dist/daemon/client.js +72 -9
- package/dist/daemon/log-collector.js +408 -0
- package/dist/daemon/server.js +120 -32
- package/dist/daemon/web-server.js +892 -0
- package/dist/device-picker.js +7 -2
- package/dist/drivers/bootstrap.js +124 -1
- package/dist/drivers/element-resolver.js +241 -30
- package/dist/drivers/flow-runner.js +63 -21
- package/dist/drivers/log-sources/android.js +156 -0
- package/dist/drivers/log-sources/daemon.js +112 -0
- package/dist/drivers/log-sources/ios.js +106 -0
- package/dist/drivers/log-sources/metro.js +252 -0
- package/dist/drivers/log-sources/types.js +13 -0
- package/dist/drivers/log-sources/web.js +96 -0
- package/dist/drivers/wait.js +57 -0
- package/dist/drivers/web.js +173 -0
- package/dist/index.js +74 -13
- package/dist/runner.js +32 -2
- package/drivers/ios/conductor-driver-ios.zip +0 -0
- package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
- package/drivers/tvos/conductor-driver-tvos.zip +0 -0
- package/drivers/tvos/conductor-driver-tvosUITests-Runner.zip +0 -0
- package/package.json +5 -2
- package/skills/conductor/SKILL.md +72 -41
- 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;
|
package/dist/daemon/server.js
CHANGED
|
@@ -14,13 +14,25 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
14
14
|
*
|
|
15
15
|
* Spawned by: node dist/daemon/server.js [sessionName]
|
|
16
16
|
*/
|
|
17
|
-
const
|
|
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';
|
|
28
|
+
/**
|
|
29
|
+
* CDP URL for connecting to an external browser (e.g. Stagehand's embedded
|
|
30
|
+
* webview). When set, the web driver attaches via Playwright's connectOverCDP
|
|
31
|
+
* instead of launching its own browser.
|
|
32
|
+
*
|
|
33
|
+
* Set by the host IDE (Stagehand) via the agent subprocess environment.
|
|
34
|
+
*/
|
|
35
|
+
const cdpUrl = process.env.CONDUCTOR_CDP_URL || undefined;
|
|
24
36
|
const SOCKET_PATH = (0, protocol_js_1.socketPath)(sessionName);
|
|
25
37
|
const PID_FILE = (0, protocol_js_1.pidFile)(sessionName);
|
|
26
38
|
const LOG_FILE = (0, protocol_js_1.logFile)(sessionName);
|
|
@@ -36,6 +48,7 @@ function dlog(msg) {
|
|
|
36
48
|
// ── Driver lifecycle ──────────────────────────────────────────────────────────
|
|
37
49
|
let driverPort = 1075;
|
|
38
50
|
let driverPlatform = 'ios';
|
|
51
|
+
let logCollector = null;
|
|
39
52
|
const DRIVER_HEALTH_INTERVAL_MS = 10000; // Check driver health every 10s
|
|
40
53
|
let _restartInProgress = false;
|
|
41
54
|
let _driverStarted = false;
|
|
@@ -50,7 +63,7 @@ async function ensureDriverRunning() {
|
|
|
50
63
|
probe.close();
|
|
51
64
|
}
|
|
52
65
|
else {
|
|
53
|
-
//
|
|
66
|
+
// 'ios', 'tvos', and 'web' all use an HTTP server — port open = alive
|
|
54
67
|
alive = await (0, bootstrap_js_1.isPortOpen)(driverPort);
|
|
55
68
|
}
|
|
56
69
|
if (!alive) {
|
|
@@ -69,6 +82,9 @@ async function ensureDriverRunning() {
|
|
|
69
82
|
// Health-check restart — don't dismiss, to avoid disrupting user's app
|
|
70
83
|
await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ false);
|
|
71
84
|
}
|
|
85
|
+
else if (driverPlatform === 'web') {
|
|
86
|
+
await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl);
|
|
87
|
+
}
|
|
72
88
|
else {
|
|
73
89
|
await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
|
|
74
90
|
}
|
|
@@ -109,6 +125,10 @@ async function main() {
|
|
|
109
125
|
async function cleanup() {
|
|
110
126
|
if (healthTimer)
|
|
111
127
|
clearInterval(healthTimer);
|
|
128
|
+
if (logCollector) {
|
|
129
|
+
logCollector.stop();
|
|
130
|
+
logCollector = null;
|
|
131
|
+
}
|
|
112
132
|
try {
|
|
113
133
|
fs_1.default.unlinkSync(SOCKET_PATH);
|
|
114
134
|
}
|
|
@@ -140,6 +160,15 @@ async function main() {
|
|
|
140
160
|
if (driverPlatform === 'tvos') {
|
|
141
161
|
dlog('tvOS: leaving driver running to preserve app state');
|
|
142
162
|
}
|
|
163
|
+
else if (driverPlatform === 'web') {
|
|
164
|
+
dlog('Stopping web driver');
|
|
165
|
+
try {
|
|
166
|
+
await (0, web_server_js_1.stopWebServer)();
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
dlog(`Stop web driver error: ${err instanceof Error ? err.message : String(err)}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
143
172
|
else {
|
|
144
173
|
dlog(`Stopping driver on port ${driverPort}`);
|
|
145
174
|
try {
|
|
@@ -176,17 +205,53 @@ async function main() {
|
|
|
176
205
|
}, DRIVER_HEALTH_INTERVAL_MS);
|
|
177
206
|
healthTimer.unref(); // Don't keep the process alive just for health checks
|
|
178
207
|
}
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
208
|
+
// ── HTTP server on Unix socket ─────────────────────────────────────────────
|
|
209
|
+
// Replaces the old raw-TCP accept-and-close with a proper HTTP server so we
|
|
210
|
+
// can serve /status (aliveness) and /logs (buffered log entries).
|
|
211
|
+
function jsonResponse(res, body, status = 200) {
|
|
212
|
+
const json = JSON.stringify(body);
|
|
213
|
+
res.writeHead(status, {
|
|
214
|
+
'Content-Type': 'application/json',
|
|
215
|
+
'Content-Length': Buffer.byteLength(json),
|
|
186
216
|
});
|
|
217
|
+
res.end(json);
|
|
218
|
+
}
|
|
219
|
+
const server = http_1.default.createServer((req, res) => {
|
|
220
|
+
resetIdleTimer();
|
|
221
|
+
const parsed = url_1.default.parse(req.url ?? '/', true);
|
|
222
|
+
if (req.method === 'GET' && parsed.pathname === '/status') {
|
|
223
|
+
jsonResponse(res, { ok: true, platform: driverPlatform, driverPort });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (req.method === 'GET' && parsed.pathname === '/logs') {
|
|
227
|
+
if (!logCollector) {
|
|
228
|
+
jsonResponse(res, { entries: [] });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const q = parsed.query;
|
|
232
|
+
// Opt-in Metro discovery: ?metro=8081 uses that port directly,
|
|
233
|
+
// ?metro (no value) or ?metro=auto triggers auto-discovery.
|
|
234
|
+
if (q.metro !== undefined) {
|
|
235
|
+
const metroPort = typeof q.metro === 'string' ? parseInt(q.metro, 10) : NaN;
|
|
236
|
+
if (metroPort > 0) {
|
|
237
|
+
logCollector.enableMetro(metroPort);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
logCollector.enableMetro(); // auto-discover
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const entries = logCollector.query({
|
|
244
|
+
since: typeof q.since === 'string' ? q.since : undefined,
|
|
245
|
+
level: typeof q.level === 'string' ? q.level : undefined,
|
|
246
|
+
limit: typeof q.limit === 'string' ? parseInt(q.limit, 10) || undefined : undefined,
|
|
247
|
+
});
|
|
248
|
+
jsonResponse(res, { entries });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
jsonResponse(res, { error: 'not found' }, 404);
|
|
187
252
|
});
|
|
188
253
|
server.listen(SOCKET_PATH, () => {
|
|
189
|
-
dlog(`socket ready at ${SOCKET_PATH}`);
|
|
254
|
+
dlog(`HTTP socket ready at ${SOCKET_PATH}`);
|
|
190
255
|
resetIdleTimer();
|
|
191
256
|
// Start driver in the background after the socket is ready (so the client
|
|
192
257
|
// doesn't time out waiting for the socket while the driver is starting).
|
|
@@ -204,38 +269,61 @@ async function main() {
|
|
|
204
269
|
probe.close();
|
|
205
270
|
}
|
|
206
271
|
else {
|
|
207
|
-
//
|
|
272
|
+
// 'ios', 'tvos', and 'web' all use an HTTP server — port open = alive
|
|
208
273
|
driverAlive = await (0, bootstrap_js_1.isPortOpen)(driverPort);
|
|
209
274
|
}
|
|
210
275
|
if (driverAlive) {
|
|
211
276
|
_driverStarted = true;
|
|
212
277
|
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
278
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
279
|
+
else {
|
|
280
|
+
// Android: install APKs before starting the driver.
|
|
281
|
+
// iOS/tvOS: xcodebuild installs silently via DependentProductPaths.
|
|
282
|
+
// Web: ensure Playwright browser binary is installed.
|
|
283
|
+
if (platform === 'android') {
|
|
284
|
+
dlog(`Installing Android driver on ${sessionName}`);
|
|
285
|
+
await (0, bootstrap_js_1.installDriver)(sessionName);
|
|
286
|
+
dlog(`Driver installation complete`);
|
|
226
287
|
}
|
|
227
|
-
else if (platform === '
|
|
228
|
-
//
|
|
229
|
-
|
|
288
|
+
else if (platform === 'web' && !cdpUrl) {
|
|
289
|
+
// Only install Playwright browser when launching standalone.
|
|
290
|
+
// In CDP mode we attach to the host app's browser (e.g. Electron).
|
|
291
|
+
const browser = (0, bootstrap_js_1.webBrowserName)(sessionName);
|
|
292
|
+
await (0, bootstrap_js_1.ensurePlaywrightBrowser)(browser, dlog);
|
|
230
293
|
}
|
|
231
|
-
|
|
232
|
-
|
|
294
|
+
dlog(`Starting ${platform} driver on port ${driverPort}`);
|
|
295
|
+
try {
|
|
296
|
+
if (platform === 'ios') {
|
|
297
|
+
await (0, bootstrap_js_1.startIOSDriver)(sessionName, driverPort);
|
|
298
|
+
}
|
|
299
|
+
else if (platform === 'tvos') {
|
|
300
|
+
// First install — dismiss the runner app to return to homescreen
|
|
301
|
+
await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* dismissAfterLaunch */ true);
|
|
302
|
+
}
|
|
303
|
+
else if (platform === 'web') {
|
|
304
|
+
await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
await (0, bootstrap_js_1.startAndroidDriver)(sessionName, driverPort);
|
|
308
|
+
}
|
|
309
|
+
_driverStarted = true;
|
|
310
|
+
dlog(`Driver started successfully`);
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
dlog(`Driver startup error: ${err instanceof Error ? err.message : String(err)}`);
|
|
233
314
|
}
|
|
234
|
-
_driverStarted = true;
|
|
235
|
-
dlog(`Driver started successfully`);
|
|
236
315
|
}
|
|
237
|
-
|
|
238
|
-
|
|
316
|
+
// Start collecting logs once the driver is (or was already) running.
|
|
317
|
+
if (_driverStarted) {
|
|
318
|
+
try {
|
|
319
|
+
const session = await (0, session_js_1.getSession)(sessionName);
|
|
320
|
+
logCollector = new log_collector_js_1.LogCollector(platform, sessionName, driverPort, session.appId, dlog);
|
|
321
|
+
await logCollector.start();
|
|
322
|
+
dlog(`Log collector started${session.appId ? ` (appId=${session.appId})` : ''}`);
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
dlog(`Log collector startup error: ${err instanceof Error ? err.message : String(err)}`);
|
|
326
|
+
}
|
|
239
327
|
}
|
|
240
328
|
})
|
|
241
329
|
.catch((err) => {
|