@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
@@ -16,6 +16,7 @@ const node_vm_1 = __importDefault(require("node:vm"));
16
16
  const js_yaml_1 = __importDefault(require("js-yaml"));
17
17
  const ios_js_1 = require("./ios.js");
18
18
  const android_js_1 = require("./android.js");
19
+ const web_js_1 = require("./web.js");
19
20
  const wait_js_1 = require("./wait.js");
20
21
  const perf_hooks_1 = require("perf_hooks");
21
22
  const js_engine_js_1 = require("./js-engine.js");
@@ -148,6 +149,9 @@ async function waitForElement(driver, sel, timeoutMs, appIds, opts) {
148
149
  const iosShouldAllow = opts?.output[OUTPUT_IOS_SHOULD_ALLOW];
149
150
  return (0, wait_js_1.waitForIOSElement)(() => iosGetHierarchy(driver, appIds ?? [], iosShouldAllow), elSel, timeoutMs);
150
151
  }
152
+ else if (driver instanceof web_js_1.WebDriver) {
153
+ return (0, wait_js_1.waitForWebElement)(() => driver.viewHierarchy(), elSel, timeoutMs);
154
+ }
151
155
  else {
152
156
  return (0, wait_js_1.waitForAndroidElement)(() => driver.viewHierarchy(), elSel, timeoutMs);
153
157
  }
@@ -244,6 +248,9 @@ async function waitForSettle(driver) {
244
248
  if (driver instanceof ios_js_1.IOSDriver) {
245
249
  await (0, wait_js_1.waitForIOSTransitionToSettle)(() => driver.isScreenStatic());
246
250
  }
251
+ else if (driver instanceof web_js_1.WebDriver) {
252
+ await (0, wait_js_1.waitForWebHierarchyToSettle)(() => driver.viewHierarchy());
253
+ }
247
254
  else {
248
255
  await (0, wait_js_1.waitForAndroidHierarchyToSettle)(() => driver.viewHierarchy());
249
256
  }
@@ -498,8 +505,9 @@ async function executeCommand(cmd, driver, opts) {
498
505
  }
499
506
  }
500
507
  function getConductorObj(driver, output) {
508
+ const platform = driver instanceof ios_js_1.IOSDriver ? 'ios' : driver instanceof web_js_1.WebDriver ? 'web' : 'android';
501
509
  return {
502
- platform: driver instanceof ios_js_1.IOSDriver ? 'ios' : 'android',
510
+ platform,
503
511
  copiedText: output['__copiedText'] ?? '',
504
512
  };
505
513
  }
