@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,892 @@
|
|
|
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.parseAriaSnapshot = parseAriaSnapshot;
|
|
7
|
+
exports.startWebServer = startWebServer;
|
|
8
|
+
exports.stopWebServer = stopWebServer;
|
|
9
|
+
/**
|
|
10
|
+
* Daemon-embedded HTTP server wrapping Playwright for web browser control.
|
|
11
|
+
*
|
|
12
|
+
* Runs inside the daemon process (spawned by daemon/server.ts) and exposes
|
|
13
|
+
* REST endpoints that the CLI's WebDriver client calls — mirroring the iOS
|
|
14
|
+
* XCTest HTTP server pattern.
|
|
15
|
+
*
|
|
16
|
+
* Browser lifecycle, ARIA snapshot parsing, and bounding-box resolution all
|
|
17
|
+
* happen here so the CLI remains a thin HTTP client.
|
|
18
|
+
*/
|
|
19
|
+
const http_1 = __importDefault(require("http"));
|
|
20
|
+
const url_1 = __importDefault(require("url"));
|
|
21
|
+
const playwright_core_1 = require("playwright-core");
|
|
22
|
+
const MAX_CONSOLE_BUFFER = 1000;
|
|
23
|
+
const _consoleBuffer = [];
|
|
24
|
+
function mapPlaywrightLevel(type) {
|
|
25
|
+
switch (type) {
|
|
26
|
+
case 'warning':
|
|
27
|
+
return 'warning';
|
|
28
|
+
case 'error':
|
|
29
|
+
return 'error';
|
|
30
|
+
case 'info':
|
|
31
|
+
return 'info';
|
|
32
|
+
case 'debug':
|
|
33
|
+
return 'debug';
|
|
34
|
+
case 'trace':
|
|
35
|
+
return 'verbose';
|
|
36
|
+
default:
|
|
37
|
+
return 'log';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function pushConsoleEntry(entry) {
|
|
41
|
+
_consoleBuffer.push(entry);
|
|
42
|
+
if (_consoleBuffer.length > MAX_CONSOLE_BUFFER) {
|
|
43
|
+
_consoleBuffer.splice(0, _consoleBuffer.length - MAX_CONSOLE_BUFFER);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function attachConsoleListeners(page) {
|
|
47
|
+
page.on('console', (msg) => {
|
|
48
|
+
const text = msg.text();
|
|
49
|
+
const loc = msg.location();
|
|
50
|
+
let stackTrace = null;
|
|
51
|
+
if (loc.url && loc.lineNumber !== undefined) {
|
|
52
|
+
stackTrace = ` at ${loc.url}:${loc.lineNumber + 1}:${(loc.columnNumber ?? 0) + 1}`;
|
|
53
|
+
}
|
|
54
|
+
pushConsoleEntry({
|
|
55
|
+
timestamp: new Date().toISOString(),
|
|
56
|
+
level: mapPlaywrightLevel(msg.type()),
|
|
57
|
+
message: text,
|
|
58
|
+
stackTrace,
|
|
59
|
+
source: 'console',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
page.on('pageerror', (err) => {
|
|
63
|
+
pushConsoleEntry({
|
|
64
|
+
timestamp: new Date().toISOString(),
|
|
65
|
+
level: 'error',
|
|
66
|
+
message: err.message,
|
|
67
|
+
stackTrace: err.stack ?? null,
|
|
68
|
+
source: 'console',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
// ── ARIA snapshot parser ─────────────────────────────────────────────────────
|
|
73
|
+
/**
|
|
74
|
+
* Parse Playwright's ariaSnapshot() YAML output into structured WebElement[].
|
|
75
|
+
*
|
|
76
|
+
* Format example:
|
|
77
|
+
* - heading "My App" [level=1]
|
|
78
|
+
* - navigation:
|
|
79
|
+
* - link "Home" [ref=e1]
|
|
80
|
+
* - link "About" [ref=e2]
|
|
81
|
+
* - main:
|
|
82
|
+
* - textbox "Search" [ref=e3]
|
|
83
|
+
* - button "Submit" [ref=e4] [disabled]
|
|
84
|
+
*/
|
|
85
|
+
function parseAriaSnapshot(yaml) {
|
|
86
|
+
const lines = yaml.split('\n');
|
|
87
|
+
const root = [];
|
|
88
|
+
const stack = [{ indent: -1, children: root }];
|
|
89
|
+
for (const line of lines) {
|
|
90
|
+
if (!line.trim() || !line.trim().startsWith('-'))
|
|
91
|
+
continue;
|
|
92
|
+
const indent = line.search(/\S/);
|
|
93
|
+
const content = line.trim().replace(/^-\s*/, '');
|
|
94
|
+
const el = parseAriaLine(content);
|
|
95
|
+
if (!el)
|
|
96
|
+
continue;
|
|
97
|
+
// Find the right parent based on indentation
|
|
98
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
99
|
+
stack.pop();
|
|
100
|
+
}
|
|
101
|
+
const parent = stack[stack.length - 1];
|
|
102
|
+
parent.children.push(el);
|
|
103
|
+
// If this element could have children (ends with ':'), push onto stack
|
|
104
|
+
if (content.endsWith(':') || el.children) {
|
|
105
|
+
if (!el.children)
|
|
106
|
+
el.children = [];
|
|
107
|
+
stack.push({ indent, children: el.children });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return root;
|
|
111
|
+
}
|
|
112
|
+
/** Bracket tokens from one aria snapshot text line, e.g. `[active] [ref=e3]`. */
|
|
113
|
+
function parseBracketAttrs(text) {
|
|
114
|
+
const attrs = {};
|
|
115
|
+
const attrRe = /\[(\w[\w-]*)(?:=([^\]]*))?\]/g;
|
|
116
|
+
let m;
|
|
117
|
+
while ((m = attrRe.exec(text)) !== null) {
|
|
118
|
+
attrs[m[1]] = m[2] ?? 'true';
|
|
119
|
+
}
|
|
120
|
+
return attrs;
|
|
121
|
+
}
|
|
122
|
+
/** `[active]` / `[focused]` (Playwright); `[active=false]` is not focused. */
|
|
123
|
+
function attrsIndicateFocus(attrs) {
|
|
124
|
+
const truthy = (key) => {
|
|
125
|
+
const v = attrs[key];
|
|
126
|
+
return v !== undefined && v !== 'false';
|
|
127
|
+
};
|
|
128
|
+
return truthy('focused') || truthy('active');
|
|
129
|
+
}
|
|
130
|
+
function parseAriaLine(content) {
|
|
131
|
+
// Check if this is a container line like "navigation:" or "main:"
|
|
132
|
+
const containerMatch = content.match(/^(\w[\w-]*)\s*:$/);
|
|
133
|
+
if (containerMatch) {
|
|
134
|
+
return {
|
|
135
|
+
role: containerMatch[1],
|
|
136
|
+
name: '',
|
|
137
|
+
ref: '',
|
|
138
|
+
enabled: true,
|
|
139
|
+
focused: false,
|
|
140
|
+
children: [],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// Parse: role "name" [attr1] [attr2=val] [ref=eN]
|
|
144
|
+
// Also handles: role "name": (container with name)
|
|
145
|
+
const isContainer = content.endsWith(':');
|
|
146
|
+
const line = isContainer ? content.slice(0, -1).trim() : content;
|
|
147
|
+
const roleMatch = line.match(/^(\w[\w-]*)/);
|
|
148
|
+
if (!roleMatch)
|
|
149
|
+
return null;
|
|
150
|
+
const role = roleMatch[1];
|
|
151
|
+
// Extract quoted name
|
|
152
|
+
const nameMatch = line.match(/"([^"]*)"/);
|
|
153
|
+
const name = nameMatch ? nameMatch[1] : '';
|
|
154
|
+
const attrs = parseBracketAttrs(line);
|
|
155
|
+
const el = {
|
|
156
|
+
role,
|
|
157
|
+
name,
|
|
158
|
+
ref: attrs['ref'] ?? '',
|
|
159
|
+
enabled: attrs['disabled'] === undefined,
|
|
160
|
+
// Playwright AI snapshots use [active] for the focused element; older output used [focused].
|
|
161
|
+
focused: attrsIndicateFocus(attrs),
|
|
162
|
+
checked: attrs['checked'] !== undefined ? true : attrs['unchecked'] !== undefined ? false : undefined,
|
|
163
|
+
selected: attrs['selected'] !== undefined ? true : undefined,
|
|
164
|
+
...(isContainer ? { children: [] } : {}),
|
|
165
|
+
};
|
|
166
|
+
return el;
|
|
167
|
+
}
|
|
168
|
+
// ── Bounding box resolution ──────────────────────────────────────────────────
|
|
169
|
+
/**
|
|
170
|
+
* Resolve bounding boxes for elements that carry an aria-snapshot `[ref=e…]`.
|
|
171
|
+
* Playwright exposes these as `aria-ref=<ref>` (see ariaSnapshotFrameRef in page.js).
|
|
172
|
+
*/
|
|
173
|
+
async function resolveBoundingBoxes(page, elements) {
|
|
174
|
+
const queue = [...elements];
|
|
175
|
+
while (queue.length > 0) {
|
|
176
|
+
const el = queue.shift();
|
|
177
|
+
if (el.ref) {
|
|
178
|
+
try {
|
|
179
|
+
const locator = page.locator(`aria-ref=${el.ref}`);
|
|
180
|
+
const box = await locator.boundingBox({ timeout: 750 });
|
|
181
|
+
if (box && box.width > 0 && box.height > 0) {
|
|
182
|
+
el.bounds = { x: box.x, y: box.y, width: box.width, height: box.height };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Element not visible or locator failed — skip
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (el.children) {
|
|
190
|
+
queue.push(...el.children);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Last-resort bounds for nodes still missing boxes: role + accessible name via getByRole.
|
|
196
|
+
*/
|
|
197
|
+
async function resolveBoundingBoxesByRole(page, elements) {
|
|
198
|
+
const queue = [...elements];
|
|
199
|
+
while (queue.length > 0) {
|
|
200
|
+
const el = queue.shift();
|
|
201
|
+
if (el.children)
|
|
202
|
+
queue.push(...el.children);
|
|
203
|
+
if (el.bounds || !el.name)
|
|
204
|
+
continue;
|
|
205
|
+
try {
|
|
206
|
+
const loc = page.getByRole(el.role, {
|
|
207
|
+
name: el.name,
|
|
208
|
+
exact: true,
|
|
209
|
+
});
|
|
210
|
+
const box = await loc.first().boundingBox({ timeout: 600 });
|
|
211
|
+
if (box && box.width > 0 && box.height > 0) {
|
|
212
|
+
el.bounds = { x: box.x, y: box.y, width: box.width, height: box.height };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// Unknown role or no match
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Alternative: resolve bounding boxes by using the Playwright accessibility
|
|
222
|
+
* snapshot and matching refs to the ARIA snapshot elements. This gives us
|
|
223
|
+
* all bounding boxes in a single call rather than per-element.
|
|
224
|
+
*/
|
|
225
|
+
async function resolveBoundingBoxesBatch(page, elements) {
|
|
226
|
+
// Use page.evaluate to get all elements with their bounding rects in one shot
|
|
227
|
+
const refMap = new Map();
|
|
228
|
+
flattenRefs(elements, refMap);
|
|
229
|
+
if (refMap.size === 0)
|
|
230
|
+
return;
|
|
231
|
+
// Get the Playwright accessibility snapshot which includes name/role but not bounds.
|
|
232
|
+
// Then use evaluate to get bounding boxes for visible interactive elements.
|
|
233
|
+
try {
|
|
234
|
+
const rects = (await page.evaluate(`(() => {
|
|
235
|
+
const results = {};
|
|
236
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
|
237
|
+
let node = walker.currentNode;
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
while (node) {
|
|
240
|
+
const el = node;
|
|
241
|
+
const rect = el.getBoundingClientRect();
|
|
242
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
243
|
+
const name =
|
|
244
|
+
el.getAttribute('aria-label') ||
|
|
245
|
+
el.getAttribute('alt') ||
|
|
246
|
+
el.getAttribute('title') ||
|
|
247
|
+
el.getAttribute('placeholder') ||
|
|
248
|
+
(el.textContent || '').trim().slice(0, 100);
|
|
249
|
+
const role =
|
|
250
|
+
el.getAttribute('role') || el.tagName.toLowerCase();
|
|
251
|
+
const key = role + ':' + name;
|
|
252
|
+
if (name && !seen.has(key)) {
|
|
253
|
+
seen.add(key);
|
|
254
|
+
results[key] = {
|
|
255
|
+
x: Math.round(rect.x),
|
|
256
|
+
y: Math.round(rect.y),
|
|
257
|
+
width: Math.round(rect.width),
|
|
258
|
+
height: Math.round(rect.height),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
node = walker.nextNode();
|
|
263
|
+
}
|
|
264
|
+
return results;
|
|
265
|
+
})()`));
|
|
266
|
+
// Match rects to our parsed elements by name+role
|
|
267
|
+
for (const [, el] of refMap) {
|
|
268
|
+
const key = `${el.role}:${el.name}`;
|
|
269
|
+
if (rects[key]) {
|
|
270
|
+
el.bounds = rects[key];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Second pass: match by name only for elements that didn't get bounds
|
|
274
|
+
for (const [, el] of refMap) {
|
|
275
|
+
if (el.bounds)
|
|
276
|
+
continue;
|
|
277
|
+
for (const [key, rect] of Object.entries(rects)) {
|
|
278
|
+
if (key.endsWith(`:${el.name}`) && el.name) {
|
|
279
|
+
el.bounds = rect;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// Fallback: skip bounding boxes if evaluate fails
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function flattenRefs(elements, map) {
|
|
290
|
+
for (const el of elements) {
|
|
291
|
+
if (el.ref)
|
|
292
|
+
map.set(el.ref, el);
|
|
293
|
+
if (el.children)
|
|
294
|
+
flattenRefs(el.children, map);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function clearFocusedFlags(elements) {
|
|
298
|
+
for (const el of elements) {
|
|
299
|
+
el.focused = false;
|
|
300
|
+
if (el.children)
|
|
301
|
+
clearFocusedFlags(el.children);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function stampRefFocused(elements, ref) {
|
|
305
|
+
for (const el of elements) {
|
|
306
|
+
if (el.ref === ref) {
|
|
307
|
+
el.focused = true;
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
if (el.children && stampRefFocused(el.children, ref))
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Apply `[active]` / `[focused]` from the same YAML string returned as `ariaSnapshot`, so focus state
|
|
317
|
+
* cannot diverge from the snapshot (e.g. stale daemon build or parser edge cases).
|
|
318
|
+
*/
|
|
319
|
+
function applyFocusFromAriaSnapshotYaml(yaml, elements) {
|
|
320
|
+
for (const raw of yaml.split('\n')) {
|
|
321
|
+
if (!raw.trim().startsWith('-'))
|
|
322
|
+
continue;
|
|
323
|
+
const content = raw.trim().replace(/^-\s*/, '');
|
|
324
|
+
const attrs = parseBracketAttrs(content);
|
|
325
|
+
if (!attrsIndicateFocus(attrs))
|
|
326
|
+
continue;
|
|
327
|
+
const ref = attrs['ref'];
|
|
328
|
+
if (!ref)
|
|
329
|
+
continue;
|
|
330
|
+
stampRefFocused(elements, ref);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function treeHasFocused(elements) {
|
|
334
|
+
for (const el of elements) {
|
|
335
|
+
if (el.focused)
|
|
336
|
+
return true;
|
|
337
|
+
if (el.children && treeHasFocused(el.children))
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* When the ARIA snapshot omits `[active]` (e.g. page/window not foreground, or focus not exposed in the
|
|
344
|
+
* a11y snapshot), align focus with `document.activeElement` by matching Playwright `aria-ref` nodes.
|
|
345
|
+
*/
|
|
346
|
+
async function stampFocusFromDocumentActiveElement(page, elements) {
|
|
347
|
+
if (treeHasFocused(elements))
|
|
348
|
+
return;
|
|
349
|
+
const refMap = new Map();
|
|
350
|
+
flattenRefs(elements, refMap);
|
|
351
|
+
if (refMap.size === 0)
|
|
352
|
+
return;
|
|
353
|
+
const activeHandle = await page.evaluateHandle(`(() => document.activeElement)()`);
|
|
354
|
+
try {
|
|
355
|
+
const activeEl = activeHandle.asElement();
|
|
356
|
+
if (!activeEl)
|
|
357
|
+
return;
|
|
358
|
+
for (const [ref, el] of refMap) {
|
|
359
|
+
try {
|
|
360
|
+
const hit = await page
|
|
361
|
+
.locator(`aria-ref=${ref}`)
|
|
362
|
+
.first()
|
|
363
|
+
.evaluate((node, active) => active !== null && node === active, activeEl);
|
|
364
|
+
if (hit) {
|
|
365
|
+
el.focused = true;
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
// Locator may not resolve for this ref
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
await activeHandle.dispose();
|
|
376
|
+
}
|
|
377
|
+
const activeRect = (await page.evaluate(`(() => {
|
|
378
|
+
const el = document.activeElement;
|
|
379
|
+
if (!el || el === document.body || el === document.documentElement) return null;
|
|
380
|
+
const r = el.getBoundingClientRect();
|
|
381
|
+
if (r.width <= 0 && r.height <= 0) return null;
|
|
382
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
383
|
+
})()`));
|
|
384
|
+
if (!activeRect)
|
|
385
|
+
return;
|
|
386
|
+
const acx = activeRect.x + activeRect.width / 2;
|
|
387
|
+
const acy = activeRect.y + activeRect.height / 2;
|
|
388
|
+
const rectPick = { best: null, bestScore: -1 };
|
|
389
|
+
const consider = (el) => {
|
|
390
|
+
if (!el.bounds)
|
|
391
|
+
return;
|
|
392
|
+
const b = el.bounds;
|
|
393
|
+
const x1 = Math.max(activeRect.x, b.x);
|
|
394
|
+
const y1 = Math.max(activeRect.y, b.y);
|
|
395
|
+
const x2 = Math.min(activeRect.x + activeRect.width, b.x + b.width);
|
|
396
|
+
const y2 = Math.min(activeRect.y + activeRect.height, b.y + b.height);
|
|
397
|
+
const iw = Math.max(0, x2 - x1);
|
|
398
|
+
const ih = Math.max(0, y2 - y1);
|
|
399
|
+
const inter = iw * ih;
|
|
400
|
+
const a1 = activeRect.width * activeRect.height;
|
|
401
|
+
const a2 = b.width * b.height;
|
|
402
|
+
const union = a1 + a2 - inter;
|
|
403
|
+
const iou = union > 0 ? inter / union : 0;
|
|
404
|
+
const centerInside = acx >= b.x && acx <= b.x + b.width && acy >= b.y && acy <= b.y + b.height;
|
|
405
|
+
const score = centerInside ? iou + 1 : iou;
|
|
406
|
+
if (score > rectPick.bestScore) {
|
|
407
|
+
rectPick.bestScore = score;
|
|
408
|
+
rectPick.best = el;
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const walk = (els) => {
|
|
412
|
+
for (const el of els) {
|
|
413
|
+
consider(el);
|
|
414
|
+
if (el.children)
|
|
415
|
+
walk(el.children);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
walk(elements);
|
|
419
|
+
if (rectPick.best !== null && rectPick.bestScore > 0.15) {
|
|
420
|
+
rectPick.best.focused = true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// ── Web server ───────────────────────────────────────────────────────────────
|
|
424
|
+
const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
|
|
425
|
+
let _browser = null;
|
|
426
|
+
let _context = null;
|
|
427
|
+
let _page = null;
|
|
428
|
+
let _server = null;
|
|
429
|
+
/**
|
|
430
|
+
* True when connected to an external browser via CDP (e.g. Stagehand's
|
|
431
|
+
* embedded webview). In this mode we must NOT close the browser on shutdown
|
|
432
|
+
* — we only disconnect.
|
|
433
|
+
*/
|
|
434
|
+
let _cdpMode = false;
|
|
435
|
+
async function startWebServer(port, browserName = 'chromium', dlog = () => { }, cdpUrl) {
|
|
436
|
+
if (cdpUrl) {
|
|
437
|
+
// ── CDP mode: attach to an existing browser (e.g. Electron webview) ───
|
|
438
|
+
dlog(`Connecting to existing browser via CDP: ${cdpUrl}`);
|
|
439
|
+
_browser = await playwright_core_1.chromium.connectOverCDP(cdpUrl);
|
|
440
|
+
_cdpMode = true;
|
|
441
|
+
// Use the first existing context and page. The host app (e.g. Stagehand)
|
|
442
|
+
// already created them — we just take a handle.
|
|
443
|
+
const contexts = _browser.contexts();
|
|
444
|
+
if (contexts.length === 0) {
|
|
445
|
+
throw new Error('No browser contexts found via CDP — is the webview loaded?');
|
|
446
|
+
}
|
|
447
|
+
// Find the context with a real page (not about:blank, not the host app).
|
|
448
|
+
for (const ctx of contexts) {
|
|
449
|
+
const pages = ctx.pages();
|
|
450
|
+
const candidate = pages.find((p) => p.url() !== 'about:blank' && !p.url().startsWith('file://'));
|
|
451
|
+
if (candidate) {
|
|
452
|
+
_context = ctx;
|
|
453
|
+
_page = candidate;
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
// Fallback: just use the first context's first page.
|
|
458
|
+
if (!_page) {
|
|
459
|
+
_context = contexts[0];
|
|
460
|
+
const pages = _context.pages();
|
|
461
|
+
_page = pages[0] ?? (await _context.newPage());
|
|
462
|
+
}
|
|
463
|
+
attachConsoleListeners(_page);
|
|
464
|
+
dlog(`CDP connected — page: ${_page.url()}`);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
// ── Standalone mode: launch a fresh browser ───────────────────────────
|
|
468
|
+
const browserType = browserName === 'firefox' ? playwright_core_1.firefox : browserName === 'webkit' ? playwright_core_1.webkit : playwright_core_1.chromium;
|
|
469
|
+
dlog(`Launching ${browserName} browser...`);
|
|
470
|
+
_browser = await browserType.launch({
|
|
471
|
+
headless: false,
|
|
472
|
+
args: browserName === 'chromium' ? ['--disable-search-engine-choice-screen'] : undefined,
|
|
473
|
+
});
|
|
474
|
+
_context = await _browser.newContext({
|
|
475
|
+
viewport: DEFAULT_VIEWPORT,
|
|
476
|
+
});
|
|
477
|
+
_page = await _context.newPage();
|
|
478
|
+
attachConsoleListeners(_page);
|
|
479
|
+
dlog(`Browser ready, page created`);
|
|
480
|
+
_cdpMode = false;
|
|
481
|
+
}
|
|
482
|
+
_server = http_1.default.createServer(async (req, res) => {
|
|
483
|
+
try {
|
|
484
|
+
await handleRequest(req, res, dlog);
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
488
|
+
dlog(`Request error: ${msg}`);
|
|
489
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
490
|
+
res.end(JSON.stringify({ error: msg }));
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
await new Promise((resolve) => {
|
|
494
|
+
_server.listen(port, '127.0.0.1', () => {
|
|
495
|
+
dlog(`Web server listening on port ${port}`);
|
|
496
|
+
resolve();
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
async function stopWebServer() {
|
|
501
|
+
if (_server) {
|
|
502
|
+
_server.close();
|
|
503
|
+
_server = null;
|
|
504
|
+
}
|
|
505
|
+
if (_cdpMode) {
|
|
506
|
+
// CDP mode: we don't own the browser — just release our handles.
|
|
507
|
+
// Do NOT close the page, context, or browser.
|
|
508
|
+
_page = null;
|
|
509
|
+
_context = null;
|
|
510
|
+
if (_browser) {
|
|
511
|
+
// Playwright's connectOverCDP browser supports disconnect() but not close().
|
|
512
|
+
try {
|
|
513
|
+
_browser.close().catch(() => { });
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
/* not all CDP browsers support close gracefully */
|
|
517
|
+
}
|
|
518
|
+
_browser = null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
// Standalone mode: we launched the browser, so tear it all down.
|
|
523
|
+
if (_page) {
|
|
524
|
+
await _page.close().catch(() => { });
|
|
525
|
+
_page = null;
|
|
526
|
+
}
|
|
527
|
+
if (_context) {
|
|
528
|
+
await _context.close().catch(() => { });
|
|
529
|
+
_context = null;
|
|
530
|
+
}
|
|
531
|
+
if (_browser) {
|
|
532
|
+
await _browser.close().catch(() => { });
|
|
533
|
+
_browser = null;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
_cdpMode = false;
|
|
537
|
+
}
|
|
538
|
+
/** Playwright / CDP errors when the tab, context, or session died but our JS refs still exist. */
|
|
539
|
+
function isClosedLikeError(err) {
|
|
540
|
+
const m = err instanceof Error ? err.message : String(err);
|
|
541
|
+
return /has been closed|Target page|Target closed|Browser has been closed|Context was closed/i.test(m);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Drop the context and open a fresh one. Used when newPage/goto fails after the user closed
|
|
545
|
+
* the last tab (Chromium can quit the window) or the CDP target is gone while isConnected stays true.
|
|
546
|
+
*
|
|
547
|
+
* In CDP mode, we can't create new contexts — the host app owns the browser.
|
|
548
|
+
* Instead we try to re-acquire an existing context/page.
|
|
549
|
+
*/
|
|
550
|
+
async function recreateBrowserContext(dlog) {
|
|
551
|
+
dlog?.('Web driver: recreating browser context');
|
|
552
|
+
if (!_browser) {
|
|
553
|
+
throw new Error('No page available');
|
|
554
|
+
}
|
|
555
|
+
if (!_browser.isConnected()) {
|
|
556
|
+
throw new Error('Browser has been closed. Restart the web driver (e.g. conductor daemon-start --device web).');
|
|
557
|
+
}
|
|
558
|
+
if (_cdpMode) {
|
|
559
|
+
// In CDP mode, try to re-acquire a page from existing contexts.
|
|
560
|
+
_page = null;
|
|
561
|
+
_context = null;
|
|
562
|
+
const contexts = _browser.contexts();
|
|
563
|
+
for (const ctx of contexts) {
|
|
564
|
+
const pages = ctx.pages();
|
|
565
|
+
const candidate = pages.find((p) => !p.isClosed());
|
|
566
|
+
if (candidate) {
|
|
567
|
+
_context = ctx;
|
|
568
|
+
_page = candidate;
|
|
569
|
+
attachConsoleListeners(_page);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
throw new Error('No live pages found via CDP — is the webview still open?');
|
|
574
|
+
}
|
|
575
|
+
if (_context) {
|
|
576
|
+
await _context.close().catch(() => { });
|
|
577
|
+
_context = null;
|
|
578
|
+
}
|
|
579
|
+
_page = null;
|
|
580
|
+
_context = await _browser.newContext({
|
|
581
|
+
viewport: DEFAULT_VIEWPORT,
|
|
582
|
+
});
|
|
583
|
+
_page = await _context.newPage();
|
|
584
|
+
attachConsoleListeners(_page);
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Return the active Page, recreating the tab or whole context if handles are stale.
|
|
588
|
+
*/
|
|
589
|
+
async function getPage(dlog) {
|
|
590
|
+
if (!_browser) {
|
|
591
|
+
throw new Error('No page available');
|
|
592
|
+
}
|
|
593
|
+
if (!_browser.isConnected()) {
|
|
594
|
+
throw new Error('Browser has been closed. Restart the web driver (e.g. conductor daemon-start --device web).');
|
|
595
|
+
}
|
|
596
|
+
if (_page && !_page.isClosed()) {
|
|
597
|
+
return _page;
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
if (!_context) {
|
|
601
|
+
await recreateBrowserContext(dlog);
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
_page = await _context.newPage();
|
|
605
|
+
attachConsoleListeners(_page);
|
|
606
|
+
}
|
|
607
|
+
return _page;
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
if (!isClosedLikeError(err))
|
|
611
|
+
throw err;
|
|
612
|
+
await recreateBrowserContext(dlog);
|
|
613
|
+
return _page;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async function gotoWithRecovery(targetUrl, dlog) {
|
|
617
|
+
const opts = { waitUntil: 'domcontentloaded', timeout: 30000 };
|
|
618
|
+
let p = await getPage(dlog);
|
|
619
|
+
try {
|
|
620
|
+
await p.goto(targetUrl, opts);
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
if (!isClosedLikeError(err))
|
|
624
|
+
throw err;
|
|
625
|
+
dlog?.(`Web driver: goto failed (${err instanceof Error ? err.message : String(err)}), recovering`);
|
|
626
|
+
await recreateBrowserContext(dlog);
|
|
627
|
+
p = await getPage(dlog);
|
|
628
|
+
await p.goto(targetUrl, opts);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
async function readBody(req) {
|
|
632
|
+
return new Promise((resolve, reject) => {
|
|
633
|
+
const chunks = [];
|
|
634
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
635
|
+
req.on('end', () => {
|
|
636
|
+
const raw = Buffer.concat(chunks).toString('utf-8');
|
|
637
|
+
if (!raw)
|
|
638
|
+
return resolve({});
|
|
639
|
+
try {
|
|
640
|
+
resolve(JSON.parse(raw));
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
reject(new Error('Invalid JSON body'));
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
req.on('error', reject);
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
function jsonResponse(res, data, status = 200) {
|
|
650
|
+
const body = JSON.stringify(data);
|
|
651
|
+
res.writeHead(status, {
|
|
652
|
+
'Content-Type': 'application/json',
|
|
653
|
+
'Content-Length': Buffer.byteLength(body),
|
|
654
|
+
});
|
|
655
|
+
res.end(body);
|
|
656
|
+
}
|
|
657
|
+
async function handleRequest(req, res, dlog) {
|
|
658
|
+
const rawUrl = req.url ?? '/';
|
|
659
|
+
const parsedUrl = url_1.default.parse(rawUrl, true);
|
|
660
|
+
const pathname = parsedUrl.pathname ?? '/';
|
|
661
|
+
const method = req.method ?? 'GET';
|
|
662
|
+
// ── GET endpoints ────────────────────────────────────────────────────────
|
|
663
|
+
if (method === 'GET') {
|
|
664
|
+
switch (pathname) {
|
|
665
|
+
case '/status': {
|
|
666
|
+
jsonResponse(res, { alive: true });
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
case '/deviceInfo': {
|
|
670
|
+
const p = await getPage(dlog);
|
|
671
|
+
const viewport = p.viewportSize() ?? DEFAULT_VIEWPORT;
|
|
672
|
+
jsonResponse(res, {
|
|
673
|
+
widthPixels: viewport.width,
|
|
674
|
+
heightPixels: viewport.height,
|
|
675
|
+
browserName: _browser?.browserType().name() ?? 'unknown',
|
|
676
|
+
url: p.url(),
|
|
677
|
+
});
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
case '/screenshot': {
|
|
681
|
+
const buf = await (await getPage(dlog)).screenshot({ type: 'png' });
|
|
682
|
+
res.writeHead(200, {
|
|
683
|
+
'Content-Type': 'image/png',
|
|
684
|
+
'Content-Length': buf.length,
|
|
685
|
+
});
|
|
686
|
+
res.end(buf);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
case '/viewHierarchy': {
|
|
690
|
+
const p = await getPage(dlog);
|
|
691
|
+
// `mode: 'ai'` includes `[ref=e…]` on nodes so we can resolve bounds via `aria-ref=`.
|
|
692
|
+
const ariaSnapshot = await p.locator('body').ariaSnapshot({ mode: 'ai' });
|
|
693
|
+
const elements = parseAriaSnapshot(ariaSnapshot);
|
|
694
|
+
await resolveBoundingBoxes(p, elements);
|
|
695
|
+
await resolveBoundingBoxesBatch(p, elements);
|
|
696
|
+
await resolveBoundingBoxesByRole(p, elements);
|
|
697
|
+
clearFocusedFlags(elements);
|
|
698
|
+
applyFocusFromAriaSnapshotYaml(ariaSnapshot, elements);
|
|
699
|
+
await stampFocusFromDocumentActiveElement(p, elements);
|
|
700
|
+
jsonResponse(res, {
|
|
701
|
+
url: p.url(),
|
|
702
|
+
title: await p.title(),
|
|
703
|
+
elements,
|
|
704
|
+
ariaSnapshot,
|
|
705
|
+
});
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
case '/currentUrl': {
|
|
709
|
+
jsonResponse(res, { url: (await getPage(dlog)).url() });
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
case '/title': {
|
|
713
|
+
jsonResponse(res, { title: await (await getPage(dlog)).title() });
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
case '/isScreenStatic': {
|
|
717
|
+
// Compare two consecutive ARIA snapshots to detect page changes
|
|
718
|
+
const p = await getPage(dlog);
|
|
719
|
+
const snap1 = await p.locator('body').ariaSnapshot();
|
|
720
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
721
|
+
const snap2 = await p.locator('body').ariaSnapshot();
|
|
722
|
+
jsonResponse(res, { isScreenStatic: snap1 === snap2 });
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
case '/consoleLogs': {
|
|
726
|
+
const since = parsedUrl.query['since'] ?? '';
|
|
727
|
+
const entries = since
|
|
728
|
+
? _consoleBuffer.filter((e) => e.timestamp > since)
|
|
729
|
+
: _consoleBuffer.slice();
|
|
730
|
+
jsonResponse(res, { entries });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
default: {
|
|
734
|
+
res.writeHead(404);
|
|
735
|
+
res.end('Not found');
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// ── POST endpoints ───────────────────────────────────────────────────────
|
|
741
|
+
if (method === 'POST') {
|
|
742
|
+
const body = await readBody(req);
|
|
743
|
+
switch (pathname) {
|
|
744
|
+
case '/tap': {
|
|
745
|
+
const x = body['x'];
|
|
746
|
+
const y = body['y'];
|
|
747
|
+
const duration = body['duration'];
|
|
748
|
+
const p = await getPage(dlog);
|
|
749
|
+
if (duration && duration > 0.5) {
|
|
750
|
+
// Long press: mouse down, wait, mouse up
|
|
751
|
+
await p.mouse.move(x, y);
|
|
752
|
+
await p.mouse.down();
|
|
753
|
+
await new Promise((r) => setTimeout(r, duration * 1000));
|
|
754
|
+
await p.mouse.up();
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
await p.mouse.click(x, y);
|
|
758
|
+
}
|
|
759
|
+
jsonResponse(res, { ok: true });
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
case '/swipe': {
|
|
763
|
+
const { startX, startY, endX, endY, duration = 500 } = body;
|
|
764
|
+
const p = await getPage(dlog);
|
|
765
|
+
const dx = endX - startX;
|
|
766
|
+
const dy = endY - startY;
|
|
767
|
+
// Mouse drag selects text on web; use wheel events for scroll-like moves.
|
|
768
|
+
// Same-point drag is kept for long-press (flow-runner uses swipe at one point + duration).
|
|
769
|
+
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) {
|
|
770
|
+
const steps = Math.max(Math.round(duration / 16), 5); // ~60fps
|
|
771
|
+
await p.mouse.move(startX, startY);
|
|
772
|
+
await p.mouse.down();
|
|
773
|
+
for (let i = 1; i <= steps; i++) {
|
|
774
|
+
const t = i / steps;
|
|
775
|
+
await p.mouse.move(startX + (endX - startX) * t, startY + (endY - startY) * t);
|
|
776
|
+
}
|
|
777
|
+
await p.mouse.up();
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
const midX = (startX + endX) / 2;
|
|
781
|
+
const midY = (startY + endY) / 2;
|
|
782
|
+
await p.mouse.move(midX, midY);
|
|
783
|
+
// Vector (dx,dy) is finger path; wheel deltas negate that so drag-up → scroll down.
|
|
784
|
+
await p.mouse.wheel(-dx, -dy);
|
|
785
|
+
}
|
|
786
|
+
jsonResponse(res, { ok: true });
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
case '/inputText': {
|
|
790
|
+
const text = body['text'];
|
|
791
|
+
await (await getPage(dlog)).keyboard.type(text);
|
|
792
|
+
jsonResponse(res, { ok: true });
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
case '/pressKey': {
|
|
796
|
+
const key = body['key'];
|
|
797
|
+
await (await getPage(dlog)).keyboard.press(key);
|
|
798
|
+
jsonResponse(res, { ok: true });
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
case '/navigate':
|
|
802
|
+
case '/launchApp': {
|
|
803
|
+
const targetUrl = (body['url'] ?? body['bundleId'] ?? body['appId']);
|
|
804
|
+
if (!targetUrl) {
|
|
805
|
+
jsonResponse(res, { error: 'url is required' }, 400);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
await gotoWithRecovery(targetUrl, dlog);
|
|
809
|
+
jsonResponse(res, { ok: true });
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
case '/goBack': {
|
|
813
|
+
await (await getPage(dlog))
|
|
814
|
+
.goBack({ waitUntil: 'domcontentloaded', timeout: 10000 })
|
|
815
|
+
.catch(() => { });
|
|
816
|
+
jsonResponse(res, { ok: true });
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
case '/goForward': {
|
|
820
|
+
await (await getPage(dlog))
|
|
821
|
+
.goForward({ waitUntil: 'domcontentloaded', timeout: 10000 })
|
|
822
|
+
.catch(() => { });
|
|
823
|
+
jsonResponse(res, { ok: true });
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
case '/reload': {
|
|
827
|
+
await (await getPage(dlog)).reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
828
|
+
jsonResponse(res, { ok: true });
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
case '/clearCookies': {
|
|
832
|
+
if (_context)
|
|
833
|
+
await _context.clearCookies();
|
|
834
|
+
jsonResponse(res, { ok: true });
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
case '/clearStorage': {
|
|
838
|
+
await (await getPage(dlog)).evaluate(`(() => {
|
|
839
|
+
try { localStorage.clear(); } catch {}
|
|
840
|
+
try { sessionStorage.clear(); } catch {}
|
|
841
|
+
})()`);
|
|
842
|
+
jsonResponse(res, { ok: true });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
case '/terminateApp': {
|
|
846
|
+
// Do not `page.close()` here: closing the only Chromium tab often tears down the whole
|
|
847
|
+
// window/CDP target; `goto('about:blank')` resets state without killing the session.
|
|
848
|
+
await gotoWithRecovery('about:blank', dlog);
|
|
849
|
+
jsonResponse(res, { ok: true });
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
case '/clearAppState': {
|
|
853
|
+
if (_context)
|
|
854
|
+
await _context.clearCookies();
|
|
855
|
+
await (await getPage(dlog)).evaluate(`(() => {
|
|
856
|
+
try { localStorage.clear(); } catch {}
|
|
857
|
+
try { sessionStorage.clear(); } catch {}
|
|
858
|
+
})()`);
|
|
859
|
+
jsonResponse(res, { ok: true });
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
case '/runningApp': {
|
|
863
|
+
jsonResponse(res, { runningAppBundleId: (await getPage(dlog)).url() });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
case '/eraseText': {
|
|
867
|
+
const count = body['count'] ?? 50;
|
|
868
|
+
const p = await getPage(dlog);
|
|
869
|
+
for (let i = 0; i < count; i++) {
|
|
870
|
+
await p.keyboard.press('Backspace');
|
|
871
|
+
}
|
|
872
|
+
jsonResponse(res, { ok: true });
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
case '/shutdown': {
|
|
876
|
+
jsonResponse(res, { ok: true });
|
|
877
|
+
// Graceful shutdown after response is sent
|
|
878
|
+
setTimeout(() => {
|
|
879
|
+
stopWebServer().then(() => process.exit(0));
|
|
880
|
+
}, 100);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
default: {
|
|
884
|
+
res.writeHead(404);
|
|
885
|
+
res.end('Not found');
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
res.writeHead(405);
|
|
891
|
+
res.end('Method not allowed');
|
|
892
|
+
}
|