@houwert/conductor 0.19.1 → 0.21.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 +13 -1
- package/dist/commands/capture-ui.js +14 -1
- package/dist/commands/debug.js +33 -1
- package/dist/commands/init.js +240 -0
- package/dist/commands/logs.js +17 -2
- package/dist/commands/network.js +67 -2
- package/dist/commands/options.js +44 -0
- package/dist/commands/press-key.js +12 -5
- package/dist/commands/set-viewport.js +4 -4
- package/dist/daemon/web-server.js +219 -0
- package/dist/drivers/element-resolver.js +16 -7
- package/dist/drivers/web.js +26 -0
- package/dist/enum-options.js +130 -0
- package/dist/index.js +29 -1
- package/dist/utils.js +2 -0
- package/package.json +3 -2
- package/skills/conductor-create-flow/SKILL.md +56 -0
- package/skills/conductor-device-interact/SKILL.md +96 -0
- package/skills/conductor-device-setup/SKILL.md +73 -0
- package/skills/conductor-inspect/SKILL.md +48 -0
- package/skills/conductor-metro-debugger/SKILL.md +51 -0
- package/skills/conductor-profiler/SKILL.md +42 -0
|
@@ -71,6 +71,52 @@ function attachConsoleListeners(page) {
|
|
|
71
71
|
source: 'console',
|
|
72
72
|
});
|
|
73
73
|
});
|
|
74
|
+
attachNetworkListeners(page);
|
|
75
|
+
}
|
|
76
|
+
const MAX_NETWORK_BUFFER = 500;
|
|
77
|
+
const _networkBuffer = [];
|
|
78
|
+
let _netSeq = 0;
|
|
79
|
+
function pushNetworkEntry(entry) {
|
|
80
|
+
_networkBuffer.push(entry);
|
|
81
|
+
if (_networkBuffer.length > MAX_NETWORK_BUFFER) {
|
|
82
|
+
_networkBuffer.splice(0, _networkBuffer.length - MAX_NETWORK_BUFFER);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Capture all page network traffic via Playwright events — covers fetch/XHR plus
|
|
87
|
+
* document/script/image/media loads (a canvas webtv app's API calls included), without
|
|
88
|
+
* injecting a shim into the page (unlike the React Native Metro path).
|
|
89
|
+
*/
|
|
90
|
+
function attachNetworkListeners(page) {
|
|
91
|
+
const inflight = new WeakMap();
|
|
92
|
+
page.on('request', (req) => {
|
|
93
|
+
const entry = {
|
|
94
|
+
id: _netSeq++,
|
|
95
|
+
timestamp: new Date().toISOString(),
|
|
96
|
+
method: req.method(),
|
|
97
|
+
url: req.url(),
|
|
98
|
+
resourceType: req.resourceType(),
|
|
99
|
+
status: null,
|
|
100
|
+
durationMs: null,
|
|
101
|
+
error: null,
|
|
102
|
+
};
|
|
103
|
+
inflight.set(req, { entry, start: Date.now() });
|
|
104
|
+
pushNetworkEntry(entry);
|
|
105
|
+
});
|
|
106
|
+
page.on('response', (res) => {
|
|
107
|
+
const rec = inflight.get(res.request());
|
|
108
|
+
if (rec) {
|
|
109
|
+
rec.entry.status = res.status();
|
|
110
|
+
rec.entry.durationMs = Date.now() - rec.start;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
page.on('requestfailed', (req) => {
|
|
114
|
+
const rec = inflight.get(req);
|
|
115
|
+
if (rec) {
|
|
116
|
+
rec.entry.error = req.failure()?.errorText ?? 'request failed';
|
|
117
|
+
rec.entry.durationMs = Date.now() - rec.start;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
74
120
|
}
|
|
75
121
|
// ── ARIA snapshot parser ─────────────────────────────────────────────────────
|
|
76
122
|
/**
|
|
@@ -423,6 +469,113 @@ async function stampFocusFromDocumentActiveElement(page, elements) {
|
|
|
423
469
|
rectPick.best.focused = true;
|
|
424
470
|
}
|
|
425
471
|
}
|
|
472
|
+
/** IoU + center-inside score of a candidate node's bounds against a mirror rect. */
|
|
473
|
+
function overlapScore(bounds, m) {
|
|
474
|
+
const x1 = Math.max(m.x, bounds.x);
|
|
475
|
+
const y1 = Math.max(m.y, bounds.y);
|
|
476
|
+
const x2 = Math.min(m.x + m.width, bounds.x + bounds.width);
|
|
477
|
+
const y2 = Math.min(m.y + m.height, bounds.y + bounds.height);
|
|
478
|
+
const inter = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
|
|
479
|
+
if (inter <= 0)
|
|
480
|
+
return 0;
|
|
481
|
+
const union = m.width * m.height + bounds.width * bounds.height - inter;
|
|
482
|
+
const iou = union > 0 ? inter / union : 0;
|
|
483
|
+
const mcx = m.x + m.width / 2;
|
|
484
|
+
const mcy = m.y + m.height / 2;
|
|
485
|
+
const centerInside = mcx >= bounds.x &&
|
|
486
|
+
mcx <= bounds.x + bounds.width &&
|
|
487
|
+
mcy >= bounds.y &&
|
|
488
|
+
mcy <= bounds.y + bounds.height;
|
|
489
|
+
return centerInside ? iou + 1 : iou;
|
|
490
|
+
}
|
|
491
|
+
/** Find the existing tree node whose bounds best overlap a mirror rect (>0.5 score). */
|
|
492
|
+
function bestOverlappingNode(elements, m) {
|
|
493
|
+
let best = null;
|
|
494
|
+
let bestScore = 0.5;
|
|
495
|
+
const walk = (els) => {
|
|
496
|
+
for (const el of els) {
|
|
497
|
+
if (el.bounds) {
|
|
498
|
+
const s = overlapScore(el.bounds, m);
|
|
499
|
+
if (s > bestScore) {
|
|
500
|
+
bestScore = s;
|
|
501
|
+
best = el;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
if (el.children)
|
|
505
|
+
walk(el.children);
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
walk(elements);
|
|
509
|
+
return best;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Harvest a canvas DOM-inspector mirror into the hierarchy. Canvas webtv frameworks
|
|
513
|
+
* (Lightning/WPE/RDK) render the whole UI into one `<canvas>` and expose the scene graph as
|
|
514
|
+
* off-screen `<div>`s carrying `data-testid` (real identity) and `data-focused="true"` (focus
|
|
515
|
+
* — the canvas owns `document.activeElement`, so the normal focus path can't see it).
|
|
516
|
+
*
|
|
517
|
+
* Each mirror node is matched to an existing ARIA node by bounds overlap and used to enrich it
|
|
518
|
+
* (testId + focus); unmatched mirror nodes are appended as new nodes. Mirror rects come from
|
|
519
|
+
* `getBoundingClientRect`, i.e. the same viewport-CSS-pixel space taps use — drive TV apps at
|
|
520
|
+
* the app's native resolution (e.g. `set-viewport 1920 1080`) so lower nodes aren't off-screen.
|
|
521
|
+
*/
|
|
522
|
+
async function harvestDomMirror(page, elements) {
|
|
523
|
+
let mirror = null;
|
|
524
|
+
try {
|
|
525
|
+
mirror = (await page.evaluate(`(() => {
|
|
526
|
+
const nodes = document.querySelectorAll('[data-testid],[data-focused]');
|
|
527
|
+
if (!nodes.length) return null;
|
|
528
|
+
const out = [];
|
|
529
|
+
nodes.forEach((el) => {
|
|
530
|
+
const r = el.getBoundingClientRect();
|
|
531
|
+
out.push({
|
|
532
|
+
testId: el.getAttribute('data-testid') || '',
|
|
533
|
+
focused: el.getAttribute('data-focused') === 'true',
|
|
534
|
+
role: el.getAttribute('role') || 'generic',
|
|
535
|
+
name: (el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 200),
|
|
536
|
+
disabled: el.getAttribute('aria-disabled') === 'true',
|
|
537
|
+
x: r.x, y: r.y, width: r.width, height: r.height,
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
return out;
|
|
541
|
+
})()`));
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (!mirror || mirror.length === 0)
|
|
547
|
+
return;
|
|
548
|
+
// data-focused is authoritative for canvas apps (activeElement is the <canvas>, never the
|
|
549
|
+
// focused scene node), so clear any focus already inferred from the ARIA snapshot.
|
|
550
|
+
if (mirror.some((m) => m.focused))
|
|
551
|
+
clearFocusedFlags(elements);
|
|
552
|
+
for (const m of mirror) {
|
|
553
|
+
const bounds = m.width > 0 && m.height > 0
|
|
554
|
+
? { x: m.x, y: m.y, width: m.width, height: m.height }
|
|
555
|
+
: undefined;
|
|
556
|
+
const target = bounds ? bestOverlappingNode(elements, m) : null;
|
|
557
|
+
if (target) {
|
|
558
|
+
if (m.testId)
|
|
559
|
+
target.testId = m.testId;
|
|
560
|
+
if (m.focused)
|
|
561
|
+
target.focused = true;
|
|
562
|
+
if (!target.name && m.name)
|
|
563
|
+
target.name = m.name;
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
elements.push({
|
|
567
|
+
role: m.role,
|
|
568
|
+
name: m.name,
|
|
569
|
+
ref: '',
|
|
570
|
+
testId: m.testId || undefined,
|
|
571
|
+
bounds,
|
|
572
|
+
enabled: !m.disabled,
|
|
573
|
+
focused: m.focused,
|
|
574
|
+
children: [],
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
426
579
|
// ── Web server ───────────────────────────────────────────────────────────────
|
|
427
580
|
const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
|
|
428
581
|
let _browser = null;
|
|
@@ -832,6 +985,9 @@ async function handleRequest(req, res, dlog) {
|
|
|
832
985
|
await resolveBoundingBoxesByRole(p, elements);
|
|
833
986
|
clearFocusedFlags(elements);
|
|
834
987
|
applyFocusFromAriaSnapshotYaml(ariaSnapshot, elements);
|
|
988
|
+
// Canvas webtv: merge the data-testid/data-focused mirror before falling back to
|
|
989
|
+
// document.activeElement (which is the <canvas>, not the focused scene node).
|
|
990
|
+
await harvestDomMirror(p, elements);
|
|
835
991
|
await stampFocusFromDocumentActiveElement(p, elements);
|
|
836
992
|
jsonResponse(res, {
|
|
837
993
|
url: p.url(),
|
|
@@ -937,6 +1093,17 @@ async function handleRequest(req, res, dlog) {
|
|
|
937
1093
|
jsonResponse(res, { entries });
|
|
938
1094
|
return;
|
|
939
1095
|
}
|
|
1096
|
+
case '/networkLogs': {
|
|
1097
|
+
const since = parsedUrl.query['since'] ?? '';
|
|
1098
|
+
const limit = Number(parsedUrl.query['limit'] ?? 0);
|
|
1099
|
+
let entries = since
|
|
1100
|
+
? _networkBuffer.filter((e) => e.timestamp > since)
|
|
1101
|
+
: _networkBuffer.slice();
|
|
1102
|
+
if (limit > 0)
|
|
1103
|
+
entries = entries.slice(-limit);
|
|
1104
|
+
jsonResponse(res, { entries });
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
940
1107
|
default: {
|
|
941
1108
|
res.writeHead(404);
|
|
942
1109
|
res.end('Not found');
|
|
@@ -1005,6 +1172,58 @@ async function handleRequest(req, res, dlog) {
|
|
|
1005
1172
|
jsonResponse(res, { ok: true });
|
|
1006
1173
|
return;
|
|
1007
1174
|
}
|
|
1175
|
+
case '/evaluate': {
|
|
1176
|
+
const expr = body['expr'];
|
|
1177
|
+
if (!expr) {
|
|
1178
|
+
jsonResponse(res, { error: 'expr is required' }, 400);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
const p = await getPage(dlog);
|
|
1182
|
+
try {
|
|
1183
|
+
// Wrap so a bare expression (e.g. `1+1`) and a statement block both work.
|
|
1184
|
+
const result = await p.evaluate(`(() => { return (${expr}); })()`).catch(
|
|
1185
|
+
// Fall back to evaluating as-is for IIFEs / `async () => …` expressions.
|
|
1186
|
+
() => p.evaluate(expr));
|
|
1187
|
+
jsonResponse(res, { result });
|
|
1188
|
+
}
|
|
1189
|
+
catch (e) {
|
|
1190
|
+
jsonResponse(res, { error: e instanceof Error ? e.message : String(e) });
|
|
1191
|
+
}
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
case '/networkRequest': {
|
|
1195
|
+
const url = body['url'];
|
|
1196
|
+
if (!url) {
|
|
1197
|
+
jsonResponse(res, { error: 'url is required' }, 400);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const reqMethod = (body['method'] ?? 'GET').toUpperCase();
|
|
1201
|
+
const headers = body['headers'] ?? {};
|
|
1202
|
+
const data = body['body'];
|
|
1203
|
+
if (!_context) {
|
|
1204
|
+
jsonResponse(res, { error: 'no browser context' }, 500);
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
try {
|
|
1208
|
+
const apiRes = await _context.request.fetch(url, {
|
|
1209
|
+
method: reqMethod,
|
|
1210
|
+
headers,
|
|
1211
|
+
...(data !== undefined ? { data } : {}),
|
|
1212
|
+
});
|
|
1213
|
+
const hdrs = apiRes.headers();
|
|
1214
|
+
const text = await apiRes.text();
|
|
1215
|
+
jsonResponse(res, {
|
|
1216
|
+
ok: apiRes.ok(),
|
|
1217
|
+
status: apiRes.status(),
|
|
1218
|
+
headers: hdrs,
|
|
1219
|
+
body: text,
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
catch (e) {
|
|
1223
|
+
jsonResponse(res, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
1224
|
+
}
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1008
1227
|
case '/navigate':
|
|
1009
1228
|
case '/launchApp': {
|
|
1010
1229
|
const targetUrl = (body['url'] ?? body['bundleId'] ?? body['appId']);
|
|
@@ -439,7 +439,7 @@ function collectWebElements(nodes, results) {
|
|
|
439
439
|
for (const node of nodes) {
|
|
440
440
|
const visible = node.bounds && node.bounds.width > 0 && node.bounds.height > 0;
|
|
441
441
|
if (visible) {
|
|
442
|
-
const hasContent = !!(node.name || node.ref);
|
|
442
|
+
const hasContent = !!(node.name || node.ref || node.testId);
|
|
443
443
|
const isLeaf = !node.children || node.children.length === 0;
|
|
444
444
|
if (hasContent || isLeaf) {
|
|
445
445
|
results.push(node);
|
|
@@ -450,10 +450,14 @@ function collectWebElements(nodes, results) {
|
|
|
450
450
|
}
|
|
451
451
|
}
|
|
452
452
|
}
|
|
453
|
+
/** Web id field: prefer the canvas mirror's `data-testid`, else the ARIA `ref`. */
|
|
454
|
+
function webElementId(node) {
|
|
455
|
+
return node.testId || node.ref;
|
|
456
|
+
}
|
|
453
457
|
function matchesWebElement(node, sel) {
|
|
454
458
|
if (sel.query) {
|
|
455
459
|
const textOk = matchPatternAgainstAnyTextField(sel.query, [node.name]);
|
|
456
|
-
const idOk = matchPatternAgainstElementId(sel.query, node
|
|
460
|
+
const idOk = matchPatternAgainstElementId(sel.query, webElementId(node));
|
|
457
461
|
if (!textOk && !idOk)
|
|
458
462
|
return false;
|
|
459
463
|
}
|
|
@@ -462,7 +466,7 @@ function matchesWebElement(node, sel) {
|
|
|
462
466
|
return false;
|
|
463
467
|
}
|
|
464
468
|
if (sel.id) {
|
|
465
|
-
if (!matchPatternAgainstElementId(sel.id, node
|
|
469
|
+
if (!matchPatternAgainstElementId(sel.id, webElementId(node)))
|
|
466
470
|
return false;
|
|
467
471
|
}
|
|
468
472
|
if (sel.enabled !== undefined && node.enabled !== sel.enabled)
|
|
@@ -532,7 +536,7 @@ function findWebElement(hierarchy, sel) {
|
|
|
532
536
|
matches.forEach((n, i) => {
|
|
533
537
|
const b = n.bounds;
|
|
534
538
|
const interactive = WEB_INTERACTIVE_ROLES.has(n.role);
|
|
535
|
-
(0, verbose_js_1.log)(` [${i}] text="${n.name}"
|
|
539
|
+
(0, verbose_js_1.log)(` [${i}] text="${n.name}" id="${webElementId(n)}" role="${n.role}" ` +
|
|
536
540
|
`bounds=[${Math.round(b.x)},${Math.round(b.y)}][${Math.round(b.x + b.width)},${Math.round(b.y + b.height)}]` +
|
|
537
541
|
`${interactive ? ' (interactive)' : ''}`);
|
|
538
542
|
});
|
|
@@ -554,7 +558,7 @@ function findWebElement(hierarchy, sel) {
|
|
|
554
558
|
return null;
|
|
555
559
|
}
|
|
556
560
|
const b = node.bounds;
|
|
557
|
-
(0, verbose_js_1.log)(`[Web] chose [${idx}] text="${node.name}"
|
|
561
|
+
(0, verbose_js_1.log)(`[Web] chose [${idx}] text="${node.name}" id="${webElementId(node)}" role="${node.role}" ` +
|
|
558
562
|
`bounds=[${Math.round(b.x)},${Math.round(b.y)}][${Math.round(b.x + b.width)},${Math.round(b.y + b.height)}] ` +
|
|
559
563
|
`→ tap (${Math.round(b.x + b.width / 2)}, ${Math.round(b.y + b.height / 2)})`);
|
|
560
564
|
return {
|
|
@@ -562,7 +566,7 @@ function findWebElement(hierarchy, sel) {
|
|
|
562
566
|
centerY: b.y + b.height / 2,
|
|
563
567
|
bounds: { x: b.x, y: b.y, width: b.width, height: b.height },
|
|
564
568
|
text: node.name || undefined,
|
|
565
|
-
id: node
|
|
569
|
+
id: webElementId(node) || undefined,
|
|
566
570
|
};
|
|
567
571
|
}
|
|
568
572
|
/**
|
|
@@ -579,6 +583,8 @@ function visitWeb(nodes, lines, depth) {
|
|
|
579
583
|
parts.push(node.role);
|
|
580
584
|
if (node.name)
|
|
581
585
|
parts.push(`"${node.name}"`);
|
|
586
|
+
if (node.testId)
|
|
587
|
+
parts.push(`id=${node.testId}`);
|
|
582
588
|
if (node.ref)
|
|
583
589
|
parts.push(`ref=${node.ref}`);
|
|
584
590
|
if (node.bounds) {
|
|
@@ -588,7 +594,10 @@ function visitWeb(nodes, lines, depth) {
|
|
|
588
594
|
if (!node.enabled)
|
|
589
595
|
parts.push('disabled');
|
|
590
596
|
// Only output nodes that have content
|
|
591
|
-
if (node.name ||
|
|
597
|
+
if (node.name ||
|
|
598
|
+
node.ref ||
|
|
599
|
+
node.testId ||
|
|
600
|
+
(node.bounds && (!node.children || node.children.length === 0))) {
|
|
592
601
|
lines.push(`${' '.repeat(depth)}${parts.join(' ')}`);
|
|
593
602
|
}
|
|
594
603
|
if (node.children) {
|
package/dist/drivers/web.js
CHANGED
|
@@ -60,6 +60,14 @@ class WebDriver {
|
|
|
60
60
|
}
|
|
61
61
|
return JSON.parse(data.toString('utf-8'));
|
|
62
62
|
}
|
|
63
|
+
/** POST that returns the parsed JSON response body (unlike `post`, which discards it). */
|
|
64
|
+
async postJson(path, body) {
|
|
65
|
+
const { status, data } = await this.request('POST', `/${path}`, body);
|
|
66
|
+
if (status < 200 || status >= 300) {
|
|
67
|
+
throw new Error(`Web driver ${path} failed (HTTP ${status}): ${data.toString('utf-8').slice(0, 200)}`);
|
|
68
|
+
}
|
|
69
|
+
return JSON.parse(data.toString('utf-8'));
|
|
70
|
+
}
|
|
63
71
|
async isAlive() {
|
|
64
72
|
try {
|
|
65
73
|
const { status } = await this.request('GET', '/status');
|
|
@@ -154,6 +162,24 @@ class WebDriver {
|
|
|
154
162
|
}
|
|
155
163
|
return data.toString('utf-8');
|
|
156
164
|
}
|
|
165
|
+
/** Recent page network traffic captured via Playwright request/response events. */
|
|
166
|
+
async networkLogs(opts = {}) {
|
|
167
|
+
const qs = new URLSearchParams();
|
|
168
|
+
if (opts.limit)
|
|
169
|
+
qs.set('limit', String(opts.limit));
|
|
170
|
+
if (opts.since)
|
|
171
|
+
qs.set('since', opts.since);
|
|
172
|
+
const s = qs.toString();
|
|
173
|
+
return this.get(`networkLogs${s ? `?${s}` : ''}`);
|
|
174
|
+
}
|
|
175
|
+
/** Issue an HTTP request from the browser context (shares cookies/session with the page). */
|
|
176
|
+
async networkRequest(url, opts = {}) {
|
|
177
|
+
return this.postJson('networkRequest', { url, ...opts });
|
|
178
|
+
}
|
|
179
|
+
/** Evaluate a JS expression in the page runtime and return its (JSON-serializable) value. */
|
|
180
|
+
async evaluate(expr) {
|
|
181
|
+
return this.postJson('evaluate', { expr });
|
|
182
|
+
}
|
|
157
183
|
async eraseAllText(count = 50) {
|
|
158
184
|
await this.post('eraseText', { count });
|
|
159
185
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Central registry of commands/parameters that only accept a fixed set of
|
|
3
|
+
// enumerated values. The `list-options` command and the global `--options`
|
|
4
|
+
// flag read from here so agents (and humans) can discover valid values without
|
|
5
|
+
// trial-and-error.
|
|
6
|
+
//
|
|
7
|
+
// Where a value list already exists as the canonical source elsewhere, we
|
|
8
|
+
// import it so this registry can never drift from what the command validates.
|
|
9
|
+
// Small, stable 2–4 value lists are inlined with a pointer to their source.
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.ENUM_PARAMS = void 0;
|
|
12
|
+
exports.commandsWithEnums = commandsWithEnums;
|
|
13
|
+
exports.findEnumParams = findEnumParams;
|
|
14
|
+
const press_key_js_1 = require("./commands/press-key.js");
|
|
15
|
+
const set_viewport_js_1 = require("./commands/set-viewport.js");
|
|
16
|
+
const utils_js_1 = require("./utils.js");
|
|
17
|
+
const types_js_1 = require("./drivers/log-sources/types.js");
|
|
18
|
+
const DIRECTION_VALUES = utils_js_1.DIRECTIONS.map((d) => ({ value: d }));
|
|
19
|
+
// `--level` accepts every key of LEVEL_SEVERITY (includes the `warn` alias).
|
|
20
|
+
const LOG_LEVELS = Object.keys(types_js_1.LEVEL_SEVERITY).map((value) => ({ value }));
|
|
21
|
+
exports.ENUM_PARAMS = [
|
|
22
|
+
{
|
|
23
|
+
command: 'press-key',
|
|
24
|
+
param: '<key>',
|
|
25
|
+
description: 'Key, hardware button, or remote button to press',
|
|
26
|
+
values: press_key_js_1.VALID_KEYS.map((value) => ({ value })),
|
|
27
|
+
note: 'Matched case-insensitively. Availability varies by platform: "Remote …" / "TV …" keys target tvOS and Android TV; hardware buttons (Home, Lock, Power, Volume…) target iOS/Android.',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
command: 'scroll',
|
|
31
|
+
param: '--direction',
|
|
32
|
+
description: 'Scroll direction (default: down)',
|
|
33
|
+
values: DIRECTION_VALUES,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
command: 'swipe',
|
|
37
|
+
param: '--direction',
|
|
38
|
+
description: 'Swipe direction (required unless --start/--end are given)',
|
|
39
|
+
values: DIRECTION_VALUES,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
command: 'scroll-until-visible',
|
|
43
|
+
param: '--direction',
|
|
44
|
+
description: 'Scroll direction while searching (default: down)',
|
|
45
|
+
values: DIRECTION_VALUES,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
command: 'set-orientation',
|
|
49
|
+
param: '<orientation>',
|
|
50
|
+
description: 'Device orientation',
|
|
51
|
+
// Source: VALID in commands/set-orientation.ts
|
|
52
|
+
values: [{ value: 'portrait' }, { value: 'landscape' }],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
command: 'start-device',
|
|
56
|
+
param: '--platform',
|
|
57
|
+
description: 'Platform of the device to start',
|
|
58
|
+
// Source: switch in commands/start-device.ts
|
|
59
|
+
values: [{ value: 'ios' }, { value: 'android' }, { value: 'tvos' }, { value: 'web' }],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
command: 'install-web',
|
|
63
|
+
param: '[browser]',
|
|
64
|
+
description: 'Playwright browser to install (default: chromium)',
|
|
65
|
+
// Source: validBrowsers in commands/install.ts
|
|
66
|
+
values: [{ value: 'chromium' }, { value: 'firefox' }, { value: 'webkit' }],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
command: 'set-viewport',
|
|
70
|
+
param: '--preset',
|
|
71
|
+
description: 'Device size preset instead of explicit width/height (web only)',
|
|
72
|
+
values: Object.entries(set_viewport_js_1.PRESETS).map(([value, p]) => ({
|
|
73
|
+
value,
|
|
74
|
+
description: `${p.width}x${p.height} @${p.deviceScaleFactor}x${p.isMobile ? ', mobile' : ''}`,
|
|
75
|
+
})),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
command: 'set-viewport',
|
|
79
|
+
param: '--color-scheme',
|
|
80
|
+
description: 'Emulate prefers-color-scheme (web only)',
|
|
81
|
+
values: [{ value: 'dark' }, { value: 'light' }],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
command: 'logs',
|
|
85
|
+
param: '--source',
|
|
86
|
+
description: 'Filter logs by source (default: both)',
|
|
87
|
+
// Source: sourceFilter in commands/logs.ts
|
|
88
|
+
values: [{ value: 'metro' }, { value: 'device' }],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
command: 'logs',
|
|
92
|
+
param: '--level',
|
|
93
|
+
description: 'Minimum log level to show',
|
|
94
|
+
values: LOG_LEVELS,
|
|
95
|
+
note: '"warn" is an alias for "warning".',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
command: 'list-devices',
|
|
99
|
+
param: '--platform',
|
|
100
|
+
description: 'Filter listed devices by platform (also a global filter on most commands)',
|
|
101
|
+
values: [{ value: 'ios' }, { value: 'android' }, { value: 'tvos' }, { value: 'web' }],
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
/** All distinct command names that have at least one enumerated parameter. */
|
|
105
|
+
function commandsWithEnums() {
|
|
106
|
+
return [...new Set(exports.ENUM_PARAMS.map((p) => p.command))];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Find enumerated parameters matching a query. Matches a command name (e.g.
|
|
110
|
+
* "press-key"), a bare parameter name (e.g. "direction", "--level"), or a
|
|
111
|
+
* value (e.g. "tvos"). Returns all params when query is empty.
|
|
112
|
+
*/
|
|
113
|
+
function findEnumParams(query) {
|
|
114
|
+
if (!query)
|
|
115
|
+
return exports.ENUM_PARAMS;
|
|
116
|
+
const norm = (s) => s
|
|
117
|
+
.toLowerCase()
|
|
118
|
+
.replace(/^-+/, '')
|
|
119
|
+
.replace(/[<>[\]]/g, '');
|
|
120
|
+
const q = norm(query);
|
|
121
|
+
// Exact matches (command, param, or value) take precedence so that an exact
|
|
122
|
+
// command name like "scroll" doesn't also drag in "scroll-until-visible".
|
|
123
|
+
const exact = exports.ENUM_PARAMS.filter((p) => p.command.toLowerCase() === q ||
|
|
124
|
+
norm(p.param) === q ||
|
|
125
|
+
p.values.some((v) => v.value.toLowerCase() === q));
|
|
126
|
+
if (exact.length > 0)
|
|
127
|
+
return exact;
|
|
128
|
+
// Otherwise fall back to substring matching to forgive partial queries.
|
|
129
|
+
return exports.ENUM_PARAMS.filter((p) => p.command.toLowerCase().includes(q) || norm(p.param).includes(q));
|
|
130
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -28,6 +28,7 @@ const press_key_js_1 = require("./commands/press-key.js");
|
|
|
28
28
|
const session_js_1 = require("./commands/session.js");
|
|
29
29
|
const daemon_js_1 = require("./commands/daemon.js");
|
|
30
30
|
const install_js_1 = require("./commands/install.js");
|
|
31
|
+
const init_js_1 = require("./commands/init.js");
|
|
31
32
|
const device_pool_js_1 = require("./commands/device-pool.js");
|
|
32
33
|
const run_parallel_js_1 = require("./commands/run-parallel.js");
|
|
33
34
|
const run_sequence_js_1 = require("./commands/run-sequence.js");
|
|
@@ -59,6 +60,7 @@ const logs_js_1 = require("./commands/logs.js");
|
|
|
59
60
|
const memory_js_1 = require("./commands/memory.js");
|
|
60
61
|
const metro_js_1 = require("./commands/metro.js");
|
|
61
62
|
const clipboard_js_1 = require("./commands/clipboard.js");
|
|
63
|
+
const options_js_1 = require("./commands/options.js");
|
|
62
64
|
const device_picker_js_1 = require("./device-picker.js");
|
|
63
65
|
const update_check_js_1 = require("./update-check.js");
|
|
64
66
|
const pkg_root_js_1 = require("./pkg-root.js");
|
|
@@ -101,6 +103,7 @@ const COMMAND_HELP = {
|
|
|
101
103
|
'run-flow-inline': run_flow_inline_js_1.HELP,
|
|
102
104
|
session: session_js_1.HELP,
|
|
103
105
|
'install-web': install_js_1.HELP_INSTALL_WEB,
|
|
106
|
+
init: init_js_1.HELP,
|
|
104
107
|
'daemon-start': daemon_js_1.HELP_DAEMON_START,
|
|
105
108
|
'daemon-stop': daemon_js_1.HELP_DAEMON_STOP,
|
|
106
109
|
'daemon-status': daemon_js_1.HELP_DAEMON_STATUS,
|
|
@@ -119,12 +122,14 @@ const COMMAND_HELP = {
|
|
|
119
122
|
metro: metro_js_1.HELP,
|
|
120
123
|
clipboard: clipboard_js_1.HELP,
|
|
121
124
|
paste: ' paste Trigger OS-level paste (or type clipboard on iOS)',
|
|
125
|
+
'list-options': options_js_1.HELP,
|
|
122
126
|
};
|
|
123
127
|
const OPTIONS_HELP = `Options:
|
|
124
128
|
--device <id> Target device ID (also keys the session and daemon)
|
|
125
129
|
--device-name <n> Target a booted device by name (resolved to ID from booted devices)
|
|
126
130
|
--platform <p> Filter to devices of this platform (ios, android, tvos, web)
|
|
127
131
|
--json Output as machine-readable JSON
|
|
132
|
+
--options List valid values for a command's enumerated parameters and exit
|
|
128
133
|
--verbose, -v Log daemon calls, fallbacks, and raw output
|
|
129
134
|
--version, -V Print version number
|
|
130
135
|
--help, -h Show this help`;
|
|
@@ -141,6 +146,7 @@ async function main() {
|
|
|
141
146
|
boolean: [
|
|
142
147
|
'json',
|
|
143
148
|
'help',
|
|
149
|
+
'options',
|
|
144
150
|
'version',
|
|
145
151
|
'clear',
|
|
146
152
|
'list',
|
|
@@ -163,6 +169,9 @@ async function main() {
|
|
|
163
169
|
'leaks',
|
|
164
170
|
'snapshots',
|
|
165
171
|
'growth-only',
|
|
172
|
+
'global',
|
|
173
|
+
'force',
|
|
174
|
+
'yes',
|
|
166
175
|
],
|
|
167
176
|
string: [
|
|
168
177
|
'device',
|
|
@@ -222,7 +231,7 @@ async function main() {
|
|
|
222
231
|
'user-agent',
|
|
223
232
|
'color-scheme',
|
|
224
233
|
],
|
|
225
|
-
alias: { h: 'help', v: 'verbose', V: 'version', o: 'output' },
|
|
234
|
+
alias: { h: 'help', v: 'verbose', V: 'version', o: 'output', y: 'yes' },
|
|
226
235
|
});
|
|
227
236
|
if (argv['verbose'])
|
|
228
237
|
(0, verbose_js_1.setVerbose)(true);
|
|
@@ -234,6 +243,12 @@ async function main() {
|
|
|
234
243
|
console.log(pkg.version);
|
|
235
244
|
process.exit(0);
|
|
236
245
|
}
|
|
246
|
+
// `<command> --options` lists the valid values for that command's enumerated
|
|
247
|
+
// parameters and exits — no device resolution needed. With no command it
|
|
248
|
+
// lists every enumerated parameter. `--help` still wins if both are passed.
|
|
249
|
+
if (argv['options'] && !argv['help'] && command !== 'list-options') {
|
|
250
|
+
process.exit((0, options_js_1.listOptions)(command, opts));
|
|
251
|
+
}
|
|
237
252
|
// Handle help and unknown commands before device resolution —
|
|
238
253
|
// no point prompting for a device if we're just printing help or erroring out.
|
|
239
254
|
if (!command || argv['help']) {
|
|
@@ -252,11 +267,13 @@ async function main() {
|
|
|
252
267
|
'stop-device',
|
|
253
268
|
'delete-device',
|
|
254
269
|
'install-web',
|
|
270
|
+
'init',
|
|
255
271
|
'copy-app',
|
|
256
272
|
'device-pool',
|
|
257
273
|
'run-parallel',
|
|
258
274
|
'metro',
|
|
259
275
|
'workspace',
|
|
276
|
+
'list-options',
|
|
260
277
|
// `logs --list` and `logs --source metro` only query Metro on localhost — no device needed
|
|
261
278
|
// `logs` always needs a device session — Metro discovery is device-scoped.
|
|
262
279
|
// `daemon-stop --all` stops every daemon — no device needed
|
|
@@ -418,6 +435,10 @@ async function main() {
|
|
|
418
435
|
exitCode = await (0, press_key_js_1.pressKey)(key, opts, sessionName);
|
|
419
436
|
break;
|
|
420
437
|
}
|
|
438
|
+
case 'list-options': {
|
|
439
|
+
exitCode = (0, options_js_1.listOptions)(rest[0], opts);
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
421
442
|
case 'scroll': {
|
|
422
443
|
const dir = (argv['direction'] || 'down').toLowerCase();
|
|
423
444
|
exitCode = await (0, scroll_js_1.scroll)(dir, opts, sessionName);
|
|
@@ -610,6 +631,13 @@ async function main() {
|
|
|
610
631
|
case 'session':
|
|
611
632
|
exitCode = await (0, session_js_1.sessionCmd)(argv['clear'], argv['list'], opts, sessionName);
|
|
612
633
|
break;
|
|
634
|
+
case 'init':
|
|
635
|
+
exitCode = await (0, init_js_1.init)(opts, rest[0], {
|
|
636
|
+
global: argv['global'],
|
|
637
|
+
force: argv['force'],
|
|
638
|
+
yes: argv['yes'],
|
|
639
|
+
});
|
|
640
|
+
break;
|
|
613
641
|
case 'install-web':
|
|
614
642
|
exitCode = await (0, install_js_1.installWebCli)(opts, argv['check'], rest[0]);
|
|
615
643
|
break;
|
package/dist/utils.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DIRECTIONS = void 0;
|
|
3
4
|
exports.sleep = sleep;
|
|
4
5
|
exports.swipeCoords = swipeCoords;
|
|
5
6
|
function sleep(ms) {
|
|
6
7
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
8
|
}
|
|
9
|
+
exports.DIRECTIONS = ['down', 'up', 'left', 'right'];
|
|
8
10
|
function swipeCoords(dir) {
|
|
9
11
|
switch (dir) {
|
|
10
12
|
case 'down':
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@houwert/conductor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "CLI tool for mobile app interactions — optimized for AI agents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"main": "./dist/index.js",
|
|
18
18
|
"files": [
|
|
19
19
|
"dist/",
|
|
20
|
-
"proto/"
|
|
20
|
+
"proto/",
|
|
21
|
+
"skills/"
|
|
21
22
|
],
|
|
22
23
|
"scripts": {
|
|
23
24
|
"build": "tsc",
|