@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,252 @@
|
|
|
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.MetroLogSource = void 0;
|
|
7
|
+
exports.fetchTargets = fetchTargets;
|
|
8
|
+
/**
|
|
9
|
+
* Metro CDP log source — connects to the React Native Metro dev server's
|
|
10
|
+
* Chrome DevTools Protocol endpoint to stream JS console output.
|
|
11
|
+
*/
|
|
12
|
+
const http_1 = __importDefault(require("http"));
|
|
13
|
+
const ws_1 = __importDefault(require("ws"));
|
|
14
|
+
/** Strip ANSI escape sequences from a string. */
|
|
15
|
+
function stripAnsi(s) {
|
|
16
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
17
|
+
}
|
|
18
|
+
function serializeRemoteObject(obj) {
|
|
19
|
+
if (obj.type === 'string')
|
|
20
|
+
return stripAnsi(String(obj.value ?? ''));
|
|
21
|
+
if (obj.type === 'number' || obj.type === 'boolean')
|
|
22
|
+
return String(obj.value);
|
|
23
|
+
if (obj.type === 'undefined')
|
|
24
|
+
return 'undefined';
|
|
25
|
+
if (obj.type === 'symbol')
|
|
26
|
+
return obj.description ?? 'Symbol()';
|
|
27
|
+
if (obj.subtype === 'null')
|
|
28
|
+
return 'null';
|
|
29
|
+
return obj.description ?? obj.preview?.description ?? `[${obj.type}]`;
|
|
30
|
+
}
|
|
31
|
+
function mapCDPLevel(type) {
|
|
32
|
+
switch (type) {
|
|
33
|
+
case 'warning':
|
|
34
|
+
return 'warning';
|
|
35
|
+
case 'error':
|
|
36
|
+
return 'error';
|
|
37
|
+
case 'info':
|
|
38
|
+
return 'info';
|
|
39
|
+
case 'debug':
|
|
40
|
+
return 'debug';
|
|
41
|
+
case 'trace':
|
|
42
|
+
return 'verbose';
|
|
43
|
+
default:
|
|
44
|
+
return 'log';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Strip Metro bundle URLs down to just the file path. */
|
|
48
|
+
function cleanUrl(raw) {
|
|
49
|
+
// Metro URLs look like: http://localhost:8082/index.bundle//&platform=ios&dev=true&...
|
|
50
|
+
// Or relative paths from source maps. Keep just the filename if it's a bundle URL.
|
|
51
|
+
try {
|
|
52
|
+
const u = new URL(raw);
|
|
53
|
+
// It's a full URL — strip to pathname, remove /index.bundle prefix
|
|
54
|
+
let p = u.pathname;
|
|
55
|
+
if (p.startsWith('/index.bundle'))
|
|
56
|
+
p = '<bundle>';
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Already a plain path — return as-is
|
|
61
|
+
return raw;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Metro bundler-internal function names that repeat in require cycles. */
|
|
65
|
+
const METRO_INTERNALS = new Set([
|
|
66
|
+
'metroRequire',
|
|
67
|
+
'loadModuleImplementation',
|
|
68
|
+
'guardedLoadModule',
|
|
69
|
+
'metroImportDefault',
|
|
70
|
+
'metroImportAll',
|
|
71
|
+
]);
|
|
72
|
+
const MAX_STACK_FRAMES = 10;
|
|
73
|
+
function formatStackTrace(st) {
|
|
74
|
+
if (!st || st.callFrames.length === 0)
|
|
75
|
+
return null;
|
|
76
|
+
// Filter out repetitive Metro bundler internals
|
|
77
|
+
const meaningful = st.callFrames.filter((f) => !METRO_INTERNALS.has(f.functionName));
|
|
78
|
+
const frames = meaningful.slice(0, MAX_STACK_FRAMES);
|
|
79
|
+
const lines = frames.map((f) => {
|
|
80
|
+
const fn = f.functionName || '<anonymous>';
|
|
81
|
+
return ` at ${fn} (${cleanUrl(f.url)}:${f.lineNumber + 1}:${f.columnNumber + 1})`;
|
|
82
|
+
});
|
|
83
|
+
const omitted = meaningful.length - frames.length;
|
|
84
|
+
if (omitted > 0) {
|
|
85
|
+
lines.push(` ... ${omitted} more frames`);
|
|
86
|
+
}
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|
|
89
|
+
/** Fetch the list of debugger targets from Metro's /json endpoint. */
|
|
90
|
+
async function fetchTargets(port, host) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const req = http_1.default.get(`http://${host}:${port}/json`, (res) => {
|
|
93
|
+
const chunks = [];
|
|
94
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
95
|
+
res.on('end', () => {
|
|
96
|
+
try {
|
|
97
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString('utf-8')));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
reject(new Error('Failed to parse Metro /json response'));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
req.setTimeout(2000, () => {
|
|
105
|
+
req.destroy();
|
|
106
|
+
reject(new Error(`Could not connect to Metro on port ${port}. Is Metro running?`));
|
|
107
|
+
});
|
|
108
|
+
req.on('error', (err) => {
|
|
109
|
+
if (err.code === 'ECONNREFUSED') {
|
|
110
|
+
reject(new Error(`Could not connect to Metro on port ${port}. Is Metro running?`));
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
reject(err);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Discover the WebSocket debugger URL from Metro's /json endpoint.
|
|
120
|
+
*
|
|
121
|
+
* When `targetIndex` is provided, connects to that specific target (0-based).
|
|
122
|
+
* This lets agents running on multiple devices each pick their own debugger
|
|
123
|
+
* target explicitly, since Metro's internal target IDs don't map cleanly to
|
|
124
|
+
* simulator UDIDs or emulator serial numbers.
|
|
125
|
+
*
|
|
126
|
+
* When omitted, prefers the first Hermes target, then the first available.
|
|
127
|
+
* If there are multiple targets and no index was given, logs a hint about
|
|
128
|
+
* using `--target` to select one.
|
|
129
|
+
*/
|
|
130
|
+
async function discoverTarget(port, host, targetIndex) {
|
|
131
|
+
const data = await fetchTargets(port, host);
|
|
132
|
+
const withWs = data.filter((t) => t.webSocketDebuggerUrl);
|
|
133
|
+
if (withWs.length === 0) {
|
|
134
|
+
throw new Error('Metro returned no debugger targets. Is the app running on a device/simulator?');
|
|
135
|
+
}
|
|
136
|
+
if (targetIndex !== undefined) {
|
|
137
|
+
if (targetIndex < 0 || targetIndex >= withWs.length) {
|
|
138
|
+
const list = withWs
|
|
139
|
+
.map((t, i) => ` ${i}: ${t.title ?? t.description ?? t.deviceName ?? '(unnamed)'}`)
|
|
140
|
+
.join('\n');
|
|
141
|
+
throw new Error(`--target ${targetIndex} is out of range. Available Metro targets:\n${list}`);
|
|
142
|
+
}
|
|
143
|
+
return withWs[targetIndex].webSocketDebuggerUrl;
|
|
144
|
+
}
|
|
145
|
+
if (withWs.length > 1) {
|
|
146
|
+
const list = withWs
|
|
147
|
+
.map((t, i) => ` ${i}: ${t.title ?? t.description ?? t.deviceName ?? '(unnamed)'}`)
|
|
148
|
+
.join('\n');
|
|
149
|
+
console.error(`Multiple Metro targets found. Using target 0. Use --target <n> to select:\n${list}`);
|
|
150
|
+
}
|
|
151
|
+
const target = withWs.find((t) => t.title && /hermes|react/i.test(t.title)) ?? withWs[0];
|
|
152
|
+
return target.webSocketDebuggerUrl;
|
|
153
|
+
}
|
|
154
|
+
class MetroLogSource {
|
|
155
|
+
constructor(port = 8081, host = 'localhost', targetIndex) {
|
|
156
|
+
this.port = port;
|
|
157
|
+
this.host = host;
|
|
158
|
+
this.targetIndex = targetIndex;
|
|
159
|
+
this.ws = null;
|
|
160
|
+
this.callback = null;
|
|
161
|
+
this.nextId = 1;
|
|
162
|
+
this.reconnecting = false;
|
|
163
|
+
this.disconnected = false;
|
|
164
|
+
}
|
|
165
|
+
async connect() {
|
|
166
|
+
const wsUrl = await discoverTarget(this.port, this.host, this.targetIndex);
|
|
167
|
+
await this.openSocket(wsUrl);
|
|
168
|
+
}
|
|
169
|
+
onEntry(callback) {
|
|
170
|
+
this.callback = callback;
|
|
171
|
+
}
|
|
172
|
+
disconnect() {
|
|
173
|
+
this.disconnected = true;
|
|
174
|
+
if (this.ws) {
|
|
175
|
+
this.ws.removeAllListeners();
|
|
176
|
+
this.ws.close();
|
|
177
|
+
this.ws = null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async openSocket(wsUrl) {
|
|
181
|
+
return new Promise((resolve, reject) => {
|
|
182
|
+
const ws = new ws_1.default(wsUrl);
|
|
183
|
+
this.ws = ws;
|
|
184
|
+
ws.on('open', () => {
|
|
185
|
+
// Enable the Runtime domain to receive consoleAPICalled events
|
|
186
|
+
ws.send(JSON.stringify({ id: this.nextId++, method: 'Runtime.enable' }));
|
|
187
|
+
resolve();
|
|
188
|
+
});
|
|
189
|
+
ws.on('message', (data) => {
|
|
190
|
+
try {
|
|
191
|
+
const msg = JSON.parse(data.toString());
|
|
192
|
+
if (msg.method === 'Runtime.consoleAPICalled') {
|
|
193
|
+
const { type, args, timestamp, stackTrace } = msg.params;
|
|
194
|
+
const entry = {
|
|
195
|
+
timestamp: new Date(timestamp).toISOString(),
|
|
196
|
+
level: mapCDPLevel(type),
|
|
197
|
+
message: args.map(serializeRemoteObject).join(' '),
|
|
198
|
+
stackTrace: formatStackTrace(stackTrace),
|
|
199
|
+
source: 'metro',
|
|
200
|
+
};
|
|
201
|
+
this.callback?.(entry);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// Ignore non-matching messages
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
ws.on('close', () => {
|
|
209
|
+
if (!this.disconnected) {
|
|
210
|
+
this.scheduleReconnect();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
ws.on('error', (err) => {
|
|
214
|
+
if (!this.ws) {
|
|
215
|
+
// Connection never opened
|
|
216
|
+
reject(err);
|
|
217
|
+
}
|
|
218
|
+
// Errors while running will trigger 'close' → reconnect
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
scheduleReconnect(attempt = 0) {
|
|
223
|
+
if (this.disconnected || this.reconnecting)
|
|
224
|
+
return;
|
|
225
|
+
this.reconnecting = true;
|
|
226
|
+
const delay = Math.min(1000 * Math.pow(2, attempt), 10000);
|
|
227
|
+
setTimeout(async () => {
|
|
228
|
+
if (this.disconnected) {
|
|
229
|
+
this.reconnecting = false;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const wsUrl = await discoverTarget(this.port, this.host, this.targetIndex);
|
|
234
|
+
await this.openSocket(wsUrl);
|
|
235
|
+
this.reconnecting = false;
|
|
236
|
+
// Signal reconnection to the user via a synthetic log entry
|
|
237
|
+
this.callback?.({
|
|
238
|
+
timestamp: new Date().toISOString(),
|
|
239
|
+
level: 'info',
|
|
240
|
+
message: '[conductor] Reconnected to Metro',
|
|
241
|
+
stackTrace: null,
|
|
242
|
+
source: 'metro',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
this.reconnecting = false;
|
|
247
|
+
this.scheduleReconnect(attempt + 1);
|
|
248
|
+
}
|
|
249
|
+
}, delay);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
exports.MetroLogSource = MetroLogSource;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LEVEL_SEVERITY = void 0;
|
|
4
|
+
/** Numeric severity for level filtering. Includes short aliases. */
|
|
5
|
+
exports.LEVEL_SEVERITY = {
|
|
6
|
+
verbose: 0,
|
|
7
|
+
debug: 1,
|
|
8
|
+
log: 2,
|
|
9
|
+
info: 3,
|
|
10
|
+
warning: 4,
|
|
11
|
+
warn: 4,
|
|
12
|
+
error: 5,
|
|
13
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
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.WebLogSource = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* Web log source — polls the Conductor daemon's web server for Playwright console events.
|
|
9
|
+
*/
|
|
10
|
+
const http_1 = __importDefault(require("http"));
|
|
11
|
+
class WebLogSource {
|
|
12
|
+
constructor(port, host = '127.0.0.1') {
|
|
13
|
+
this.port = port;
|
|
14
|
+
this.host = host;
|
|
15
|
+
this.callback = null;
|
|
16
|
+
this.pollTimer = null;
|
|
17
|
+
this.since = new Date().toISOString();
|
|
18
|
+
this.stopped = false;
|
|
19
|
+
}
|
|
20
|
+
async connect() {
|
|
21
|
+
// Verify the web driver is reachable
|
|
22
|
+
const alive = await this.checkAlive();
|
|
23
|
+
if (!alive) {
|
|
24
|
+
throw new Error(`Web driver on port ${this.port} is not responding. Is the web session running?`);
|
|
25
|
+
}
|
|
26
|
+
this.startPolling();
|
|
27
|
+
}
|
|
28
|
+
onEntry(callback) {
|
|
29
|
+
this.callback = callback;
|
|
30
|
+
}
|
|
31
|
+
disconnect() {
|
|
32
|
+
this.stopped = true;
|
|
33
|
+
if (this.pollTimer) {
|
|
34
|
+
clearTimeout(this.pollTimer);
|
|
35
|
+
this.pollTimer = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
startPolling() {
|
|
39
|
+
const poll = async () => {
|
|
40
|
+
if (this.stopped)
|
|
41
|
+
return;
|
|
42
|
+
try {
|
|
43
|
+
const entries = await this.fetchLogs();
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
this.callback?.(entry);
|
|
46
|
+
}
|
|
47
|
+
if (entries.length > 0) {
|
|
48
|
+
this.since = entries[entries.length - 1].timestamp;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Web driver may have restarted — keep polling
|
|
53
|
+
}
|
|
54
|
+
if (!this.stopped) {
|
|
55
|
+
this.pollTimer = setTimeout(poll, 500);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
poll();
|
|
59
|
+
}
|
|
60
|
+
fetchLogs() {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const req = http_1.default.get(`http://${this.host}:${this.port}/consoleLogs?since=${encodeURIComponent(this.since)}`, (res) => {
|
|
63
|
+
const chunks = [];
|
|
64
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
65
|
+
res.on('end', () => {
|
|
66
|
+
try {
|
|
67
|
+
const data = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
68
|
+
resolve(data.entries ?? []);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
resolve([]);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
req.setTimeout(5000, () => {
|
|
76
|
+
req.destroy();
|
|
77
|
+
reject(new Error('Timeout polling console logs'));
|
|
78
|
+
});
|
|
79
|
+
req.on('error', reject);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
checkAlive() {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const req = http_1.default.get(`http://${this.host}:${this.port}/status`, (res) => {
|
|
85
|
+
resolve(res.statusCode === 200);
|
|
86
|
+
res.resume();
|
|
87
|
+
});
|
|
88
|
+
req.setTimeout(2000, () => {
|
|
89
|
+
req.destroy();
|
|
90
|
+
resolve(false);
|
|
91
|
+
});
|
|
92
|
+
req.on('error', () => resolve(false));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exports.WebLogSource = WebLogSource;
|
package/dist/drivers/wait.js
CHANGED
|
@@ -9,6 +9,9 @@ exports.waitForIOSScreenToSettle = waitForIOSScreenToSettle;
|
|
|
9
9
|
exports.waitForIOSTransitionToSettle = waitForIOSTransitionToSettle;
|
|
10
10
|
exports.waitForIOSHierarchyToSettle = waitForIOSHierarchyToSettle;
|
|
11
11
|
exports.waitForAndroidHierarchyToSettle = waitForAndroidHierarchyToSettle;
|
|
12
|
+
exports.waitForWebElement = waitForWebElement;
|
|
13
|
+
exports.waitUntilWebElementGone = waitUntilWebElementGone;
|
|
14
|
+
exports.waitForWebHierarchyToSettle = waitForWebHierarchyToSettle;
|
|
12
15
|
const element_resolver_js_1 = require("./element-resolver.js");
|
|
13
16
|
const utils_js_1 = require("../utils.js");
|
|
14
17
|
const DEFAULT_TIMEOUT_MS = 17000;
|
|
@@ -181,6 +184,60 @@ async function waitForAndroidHierarchyToSettle(getHierarchy, timeoutMs = 3000, i
|
|
|
181
184
|
await (0, utils_js_1.sleep)(intervalMs);
|
|
182
185
|
}
|
|
183
186
|
}
|
|
187
|
+
// ── Web ──────────────────────────────────────────────────────────────────────
|
|
188
|
+
async function waitForWebElement(getHierarchy, selector, timeoutMs = DEFAULT_TIMEOUT_MS, intervalMs = DEFAULT_INTERVAL_MS) {
|
|
189
|
+
const deadline = Date.now() + timeoutMs;
|
|
190
|
+
while (Date.now() < deadline) {
|
|
191
|
+
try {
|
|
192
|
+
const hierarchy = await getHierarchy();
|
|
193
|
+
const el = (0, element_resolver_js_1.findWebElement)(hierarchy, selector);
|
|
194
|
+
if (el)
|
|
195
|
+
return el;
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Hierarchy fetch failed; keep retrying
|
|
199
|
+
}
|
|
200
|
+
await (0, utils_js_1.sleep)(intervalMs);
|
|
201
|
+
}
|
|
202
|
+
const desc = selectorDesc(selector);
|
|
203
|
+
throw new Error(`Element not found after ${timeoutMs}ms: ${desc}`);
|
|
204
|
+
}
|
|
205
|
+
async function waitUntilWebElementGone(getHierarchy, selector, timeoutMs = exports.OPTIONAL_TIMEOUT_MS) {
|
|
206
|
+
const deadline = Date.now() + timeoutMs;
|
|
207
|
+
do {
|
|
208
|
+
try {
|
|
209
|
+
const hierarchy = await getHierarchy();
|
|
210
|
+
if (!(0, element_resolver_js_1.findWebElement)(hierarchy, selector))
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return; // hierarchy fetch failed — treat as gone
|
|
215
|
+
}
|
|
216
|
+
await (0, utils_js_1.sleep)(DEFAULT_INTERVAL_MS);
|
|
217
|
+
} while (Date.now() < deadline);
|
|
218
|
+
const desc = selectorDesc(selector);
|
|
219
|
+
throw new Error(`Element still visible after ${timeoutMs}ms: ${desc}`);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Wait until the web page ARIA snapshot stops changing between consecutive polls.
|
|
223
|
+
*/
|
|
224
|
+
async function waitForWebHierarchyToSettle(getHierarchy, timeoutMs = 3000, intervalMs = 200) {
|
|
225
|
+
const deadline = Date.now() + timeoutMs;
|
|
226
|
+
let prev = null;
|
|
227
|
+
while (Date.now() < deadline) {
|
|
228
|
+
try {
|
|
229
|
+
const hierarchy = await getHierarchy();
|
|
230
|
+
const curr = hierarchy.ariaSnapshot;
|
|
231
|
+
if (curr === prev)
|
|
232
|
+
return; // stable
|
|
233
|
+
prev = curr;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// ignore
|
|
237
|
+
}
|
|
238
|
+
await (0, utils_js_1.sleep)(intervalMs);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
184
241
|
function selectorDesc(sel) {
|
|
185
242
|
const parts = [];
|
|
186
243
|
if (sel.query)
|
|
@@ -0,0 +1,173 @@
|
|
|
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.WebDriver = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* HTTP client for the Conductor web browser driver.
|
|
9
|
+
* The driver runs inside the daemon process at http://127.0.0.1:4075 (or custom port).
|
|
10
|
+
*
|
|
11
|
+
* Protocol: plain HTTP REST with JSON bodies — mirrors the iOS XCTest driver pattern.
|
|
12
|
+
* The daemon-side web-server.ts wraps Playwright and exposes these endpoints.
|
|
13
|
+
*/
|
|
14
|
+
const http_1 = __importDefault(require("http"));
|
|
15
|
+
class WebDriver {
|
|
16
|
+
constructor(port = 4075, host = '127.0.0.1', deviceId) {
|
|
17
|
+
this.port = port;
|
|
18
|
+
this.host = host;
|
|
19
|
+
this.deviceId = deviceId;
|
|
20
|
+
}
|
|
21
|
+
request(method, path, body) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const bodyBuf = body !== undefined ? Buffer.from(JSON.stringify(body), 'utf-8') : undefined;
|
|
24
|
+
const options = {
|
|
25
|
+
hostname: this.host,
|
|
26
|
+
port: this.port,
|
|
27
|
+
path,
|
|
28
|
+
method,
|
|
29
|
+
headers: {
|
|
30
|
+
...(bodyBuf
|
|
31
|
+
? { 'Content-Type': 'application/json', 'Content-Length': bodyBuf.length }
|
|
32
|
+
: {}),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
const req = http_1.default.request(options, (res) => {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
38
|
+
res.on('end', () => resolve({ status: res.statusCode ?? 0, data: Buffer.concat(chunks) }));
|
|
39
|
+
res.on('error', reject);
|
|
40
|
+
});
|
|
41
|
+
req.setTimeout(30000, () => {
|
|
42
|
+
req.destroy(new Error(`Web driver request timed out: ${method} ${path}`));
|
|
43
|
+
});
|
|
44
|
+
req.on('error', reject);
|
|
45
|
+
if (bodyBuf)
|
|
46
|
+
req.write(bodyBuf);
|
|
47
|
+
req.end();
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
async post(path, body) {
|
|
51
|
+
const { status, data } = await this.request('POST', `/${path}`, body);
|
|
52
|
+
if (status < 200 || status >= 300) {
|
|
53
|
+
throw new Error(`Web driver ${path} failed (HTTP ${status}): ${data.toString('utf-8').slice(0, 200)}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async get(path) {
|
|
57
|
+
const { status, data } = await this.request('GET', `/${path}`);
|
|
58
|
+
if (status < 200 || status >= 300) {
|
|
59
|
+
throw new Error(`Web driver GET ${path} failed (HTTP ${status}): ${data.toString('utf-8').slice(0, 200)}`);
|
|
60
|
+
}
|
|
61
|
+
return JSON.parse(data.toString('utf-8'));
|
|
62
|
+
}
|
|
63
|
+
async isAlive() {
|
|
64
|
+
try {
|
|
65
|
+
const { status } = await this.request('GET', '/status');
|
|
66
|
+
return status >= 200 && status < 300;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async deviceInfo() {
|
|
73
|
+
return this.get('deviceInfo');
|
|
74
|
+
}
|
|
75
|
+
async tap(x, y, duration) {
|
|
76
|
+
await this.post('tap', { x, y, ...(duration !== undefined ? { duration } : {}) });
|
|
77
|
+
}
|
|
78
|
+
async swipe(startX, startY, endX, endY, duration) {
|
|
79
|
+
await this.post('swipe', { startX, startY, endX, endY, duration });
|
|
80
|
+
}
|
|
81
|
+
async inputText(text) {
|
|
82
|
+
await this.post('inputText', { text });
|
|
83
|
+
}
|
|
84
|
+
async pressKey(key) {
|
|
85
|
+
await this.post('pressKey', { key });
|
|
86
|
+
}
|
|
87
|
+
async launchApp(url) {
|
|
88
|
+
await this.post('launchApp', { url });
|
|
89
|
+
}
|
|
90
|
+
async terminateApp() {
|
|
91
|
+
await this.post('terminateApp', {});
|
|
92
|
+
}
|
|
93
|
+
async clearAppState() {
|
|
94
|
+
await this.post('clearAppState', {});
|
|
95
|
+
}
|
|
96
|
+
async openLink(url) {
|
|
97
|
+
await this.post('navigate', { url });
|
|
98
|
+
}
|
|
99
|
+
async navigate(url) {
|
|
100
|
+
await this.post('navigate', { url });
|
|
101
|
+
}
|
|
102
|
+
async goBack() {
|
|
103
|
+
await this.post('goBack', {});
|
|
104
|
+
}
|
|
105
|
+
async goForward() {
|
|
106
|
+
await this.post('goForward', {});
|
|
107
|
+
}
|
|
108
|
+
async reload() {
|
|
109
|
+
await this.post('reload', {});
|
|
110
|
+
}
|
|
111
|
+
async clearCookies() {
|
|
112
|
+
await this.post('clearCookies', {});
|
|
113
|
+
}
|
|
114
|
+
async clearStorage() {
|
|
115
|
+
await this.post('clearStorage', {});
|
|
116
|
+
}
|
|
117
|
+
async clearKeychain() {
|
|
118
|
+
// Web equivalent: clear cookies
|
|
119
|
+
await this.clearCookies();
|
|
120
|
+
}
|
|
121
|
+
async viewHierarchy() {
|
|
122
|
+
return this.get('viewHierarchy');
|
|
123
|
+
}
|
|
124
|
+
async screenshot() {
|
|
125
|
+
const { status, data } = await this.request('GET', '/screenshot');
|
|
126
|
+
if (status < 200 || status >= 300) {
|
|
127
|
+
throw new Error(`Web driver screenshot failed (HTTP ${status})`);
|
|
128
|
+
}
|
|
129
|
+
return data;
|
|
130
|
+
}
|
|
131
|
+
async isScreenStatic() {
|
|
132
|
+
const result = await this.get('isScreenStatic');
|
|
133
|
+
return result.isScreenStatic;
|
|
134
|
+
}
|
|
135
|
+
async runningApp() {
|
|
136
|
+
const result = await this.get('runningApp');
|
|
137
|
+
return result.runningAppBundleId;
|
|
138
|
+
}
|
|
139
|
+
async eraseAllText(count = 50) {
|
|
140
|
+
await this.post('eraseText', { count });
|
|
141
|
+
}
|
|
142
|
+
// ── Stub methods for mobile-only features ──────────────────────────────────
|
|
143
|
+
// These throw clear errors rather than silently no-op so the user knows
|
|
144
|
+
// the command isn't applicable to web.
|
|
145
|
+
async setLocation(_latitude, _longitude) {
|
|
146
|
+
throw new Error('setLocation is not supported on web');
|
|
147
|
+
}
|
|
148
|
+
async setOrientation(_orientation) {
|
|
149
|
+
throw new Error('setOrientation is not supported on web');
|
|
150
|
+
}
|
|
151
|
+
async setPermissions(_appId, _permissions) {
|
|
152
|
+
throw new Error('setPermissions is not supported on web');
|
|
153
|
+
}
|
|
154
|
+
async addMedia(_filePath) {
|
|
155
|
+
throw new Error('addMedia is not supported on web');
|
|
156
|
+
}
|
|
157
|
+
async setAirplaneMode(_enabled) {
|
|
158
|
+
throw new Error('setAirplaneMode is not supported on web');
|
|
159
|
+
}
|
|
160
|
+
async getAirplaneMode() {
|
|
161
|
+
throw new Error('getAirplaneMode is not supported on web');
|
|
162
|
+
}
|
|
163
|
+
async startRecording(_outputPath) {
|
|
164
|
+
throw new Error('startRecording is not supported on web');
|
|
165
|
+
}
|
|
166
|
+
async stopRecording() {
|
|
167
|
+
throw new Error('stopRecording is not supported on web');
|
|
168
|
+
}
|
|
169
|
+
async uninstallApp(_appId) {
|
|
170
|
+
throw new Error('uninstallApp is not supported on web');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
exports.WebDriver = WebDriver;
|