@@ -627,6 +635,9 @@ async function executeCommandBody(key, val, driver, opts) {
627
635
  if (driver instanceof android_js_1.AndroidDriver) {
628
636
  await driver.eraseAllText(n);
629
637
  }
638
+ else if (driver instanceof web_js_1.WebDriver) {
639
+ await driver.eraseAllText(n);
640
+ }
630
641
  else {
631
642
  for (let i = 0; i < n; i++)
632
643
  await driver.pressKey('delete');
@@ -767,6 +778,8 @@ async function executeCommandBody(key, val, driver, opts) {
767
778
  case 'back': {
768
779
  if (driver instanceof android_js_1.AndroidDriver)
769
780
  await driver.back();
781
+ else if (driver instanceof web_js_1.WebDriver)
782
+ await driver.goBack();
770
783
  // iOS has no hardware back button — noop
771
784
  break;
772
785
  }
@@ -782,17 +795,6 @@ async function executeCommandBody(key, val, driver, opts) {
782
795
  await waitForSettle(driver);
783
796
  break;
784
797
  }
785
- case 'hide keyboard': {
786
- if (driver instanceof ios_js_1.IOSDriver) {
787
- await driver.pressKey('return').catch(() => {
788
- /* noop if no keyboard */
789
- });
790
- }
791
- else {
792
- await driver.pressKeyEvent(111); // KEYCODE_ESCAPE
793
- }
794
- break;
795
- }
796
798
  // ── Assertions ─────────────────────────────────────────────────────────
797
799
  case 'assertVisible': {
798
800
  const optional = typeof val === 'object' &&
@@ -918,10 +920,17 @@ async function executeCommandBody(key, val, driver, opts) {
918
920
  if (stopAppFlag) {
919
921
  if (driver instanceof ios_js_1.IOSDriver)
920
922
  await driver.terminateApp(appId);
923
+ else if (driver instanceof web_js_1.WebDriver)
924
+ await driver.terminateApp();
921
925
  else if (driver instanceof android_js_1.AndroidDriver)
922
926
  await driver.stopApp(appId);
923
927
  }
924
- await driver.launchApp(appId, launchArgs);
928
+ if (driver instanceof web_js_1.WebDriver) {
929
+ await driver.launchApp(appId); // appId is URL for web
930
+ }
931
+ else {
932
+ await driver.launchApp(appId, launchArgs);
933
+ }
925
934
  if (driver instanceof ios_js_1.IOSDriver && permissions) {
926
935
  // Determine allow vs deny from the permissions map.
927
936
  // `all` is the canonical key; fall back to majority vote across explicit keys.
@@ -953,6 +962,9 @@ async function executeCommandBody(key, val, driver, opts) {
953
962
  if (driver instanceof ios_js_1.IOSDriver) {
954
963
  await driver.terminateApp(appId);
955
964
  }
965
+ else if (driver instanceof web_js_1.WebDriver) {
966
+ await driver.terminateApp();
967
+ }
956
968
  else {
957
969
  await driver.stopApp(appId);
958
970
  }
@@ -968,19 +980,27 @@ async function executeCommandBody(key, val, driver, opts) {
968
980
  if (driver instanceof ios_js_1.IOSDriver) {
969
981
  await driver.terminateApp(appId);
970
982
  }
983
+ else if (driver instanceof web_js_1.WebDriver) {
984
+ await driver.terminateApp();
985
+ }
971
986
  else {
972
987
  await driver.stopApp(appId);
973
988
  }
974
989
  break;
975
990
  }
976
991
  case 'clearState': {
977
- const appId = val == null || val === ''
978
- ? (opts.appId ??
979
- (() => {
980
- throw new Error('clearState: no appId in command or flow header');
981
- })())
982
- : resolveAppId(val, 'clearState');
983
- await driver.clearAppState(appId);
992
+ if (driver instanceof web_js_1.WebDriver) {
993
+ await driver.clearAppState();
994
+ }
995
+ else {
996
+ const appId = val == null || val === ''
997
+ ? (opts.appId ??
998
+ (() => {
999
+ throw new Error('clearState: no appId in command or flow header');
1000
+ })())
1001
+ : resolveAppId(val, 'clearState');
1002
+ await driver.clearAppState(appId);
1003
+ }
984
1004
  break;
985
1005
  }
986
1006
  case 'clearKeychain': {
@@ -994,7 +1014,10 @@ async function executeCommandBody(key, val, driver, opts) {
994
1014
  throw new Error('uninstallApp: no appId in command or flow header');
995
1015
  })())
996
1016
  : resolveAppId(val, 'uninstallApp');
997
- if (driver instanceof ios_js_1.IOSDriver) {
1017
+ if (driver instanceof web_js_1.WebDriver) {
1018
+ throw new Error('uninstallApp is not supported on web');
1019
+ }
1020
+ else if (driver instanceof ios_js_1.IOSDriver) {
998
1021
  await driver.uninstallApp(appId);
999
1022
  }
1000
1023
  else {
@@ -1017,6 +1040,19 @@ async function executeCommandBody(key, val, driver, opts) {
1017
1040
  await driver.pressKey(mapIosKey(keyName));
1018
1041
  }
1019
1042
  }
1043
+ else if (driver instanceof web_js_1.WebDriver) {
1044
+ // Map common key names to Playwright key names
1045
+ const WEB_KEY_MAP = {
1046
+ ENTER: 'Enter',
1047
+ RETURN: 'Enter',
1048
+ TAB: 'Tab',
1049
+ DELETE: 'Backspace',
1050
+ BACKSPACE: 'Backspace',
1051
+ SPACE: 'Space',
1052
+ ESCAPE: 'Escape',
1053
+ };
1054
+ await driver.pressKey(WEB_KEY_MAP[keyName] ?? keyName);
1055
+ }
1020
1056
  else {
1021
1057
  const keycode = ANDROID_KEYCODES[keyName];
1022
1058
  if (keycode === undefined)
@@ -1031,6 +1067,9 @@ async function executeCommandBody(key, val, driver, opts) {
1031
1067
  /* noop if no keyboard */
1032
1068
  });
1033
1069
  }
1070
+ else if (driver instanceof web_js_1.WebDriver) {
1071
+ // No virtual keyboard on web — noop
1072
+ }
1034
1073
  else {
1035
1074
  await driver.pressKeyEvent(111); // KEYCODE_ESCAPE
1036
1075
  }
@@ -1206,6 +1245,9 @@ async function executeCommandBody(key, val, driver, opts) {
1206
1245
  if (driver instanceof ios_js_1.IOSDriver) {
1207
1246
  await (0, wait_js_1.waitForIOSHierarchyToSettle)(() => driver.viewHierarchy().then((h) => h.axElement), wfaTimeout);
1208
1247
  }
1248
+ else if (driver instanceof web_js_1.WebDriver) {
1249
+ await (0, wait_js_1.waitForWebHierarchyToSettle)(() => driver.viewHierarchy(), wfaTimeout);
1250
+ }
1209
1251
  else {
1210
1252
  await (0, wait_js_1.waitForAndroidHierarchyToSettle)(() => driver.viewHierarchy(), wfaTimeout);
1211
1253
  }
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AndroidLogSource = void 0;
4
+ /**
5
+ * Android log source — streams logs from an Android device/emulator via `adb logcat`.
6
+ */
7
+ const child_process_1 = require("child_process");
8
+ function mapPriority(priority) {
9
+ // Android log priorities: 2=V, 3=D, 4=I, 5=W, 6=E, 7=F
10
+ const p = typeof priority === 'string' ? priority.toUpperCase() : String(priority);
11
+ switch (p) {
12
+ case '2':
13
+ case 'V':
14
+ return 'verbose';
15
+ case '3':
16
+ case 'D':
17
+ return 'debug';
18
+ case '4':
19
+ case 'I':
20
+ return 'info';
21
+ case '5':
22
+ case 'W':
23
+ return 'warning';
24
+ case '6':
25
+ case 'E':
26
+ case '7':
27
+ case 'F':
28
+ return 'error';
29
+ default:
30
+ return 'log';
31
+ }
32
+ }
33
+ class AndroidLogSource {
34
+ constructor(deviceId, appId) {
35
+ this.deviceId = deviceId;
36
+ this.appId = appId;
37
+ this.proc = null;
38
+ this.callback = null;
39
+ this.buffer = '';
40
+ this.useJson = true;
41
+ }
42
+ async connect() {
43
+ // Try to find the app's PID for filtering
44
+ let pid;
45
+ if (this.appId) {
46
+ try {
47
+ pid = (0, child_process_1.execSync)(`adb -s ${this.deviceId} shell pidof ${this.appId}`, {
48
+ encoding: 'utf-8',
49
+ timeout: 5000,
50
+ }).trim();
51
+ }
52
+ catch {
53
+ // App may not be running yet — proceed without PID filter
54
+ }
55
+ }
56
+ // Clear existing logcat buffer so we start fresh
57
+ try {
58
+ (0, child_process_1.execSync)(`adb -s ${this.deviceId} logcat -c`, { timeout: 5000 });
59
+ }
60
+ catch {
61
+ // Ignore clear failures
62
+ }
63
+ // Try JSON format first (available on API 26+)
64
+ const args = ['-s', this.deviceId, 'logcat'];
65
+ if (this.useJson) {
66
+ args.push('-v', 'json');
67
+ }
68
+ else {
69
+ args.push('-v', 'threadtime');
70
+ }
71
+ if (pid) {
72
+ args.push('--pid', pid);
73
+ }
74
+ this.proc = (0, child_process_1.spawn)('adb', args, { stdio: ['ignore', 'pipe', 'pipe'] });
75
+ let stderrChunks = '';
76
+ this.proc.stderr.on('data', (chunk) => {
77
+ stderrChunks += chunk.toString('utf-8');
78
+ });
79
+ this.proc.stdout.on('data', (chunk) => {
80
+ this.buffer += chunk.toString('utf-8');
81
+ const lines = this.buffer.split('\n');
82
+ this.buffer = lines.pop() ?? '';
83
+ for (const line of lines) {
84
+ if (!line.trim())
85
+ continue;
86
+ if (this.useJson) {
87
+ this.parseJsonLine(line);
88
+ }
89
+ else {
90
+ this.parseThreadtimeLine(line);
91
+ }
92
+ }
93
+ });
94
+ this.proc.on('error', () => {
95
+ // adb not available
96
+ });
97
+ // If JSON format isn't supported, the process will exit quickly with an error.
98
+ // Fall back to threadtime format.
99
+ await new Promise((resolve, reject) => {
100
+ const timer = setTimeout(resolve, 1000);
101
+ this.proc.on('close', (code) => {
102
+ clearTimeout(timer);
103
+ if (code !== 0 && code !== null && this.useJson) {
104
+ // JSON format not supported — retry with threadtime
105
+ this.useJson = false;
106
+ this.connect().then(resolve, reject);
107
+ return;
108
+ }
109
+ if (code !== 0 && code !== null) {
110
+ reject(new Error(`adb logcat exited with code ${code}. ${stderrChunks.trim() || 'Is the device connected?'}`));
111
+ }
112
+ });
113
+ });
114
+ }
115
+ onEntry(callback) {
116
+ this.callback = callback;
117
+ }
118
+ disconnect() {
119
+ if (this.proc) {
120
+ this.proc.kill('SIGTERM');
121
+ this.proc = null;
122
+ }
123
+ }
124
+ parseJsonLine(line) {
125
+ try {
126
+ const data = JSON.parse(line);
127
+ const entry = {
128
+ timestamp: data.timestamp || new Date().toISOString(),
129
+ level: mapPriority(data.priority),
130
+ message: data.tag ? `[${data.tag}] ${data.message}` : data.message,
131
+ stackTrace: null,
132
+ source: 'device',
133
+ };
134
+ this.callback?.(entry);
135
+ }
136
+ catch {
137
+ // Non-JSON line — skip
138
+ }
139
+ }
140
+ /** Parse threadtime format: `MM-DD HH:MM:SS.mmm PID TID LEVEL TAG: message` */
141
+ parseThreadtimeLine(line) {
142
+ const match = line.match(/^(\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\.\d+)\s+\d+\s+\d+\s+([VDIWEF])\s+(.+?):\s+(.*)$/);
143
+ if (!match)
144
+ return;
145
+ const [, ts, level, tag, message] = match;
146
+ const entry = {
147
+ timestamp: ts,
148
+ level: mapPriority(level),
149
+ message: `[${tag}] ${message}`,
150
+ stackTrace: null,
151
+ source: 'device',
152
+ };
153
+ this.callback?.(entry);
154
+ }
155
+ }
156
+ exports.AndroidLogSource = AndroidLogSource;
@@ -0,0 +1,112 @@
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.DaemonLogSource = void 0;
7
+ /**
8
+ * Daemon log source — polls the daemon's /logs HTTP endpoint over Unix socket.
9
+ *
10
+ * Used by the `logs` command for streaming mode. For snapshot mode (--recent),
11
+ * the CLI calls fetchDaemonLogs() directly instead.
12
+ */
13
+ const http_1 = __importDefault(require("http"));
14
+ const protocol_js_1 = require("../../daemon/protocol.js");
15
+ const POLL_INTERVAL_MS = 500;
16
+ class DaemonLogSource {
17
+ constructor(sessionName, metroPort) {
18
+ this.sessionName = sessionName;
19
+ this.metroPort = metroPort;
20
+ this.callback = null;
21
+ this.pollTimer = null;
22
+ this.since = new Date().toISOString();
23
+ this.stopped = false;
24
+ this.metroSent = false;
25
+ this.sockPath = (0, protocol_js_1.socketPath)(sessionName);
26
+ }
27
+ async connect() {
28
+ // Verify daemon is reachable
29
+ const alive = await this.checkAlive();
30
+ if (!alive) {
31
+ throw new Error(`Daemon for session "${this.sessionName}" is not responding. Is it running?`);
32
+ }
33
+ this.startPolling();
34
+ }
35
+ onEntry(callback) {
36
+ this.callback = callback;
37
+ }
38
+ disconnect() {
39
+ this.stopped = true;
40
+ if (this.pollTimer) {
41
+ clearTimeout(this.pollTimer);
42
+ this.pollTimer = null;
43
+ }
44
+ }
45
+ startPolling() {
46
+ const poll = async () => {
47
+ if (this.stopped)
48
+ return;
49
+ try {
50
+ const entries = await this.fetchLogs();
51
+ for (const entry of entries) {
52
+ this.callback?.(entry);
53
+ }
54
+ if (entries.length > 0) {
55
+ this.since = entries[entries.length - 1].timestamp;
56
+ }
57
+ }
58
+ catch {
59
+ // Daemon may have restarted — keep polling
60
+ }
61
+ if (!this.stopped) {
62
+ this.pollTimer = setTimeout(poll, POLL_INTERVAL_MS);
63
+ }
64
+ };
65
+ poll();
66
+ }
67
+ fetchLogs() {
68
+ // Include metro param on the first poll to trigger discovery in the daemon
69
+ let reqPath = `/logs?since=${encodeURIComponent(this.since)}`;
70
+ if (this.metroPort !== undefined && !this.metroSent) {
71
+ reqPath += this.metroPort === 'auto' ? '&metro' : `&metro=${this.metroPort}`;
72
+ this.metroSent = true;
73
+ }
74
+ return new Promise((resolve, reject) => {
75
+ const req = http_1.default.get({
76
+ socketPath: this.sockPath,
77
+ path: reqPath,
78
+ }, (res) => {
79
+ const chunks = [];
80
+ res.on('data', (chunk) => chunks.push(chunk));
81
+ res.on('end', () => {
82
+ try {
83
+ const data = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
84
+ resolve(data.entries ?? []);
85
+ }
86
+ catch {
87
+ resolve([]);
88
+ }
89
+ });
90
+ });
91
+ req.setTimeout(5000, () => {
92
+ req.destroy();
93
+ reject(new Error('Timeout polling daemon logs'));
94
+ });
95
+ req.on('error', reject);
96
+ });
97
+ }
98
+ checkAlive() {
99
+ return new Promise((resolve) => {
100
+ const req = http_1.default.get({ socketPath: this.sockPath, path: '/status' }, (res) => {
101
+ resolve(res.statusCode === 200);
102
+ res.resume();
103
+ });
104
+ req.setTimeout(2000, () => {
105
+ req.destroy();
106
+ resolve(false);
107
+ });
108
+ req.on('error', () => resolve(false));
109
+ });
110
+ }
111
+ }
112
+ exports.DaemonLogSource = DaemonLogSource;
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IOSLogSource = void 0;
4
+ /**
5
+ * iOS log source — streams logs from an iOS simulator via `xcrun simctl spawn ... log stream`.
6
+ */
7
+ const child_process_1 = require("child_process");
8
+ function mapMessageType(messageType) {
9
+ switch (messageType.toLowerCase()) {
10
+ case 'fault':
11
+ return 'error';
12
+ case 'error':
13
+ return 'error';
14
+ case 'default':
15
+ return 'log';
16
+ case 'info':
17
+ return 'info';
18
+ case 'debug':
19
+ return 'debug';
20
+ default:
21
+ return 'log';
22
+ }
23
+ }
24
+ class IOSLogSource {
25
+ constructor(deviceId, appId) {
26
+ this.deviceId = deviceId;
27
+ this.appId = appId;
28
+ this.proc = null;
29
+ this.callback = null;
30
+ this.buffer = '';
31
+ }
32
+ async connect() {
33
+ const args = [
34
+ 'simctl',
35
+ 'spawn',
36
+ this.deviceId,
37
+ 'log',
38
+ 'stream',
39
+ '--style',
40
+ 'ndjson',
41
+ '--level',
42
+ 'debug',
43
+ ];
44
+ if (this.appId) {
45
+ // Filter to just this app's process. The process name is typically the
46
+ // last component of the bundle ID (e.g. "MyApp" from "com.example.MyApp"),
47
+ // but simctl log stream matches on the full process image path, so use
48
+ // a CONTAINS predicate for robustness.
49
+ args.push('--predicate', `process CONTAINS "${this.appId.split('.').pop()}"`);
50
+ }
51
+ this.proc = (0, child_process_1.spawn)('xcrun', args, { stdio: ['ignore', 'pipe', 'ignore'] });
52
+ this.proc.stdout.on('data', (chunk) => {
53
+ this.buffer += chunk.toString('utf-8');
54
+ const lines = this.buffer.split('\n');
55
+ // Keep the last (potentially incomplete) line in the buffer
56
+ this.buffer = lines.pop() ?? '';
57
+ for (const line of lines) {
58
+ if (!line.trim())
59
+ continue;
60
+ this.parseLine(line);
61
+ }
62
+ });
63
+ this.proc.on('error', () => {
64
+ // xcrun not available or similar — ignore, the command will have errored on startup
65
+ });
66
+ // Give simctl a moment to start streaming — if it exits immediately,
67
+ // it likely means the device ID is invalid.
68
+ await new Promise((resolve, reject) => {
69
+ const timer = setTimeout(resolve, 500);
70
+ this.proc.on('close', (code) => {
71
+ clearTimeout(timer);
72
+ if (code !== 0 && code !== null) {
73
+ reject(new Error(`simctl log stream exited with code ${code}. Is the simulator booted?`));
74
+ }
75
+ });
76
+ });
77
+ }
78
+ onEntry(callback) {
79
+ this.callback = callback;
80
+ }
81
+ disconnect() {
82
+ if (this.proc) {
83
+ this.proc.kill('SIGTERM');
84
+ this.proc = null;
85
+ }
86
+ }
87
+ parseLine(line) {
88
+ try {
89
+ const data = JSON.parse(line);
90
+ if (!data.eventMessage)
91
+ return;
92
+ const entry = {
93
+ timestamp: data.timestamp || new Date().toISOString(),
94
+ level: mapMessageType(data.messageType ?? 'Default'),
95
+ message: data.eventMessage,
96
+ stackTrace: null,
97
+ source: 'device',
98
+ };
99
+ this.callback?.(entry);
100
+ }
101
+ catch {
102
+ // Non-JSON lines (e.g. simctl header) — skip
103
+ }
104
+ }
105
+ }
106
+ exports.IOSLogSource = IOSLogSource;