@houwert/conductor 0.5.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.
- 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 +110 -32
- package/dist/daemon/web-server.js +812 -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 +62 -12
- 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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
|
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;
|