@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.
Files changed (52) hide show
  1. package/README.md +12 -9
  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 +11 -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.js +103 -27
  15. package/dist/commands/launch-app.js +6 -0
  16. package/dist/commands/list-devices.js +39 -2
  17. package/dist/commands/logs.js +193 -0
  18. package/dist/commands/press-key.js +16 -0
  19. package/dist/commands/screenshot.js +1 -1
  20. package/dist/commands/scroll-until-visible.js +11 -0
  21. package/dist/commands/scroll.js +6 -0
  22. package/dist/commands/start-device.js +38 -4
  23. package/dist/commands/stop-app.js +4 -0
  24. package/dist/commands/swipe.js +22 -0
  25. package/dist/commands/tap.js +10 -3
  26. package/dist/commands/type.js +2 -2
  27. package/dist/commands/uninstall-app.js +4 -0
  28. package/dist/daemon/client.js +72 -9
  29. package/dist/daemon/log-collector.js +408 -0
  30. package/dist/daemon/server.js +110 -32
  31. package/dist/daemon/web-server.js +812 -0
  32. package/dist/device-picker.js +7 -2
  33. package/dist/drivers/bootstrap.js +124 -1
  34. package/dist/drivers/element-resolver.js +241 -30
  35. package/dist/drivers/flow-runner.js +63 -21
  36. package/dist/drivers/log-sources/android.js +156 -0
  37. package/dist/drivers/log-sources/daemon.js +112 -0
  38. package/dist/drivers/log-sources/ios.js +106 -0
  39. package/dist/drivers/log-sources/metro.js +252 -0
  40. package/dist/drivers/log-sources/types.js +13 -0
  41. package/dist/drivers/log-sources/web.js +96 -0
  42. package/dist/drivers/wait.js +57 -0
  43. package/dist/drivers/web.js +173 -0
  44. package/dist/index.js +62 -12
  45. package/dist/runner.js +32 -2
  46. package/drivers/ios/conductor-driver-ios.zip +0 -0
  47. package/drivers/ios/conductor-driver-iosUITests-Runner.zip +0 -0
  48. package/drivers/tvos/conductor-driver-tvos.zip +0 -0
  49. package/drivers/tvos/conductor-driver-tvosUITests-Runner.zip +0 -0
  50. package/package.json +5 -2
  51. package/skills/conductor/SKILL.md +72 -41
  52. 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;
@@ -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;