@houwert/conductor 0.16.0 → 0.17.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/dist/commands/assert-visible.js +2 -1
- package/dist/commands/clipboard.js +84 -0
- package/dist/commands/crashes.js +262 -0
- package/dist/commands/debug.js +244 -0
- package/dist/commands/flow-record.js +58 -0
- package/dist/commands/gestures.js +196 -0
- package/dist/commands/inspect.js +41 -1
- package/dist/commands/metro.js +109 -0
- package/dist/commands/network.js +194 -0
- package/dist/commands/profile.js +300 -0
- package/dist/commands/run-sequence.js +124 -0
- package/dist/commands/scroll-until-visible.js +17 -0
- package/dist/commands/start-device.js +18 -0
- package/dist/commands/tap.js +2 -1
- package/dist/commands/workspace.js +162 -0
- package/dist/drivers/android.js +9 -0
- package/dist/drivers/direct-ios-selector.js +70 -0
- package/dist/drivers/element-resolver.js +189 -0
- package/dist/drivers/flow-recorder.js +135 -0
- package/dist/drivers/flow-runner.js +5 -3
- package/dist/drivers/ios.js +87 -2
- package/dist/drivers/metro-cdp.js +291 -0
- package/dist/drivers/metro-scripts.js +399 -0
- package/dist/drivers/wait.js +20 -2
- package/dist/index.js +262 -1
- package/dist/runner.js +18 -0
- package/package.json +1 -1
- package/proto/conductor_android.proto +29 -0
|
@@ -0,0 +1,162 @@
|
|
|
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.HELP = void 0;
|
|
7
|
+
exports.workspaceInfo = workspaceInfo;
|
|
8
|
+
exports.workspaceCmd = workspaceCmd;
|
|
9
|
+
exports.HELP = ` workspace info Print detected project type, bundle IDs, devices, Metro port`;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const output_js_1 = require("../output.js");
|
|
13
|
+
const list_devices_js_1 = require("./list-devices.js");
|
|
14
|
+
const metro_discovery_js_1 = require("../drivers/log-sources/metro-discovery.js");
|
|
15
|
+
function findProjectRoot(start) {
|
|
16
|
+
let cur = path_1.default.resolve(start);
|
|
17
|
+
for (let i = 0; i < 20; i++) {
|
|
18
|
+
if (fs_1.default.existsSync(path_1.default.join(cur, 'package.json')))
|
|
19
|
+
return cur;
|
|
20
|
+
if (fs_1.default.existsSync(path_1.default.join(cur, '.git')))
|
|
21
|
+
return cur;
|
|
22
|
+
const parent = path_1.default.dirname(cur);
|
|
23
|
+
if (parent === cur)
|
|
24
|
+
break;
|
|
25
|
+
cur = parent;
|
|
26
|
+
}
|
|
27
|
+
return start;
|
|
28
|
+
}
|
|
29
|
+
function readJson(file) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function detectIosBundleId(projectRoot) {
|
|
38
|
+
const iosDir = path_1.default.join(projectRoot, 'ios');
|
|
39
|
+
if (!fs_1.default.existsSync(iosDir))
|
|
40
|
+
return { id: null, name: null };
|
|
41
|
+
// Look for the first .xcodeproj/project.pbxproj and grep PRODUCT_BUNDLE_IDENTIFIER.
|
|
42
|
+
try {
|
|
43
|
+
const entries = fs_1.default.readdirSync(iosDir);
|
|
44
|
+
const xcodeproj = entries.find((e) => e.endsWith('.xcodeproj'));
|
|
45
|
+
if (!xcodeproj)
|
|
46
|
+
return { id: null, name: null };
|
|
47
|
+
const pbx = path_1.default.join(iosDir, xcodeproj, 'project.pbxproj');
|
|
48
|
+
if (!fs_1.default.existsSync(pbx))
|
|
49
|
+
return { id: null, name: xcodeproj.replace('.xcodeproj', '') };
|
|
50
|
+
const text = fs_1.default.readFileSync(pbx, 'utf-8');
|
|
51
|
+
const m = text.match(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([A-Za-z0-9._-]+)"?\s*;/);
|
|
52
|
+
return {
|
|
53
|
+
id: m ? m[1] : null,
|
|
54
|
+
name: xcodeproj.replace('.xcodeproj', ''),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return { id: null, name: null };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function detectAndroidBundleId(projectRoot) {
|
|
62
|
+
const candidates = [
|
|
63
|
+
path_1.default.join(projectRoot, 'android', 'app', 'build.gradle'),
|
|
64
|
+
path_1.default.join(projectRoot, 'android', 'app', 'build.gradle.kts'),
|
|
65
|
+
];
|
|
66
|
+
for (const file of candidates) {
|
|
67
|
+
if (!fs_1.default.existsSync(file))
|
|
68
|
+
continue;
|
|
69
|
+
try {
|
|
70
|
+
const text = fs_1.default.readFileSync(file, 'utf-8');
|
|
71
|
+
const idMatch = text.match(/applicationId\s+["']([A-Za-z0-9._-]+)["']/);
|
|
72
|
+
if (idMatch) {
|
|
73
|
+
return { id: idMatch[1], name: idMatch[1].split('.').pop() ?? null };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// try next
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { id: null, name: null };
|
|
81
|
+
}
|
|
82
|
+
async function workspaceInfo(opts = {}) {
|
|
83
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
84
|
+
const pkgPath = path_1.default.join(projectRoot, 'package.json');
|
|
85
|
+
const pkg = fs_1.default.existsSync(pkgPath) ? readJson(pkgPath) : null;
|
|
86
|
+
const deps = {
|
|
87
|
+
...(pkg?.dependencies ?? {}),
|
|
88
|
+
...(pkg?.devDependencies ?? {}),
|
|
89
|
+
};
|
|
90
|
+
const rnVersion = deps['react-native'] ?? null;
|
|
91
|
+
const expoVersion = deps['expo'] ?? null;
|
|
92
|
+
const hasIosDir = fs_1.default.existsSync(path_1.default.join(projectRoot, 'ios'));
|
|
93
|
+
const hasAndroidDir = fs_1.default.existsSync(path_1.default.join(projectRoot, 'android'));
|
|
94
|
+
const hasPlaywright = fs_1.default.existsSync(path_1.default.join(projectRoot, 'playwright.config.ts')) ||
|
|
95
|
+
fs_1.default.existsSync(path_1.default.join(projectRoot, 'playwright.config.js')) ||
|
|
96
|
+
fs_1.default.existsSync(path_1.default.join(projectRoot, 'playwright.config.mjs'));
|
|
97
|
+
let projectType = 'unknown';
|
|
98
|
+
if (expoVersion)
|
|
99
|
+
projectType = 'expo';
|
|
100
|
+
else if (rnVersion)
|
|
101
|
+
projectType = 'rn';
|
|
102
|
+
else if (hasIosDir && hasAndroidDir)
|
|
103
|
+
projectType = 'mixed';
|
|
104
|
+
else if (hasIosDir)
|
|
105
|
+
projectType = 'ios';
|
|
106
|
+
else if (hasAndroidDir)
|
|
107
|
+
projectType = 'android';
|
|
108
|
+
else if (hasPlaywright)
|
|
109
|
+
projectType = 'web';
|
|
110
|
+
const ios = detectIosBundleId(projectRoot);
|
|
111
|
+
const android = detectAndroidBundleId(projectRoot);
|
|
112
|
+
const devices = await (0, list_devices_js_1.discoverBootedDevices)().catch(() => []);
|
|
113
|
+
let metroPort = null;
|
|
114
|
+
for (const d of devices) {
|
|
115
|
+
if (d.platform !== 'ios' && d.platform !== 'tvos' && d.platform !== 'android')
|
|
116
|
+
continue;
|
|
117
|
+
const port = await (0, metro_discovery_js_1.discoverMetroPortForDevice)(d.platform, d.id).catch(() => null);
|
|
118
|
+
if (port) {
|
|
119
|
+
metroPort = port;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const info = {
|
|
124
|
+
projectRoot,
|
|
125
|
+
projectType,
|
|
126
|
+
reactNativeVersion: rnVersion,
|
|
127
|
+
bundleIds: { ios: ios.id, android: android.id },
|
|
128
|
+
bundleNames: { ios: ios.name, android: android.name },
|
|
129
|
+
hasIosDir,
|
|
130
|
+
hasAndroidDir,
|
|
131
|
+
hasPlaywrightConfig: hasPlaywright,
|
|
132
|
+
configuredDevices: devices.map((d) => ({ id: d.id, name: d.name, platform: d.platform })),
|
|
133
|
+
metroPort,
|
|
134
|
+
currentSession: process.env.CONDUCTOR_DEVICE ?? 'default',
|
|
135
|
+
};
|
|
136
|
+
if (opts.json) {
|
|
137
|
+
(0, output_js_1.printData)(info, opts);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
const lines = [
|
|
141
|
+
`projectRoot: ${info.projectRoot}`,
|
|
142
|
+
`projectType: ${info.projectType}`,
|
|
143
|
+
`reactNativeVersion: ${info.reactNativeVersion ?? '(none)'}`,
|
|
144
|
+
`iOS bundle id: ${info.bundleIds.ios ?? '(none)'}`,
|
|
145
|
+
`Android bundle id: ${info.bundleIds.android ?? '(none)'}`,
|
|
146
|
+
`ios/ dir: ${info.hasIosDir}`,
|
|
147
|
+
`android/ dir: ${info.hasAndroidDir}`,
|
|
148
|
+
`playwright config: ${info.hasPlaywrightConfig}`,
|
|
149
|
+
`metroPort: ${info.metroPort ?? '(not running)'}`,
|
|
150
|
+
`booted devices: ${info.configuredDevices.length}`,
|
|
151
|
+
...info.configuredDevices.map((d) => ` - ${d.id} ${d.name} (${d.platform})`),
|
|
152
|
+
];
|
|
153
|
+
console.log(lines.join('\n'));
|
|
154
|
+
}
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
async function workspaceCmd(sub, opts = {}) {
|
|
158
|
+
if (sub === 'info' || sub === '')
|
|
159
|
+
return workspaceInfo(opts);
|
|
160
|
+
(0, output_js_1.printError)(`Unknown workspace subcommand: ${sub}`, opts);
|
|
161
|
+
return 1;
|
|
162
|
+
}
|
package/dist/drivers/android.js
CHANGED
|
@@ -123,6 +123,15 @@ class AndroidDriver {
|
|
|
123
123
|
async tap(x, y) {
|
|
124
124
|
await this.call('tap', { x: Math.round(x), y: Math.round(y) });
|
|
125
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Multi-finger gesture playback. Driver injects multi-pointer MotionEvents
|
|
128
|
+
* via UiAutomation.injectInputEvent — one pointer per path, resampled to a
|
|
129
|
+
* shared 16ms grid so all fingers move on the same clock. `dt_ms` is the
|
|
130
|
+
* delay since this finger's previous step (or initial offset for the first).
|
|
131
|
+
*/
|
|
132
|
+
async gesturePath(paths, timeoutMs = 35000) {
|
|
133
|
+
await this.call('gesturePath', { paths }, timeoutMs);
|
|
134
|
+
}
|
|
126
135
|
async inputText(text) {
|
|
127
136
|
await this.call('inputText', { text });
|
|
128
137
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.simpleIOSSelectorTarget = simpleIOSSelectorTarget;
|
|
4
|
+
exports.makeIOSDirectResolver = makeIOSDirectResolver;
|
|
5
|
+
const verbose_js_1 = require("../verbose.js");
|
|
6
|
+
/** Characters that make a selector value a regex rather than a literal. */
|
|
7
|
+
const REGEX_META = /[.*+?^${}()|[\]\\]/;
|
|
8
|
+
/**
|
|
9
|
+
* Decide whether `sel` is simple enough to resolve through a direct runner
|
|
10
|
+
* query. Returns the single text/id/query term, or `null` to force the
|
|
11
|
+
* snapshot path.
|
|
12
|
+
*
|
|
13
|
+
* A selector is eligible only when it carries exactly one of text/id/query,
|
|
14
|
+
* the value is a plain literal (no regex metacharacters), and there are no
|
|
15
|
+
* index, relative-position or state constraints — those need the full tree.
|
|
16
|
+
*/
|
|
17
|
+
function simpleIOSSelectorTarget(sel) {
|
|
18
|
+
if (sel.index !== undefined)
|
|
19
|
+
return null;
|
|
20
|
+
if (sel.below || sel.above || sel.leftOf || sel.rightOf || sel.containsChild)
|
|
21
|
+
return null;
|
|
22
|
+
if (sel.enabled !== undefined ||
|
|
23
|
+
sel.checked !== undefined ||
|
|
24
|
+
sel.focused !== undefined ||
|
|
25
|
+
sel.selected !== undefined) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const terms = [];
|
|
29
|
+
if (sel.text != null)
|
|
30
|
+
terms.push({ key: 'text', value: sel.text });
|
|
31
|
+
if (sel.id != null)
|
|
32
|
+
terms.push({ key: 'id', value: sel.id });
|
|
33
|
+
if (sel.query != null)
|
|
34
|
+
terms.push({ key: 'query', value: sel.query });
|
|
35
|
+
if (terms.length !== 1)
|
|
36
|
+
return null;
|
|
37
|
+
const target = terms[0];
|
|
38
|
+
if (!target.value || REGEX_META.test(target.value))
|
|
39
|
+
return null;
|
|
40
|
+
return target;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build a fast-path resolver for `sel`, or `undefined` when the selector is
|
|
44
|
+
* not simple enough. The returned function resolves the element via the
|
|
45
|
+
* runner; it returns `null` (so the caller falls back to the snapshot path)
|
|
46
|
+
* when the element is absent or the match is ambiguous, and throws only on a
|
|
47
|
+
* transport failure.
|
|
48
|
+
*/
|
|
49
|
+
function makeIOSDirectResolver(driver, sel, appIds = []) {
|
|
50
|
+
const target = simpleIOSSelectorTarget(sel);
|
|
51
|
+
if (!target)
|
|
52
|
+
return undefined;
|
|
53
|
+
return async () => {
|
|
54
|
+
const result = await driver.queryElement(target.key, target.value, appIds);
|
|
55
|
+
// matchCount > 1 → ambiguous: let the snapshot matcher apply its
|
|
56
|
+
// deepest-match / prefer-interactive tie-breaking instead of guessing.
|
|
57
|
+
if (!result.found || result.matchCount !== 1 || !result.node)
|
|
58
|
+
return null;
|
|
59
|
+
const n = result.node;
|
|
60
|
+
const { X, Y, Width, Height } = n.frame;
|
|
61
|
+
(0, verbose_js_1.log)(`[iOS] direct query ${target.key}="${target.value}" → ` +
|
|
62
|
+
`text="${n.label || n.title || n.value || n.placeholderValue || ''}" id="${n.identifier}"`);
|
|
63
|
+
return {
|
|
64
|
+
centerX: X + Width / 2,
|
|
65
|
+
centerY: Y + Height / 2,
|
|
66
|
+
text: n.label || n.title || n.value || n.placeholderValue || undefined,
|
|
67
|
+
id: n.identifier || undefined,
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -7,6 +7,9 @@ exports.inspectIOSToText = inspectIOSToText;
|
|
|
7
7
|
exports.inspectAndroidToText = inspectAndroidToText;
|
|
8
8
|
exports.findWebElement = findWebElement;
|
|
9
9
|
exports.inspectWebToText = inspectWebToText;
|
|
10
|
+
exports.findIOSViewAtPoint = findIOSViewAtPoint;
|
|
11
|
+
exports.findAndroidViewAtPoint = findAndroidViewAtPoint;
|
|
12
|
+
exports.findWebViewAtPoint = findWebViewAtPoint;
|
|
10
13
|
const verbose_js_1 = require("../verbose.js");
|
|
11
14
|
// XCUIElementType rawValues that represent interactive controls.
|
|
12
15
|
// Approximates “prefer clickable” when sorting, since AXElement does not expose clickable.
|
|
@@ -590,3 +593,189 @@ function visitWeb(nodes, lines, depth) {
|
|
|
590
593
|
}
|
|
591
594
|
}
|
|
592
595
|
}
|
|
596
|
+
function iosRectAt(el) {
|
|
597
|
+
return { x: el.frame.X, y: el.frame.Y, width: el.frame.Width, height: el.frame.Height };
|
|
598
|
+
}
|
|
599
|
+
function iosContains(el, x, y) {
|
|
600
|
+
const r = iosRectAt(el);
|
|
601
|
+
return x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height;
|
|
602
|
+
}
|
|
603
|
+
// iOS element types that are typically interactive (XCUIElementType enum values).
|
|
604
|
+
// 9=button, 50=staticText skipped, 49=textField, 65=secureTextField, 14=switch, 38=slider, etc.
|
|
605
|
+
const IOS_TAPPABLE_TYPES = new Set([9, 49, 65, 14, 38, 51, 52, 76, 53, 55, 56, 57]);
|
|
606
|
+
function iosNodeTappable(el) {
|
|
607
|
+
return el.enabled && IOS_TAPPABLE_TYPES.has(el.elementType);
|
|
608
|
+
}
|
|
609
|
+
function iosSummarize(el) {
|
|
610
|
+
const parts = [`type=${el.elementType}`];
|
|
611
|
+
if (el.identifier)
|
|
612
|
+
parts.push(`id=${el.identifier}`);
|
|
613
|
+
if (el.label)
|
|
614
|
+
parts.push(`label="${el.label}"`);
|
|
615
|
+
if (el.value)
|
|
616
|
+
parts.push(`value="${String(el.value)}"`);
|
|
617
|
+
const r = iosRectAt(el);
|
|
618
|
+
parts.push(`bounds=[${Math.round(r.x)},${Math.round(r.y)}][${Math.round(r.x + r.width)},${Math.round(r.y + r.height)}]`);
|
|
619
|
+
if (!el.enabled)
|
|
620
|
+
parts.push('disabled');
|
|
621
|
+
return parts.join(' ');
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Walk the iOS AX tree depth-first and return the deepest element whose frame
|
|
625
|
+
* contains (x,y). When `tappableOnly` is true, restrict to elements that look
|
|
626
|
+
* interactive (button, text field, switch, slider, etc.).
|
|
627
|
+
*/
|
|
628
|
+
function findIOSViewAtPoint(root, x, y, tappableOnly = false) {
|
|
629
|
+
let best = null;
|
|
630
|
+
let bestArea = Infinity;
|
|
631
|
+
function visit(node) {
|
|
632
|
+
if (!iosContains(node, x, y))
|
|
633
|
+
return;
|
|
634
|
+
if (!tappableOnly || iosNodeTappable(node)) {
|
|
635
|
+
const r = iosRectAt(node);
|
|
636
|
+
const area = r.width * r.height;
|
|
637
|
+
if (area <= bestArea) {
|
|
638
|
+
bestArea = area;
|
|
639
|
+
best = node;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (node.children) {
|
|
643
|
+
for (const child of node.children)
|
|
644
|
+
visit(child);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
visit(root);
|
|
648
|
+
if (!best)
|
|
649
|
+
return null;
|
|
650
|
+
const matched = best;
|
|
651
|
+
return {
|
|
652
|
+
summary: iosSummarize(matched),
|
|
653
|
+
rect: iosRectAt(matched),
|
|
654
|
+
id: matched.identifier || undefined,
|
|
655
|
+
text: matched.label || matched.value,
|
|
656
|
+
enabled: matched.enabled,
|
|
657
|
+
tappable: iosNodeTappable(matched),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
function androidContains(n, x, y) {
|
|
661
|
+
return x >= n.bounds.x1 && x < n.bounds.x2 && y >= n.bounds.y1 && y < n.bounds.y2;
|
|
662
|
+
}
|
|
663
|
+
function androidRect(n) {
|
|
664
|
+
return {
|
|
665
|
+
x: n.bounds.x1,
|
|
666
|
+
y: n.bounds.y1,
|
|
667
|
+
width: n.bounds.x2 - n.bounds.x1,
|
|
668
|
+
height: n.bounds.y2 - n.bounds.y1,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
function androidSummarize(n) {
|
|
672
|
+
const parts = [];
|
|
673
|
+
if (n.className)
|
|
674
|
+
parts.push(`class=${n.className}`);
|
|
675
|
+
if (n.resourceId)
|
|
676
|
+
parts.push(`id=${n.resourceId}`);
|
|
677
|
+
if (n.text)
|
|
678
|
+
parts.push(`text="${n.text}"`);
|
|
679
|
+
if (n.contentDesc)
|
|
680
|
+
parts.push(`desc="${n.contentDesc}"`);
|
|
681
|
+
parts.push(`bounds=[${n.bounds.x1},${n.bounds.y1}][${n.bounds.x2},${n.bounds.y2}]`);
|
|
682
|
+
if (n.clickable)
|
|
683
|
+
parts.push('clickable');
|
|
684
|
+
if (!n.enabled)
|
|
685
|
+
parts.push('disabled');
|
|
686
|
+
return parts.join(' ');
|
|
687
|
+
}
|
|
688
|
+
function findAndroidViewAtPoint(xml, x, y, tappableOnly = false) {
|
|
689
|
+
const nodes = parseAndroidHierarchy(xml);
|
|
690
|
+
let best = null;
|
|
691
|
+
let bestArea = Infinity;
|
|
692
|
+
for (const n of nodes) {
|
|
693
|
+
if (!androidContains(n, x, y))
|
|
694
|
+
continue;
|
|
695
|
+
if (tappableOnly && !n.clickable)
|
|
696
|
+
continue;
|
|
697
|
+
const r = androidRect(n);
|
|
698
|
+
const area = r.width * r.height;
|
|
699
|
+
if (area <= bestArea) {
|
|
700
|
+
bestArea = area;
|
|
701
|
+
best = n;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (!best)
|
|
705
|
+
return null;
|
|
706
|
+
const matched = best;
|
|
707
|
+
return {
|
|
708
|
+
summary: androidSummarize(matched),
|
|
709
|
+
rect: androidRect(matched),
|
|
710
|
+
id: matched.resourceId || undefined,
|
|
711
|
+
text: matched.text || matched.contentDesc || undefined,
|
|
712
|
+
enabled: matched.enabled,
|
|
713
|
+
tappable: matched.clickable,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
const WEB_TAPPABLE_ROLES = new Set([
|
|
717
|
+
'button',
|
|
718
|
+
'link',
|
|
719
|
+
'checkbox',
|
|
720
|
+
'radio',
|
|
721
|
+
'menuitem',
|
|
722
|
+
'tab',
|
|
723
|
+
'option',
|
|
724
|
+
'switch',
|
|
725
|
+
'textbox',
|
|
726
|
+
'searchbox',
|
|
727
|
+
'combobox',
|
|
728
|
+
'slider',
|
|
729
|
+
'spinbutton',
|
|
730
|
+
]);
|
|
731
|
+
function webRectContains(b, x, y) {
|
|
732
|
+
return x >= b.x && x < b.x + b.width && y >= b.y && y < b.y + b.height;
|
|
733
|
+
}
|
|
734
|
+
function webNodeTappable(node) {
|
|
735
|
+
return node.enabled && WEB_TAPPABLE_ROLES.has(node.role);
|
|
736
|
+
}
|
|
737
|
+
function webSummarize(node) {
|
|
738
|
+
const parts = [`role=${node.role}`];
|
|
739
|
+
if (node.name)
|
|
740
|
+
parts.push(`name="${node.name}"`);
|
|
741
|
+
if (node.ref)
|
|
742
|
+
parts.push(`ref=${node.ref}`);
|
|
743
|
+
if (node.bounds) {
|
|
744
|
+
const b = node.bounds;
|
|
745
|
+
parts.push(`bounds=[${Math.round(b.x)},${Math.round(b.y)}][${Math.round(b.x + b.width)},${Math.round(b.y + b.height)}]`);
|
|
746
|
+
}
|
|
747
|
+
if (!node.enabled)
|
|
748
|
+
parts.push('disabled');
|
|
749
|
+
return parts.join(' ');
|
|
750
|
+
}
|
|
751
|
+
function findWebViewAtPoint(hierarchy, x, y, tappableOnly = false) {
|
|
752
|
+
let best = null;
|
|
753
|
+
let bestArea = Infinity;
|
|
754
|
+
function visit(node) {
|
|
755
|
+
if (node.bounds && webRectContains(node.bounds, x, y)) {
|
|
756
|
+
if (!tappableOnly || webNodeTappable(node)) {
|
|
757
|
+
const area = node.bounds.width * node.bounds.height;
|
|
758
|
+
if (area <= bestArea) {
|
|
759
|
+
bestArea = area;
|
|
760
|
+
best = node;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (node.children)
|
|
765
|
+
for (const c of node.children)
|
|
766
|
+
visit(c);
|
|
767
|
+
}
|
|
768
|
+
for (const root of hierarchy.elements)
|
|
769
|
+
visit(root);
|
|
770
|
+
if (!best)
|
|
771
|
+
return null;
|
|
772
|
+
const matched = best;
|
|
773
|
+
return {
|
|
774
|
+
summary: webSummarize(matched),
|
|
775
|
+
rect: matched.bounds ?? { x: 0, y: 0, width: 0, height: 0 },
|
|
776
|
+
text: matched.name || undefined,
|
|
777
|
+
role: matched.role,
|
|
778
|
+
enabled: matched.enabled,
|
|
779
|
+
tappable: webNodeTappable(matched),
|
|
780
|
+
};
|
|
781
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
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.defaultRecordingPath = defaultRecordingPath;
|
|
7
|
+
exports.startRecording = startRecording;
|
|
8
|
+
exports.finishRecording = finishRecording;
|
|
9
|
+
exports.getActiveRecording = getActiveRecording;
|
|
10
|
+
exports.appendStep = appendStep;
|
|
11
|
+
exports.appendEcho = appendEcho;
|
|
12
|
+
exports.commandToYamlStep = commandToYamlStep;
|
|
13
|
+
/**
|
|
14
|
+
* Active flow recording. When a path is registered for a session, successful
|
|
15
|
+
* device-action commands append themselves to the file as YAML steps.
|
|
16
|
+
*
|
|
17
|
+
* This is a *command-level* recorder — Conductor's drivers don't expose an
|
|
18
|
+
* input event channel (they receive commands, not user gestures), so we record
|
|
19
|
+
* the commands the agent issues rather than user-driven taps. Pair with
|
|
20
|
+
* `flow record start` to begin and `flow record finish` to close out.
|
|
21
|
+
*/
|
|
22
|
+
const fs_1 = __importDefault(require("fs"));
|
|
23
|
+
const path_1 = __importDefault(require("path"));
|
|
24
|
+
const os_1 = __importDefault(require("os"));
|
|
25
|
+
const session_js_1 = require("../session.js");
|
|
26
|
+
const FLOWS_DIR = path_1.default.join(os_1.default.homedir(), '.conductor', 'recordings');
|
|
27
|
+
function defaultRecordingPath(sessionName) {
|
|
28
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
29
|
+
return path_1.default.join(FLOWS_DIR, `${sessionName}-${ts}.yaml`);
|
|
30
|
+
}
|
|
31
|
+
async function startRecording(sessionName, out, appId) {
|
|
32
|
+
const target = out ? path_1.default.resolve(out) : defaultRecordingPath(sessionName);
|
|
33
|
+
fs_1.default.mkdirSync(path_1.default.dirname(target), { recursive: true });
|
|
34
|
+
const header = (appId ? `appId: ${appId}\n` : `# appId: <set me>\n`) +
|
|
35
|
+
`---\n# Recording started ${new Date().toISOString()}\n`;
|
|
36
|
+
fs_1.default.writeFileSync(target, header, 'utf-8');
|
|
37
|
+
await (0, session_js_1.updateSession)({ recordingPath: target }, sessionName);
|
|
38
|
+
return target;
|
|
39
|
+
}
|
|
40
|
+
async function finishRecording(sessionName) {
|
|
41
|
+
const session = (await (0, session_js_1.getSession)(sessionName));
|
|
42
|
+
if (!session.recordingPath)
|
|
43
|
+
return null;
|
|
44
|
+
const out = session.recordingPath;
|
|
45
|
+
fs_1.default.appendFileSync(out, `# Recording finished ${new Date().toISOString()}\n`);
|
|
46
|
+
delete session.recordingPath;
|
|
47
|
+
await (0, session_js_1.updateSession)(session, sessionName);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
async function getActiveRecording(sessionName) {
|
|
51
|
+
const session = (await (0, session_js_1.getSession)(sessionName));
|
|
52
|
+
return session.recordingPath ?? null;
|
|
53
|
+
}
|
|
54
|
+
function appendStep(filePath, yamlStep) {
|
|
55
|
+
fs_1.default.appendFileSync(filePath, yamlStep.endsWith('\n') ? yamlStep : yamlStep + '\n', 'utf-8');
|
|
56
|
+
}
|
|
57
|
+
function appendEcho(filePath, text) {
|
|
58
|
+
fs_1.default.appendFileSync(filePath, `- runScript: |\n console.log(${JSON.stringify(text)})\n`, 'utf-8');
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Map a conductor command + args into one or more Maestro-flavoured YAML
|
|
62
|
+
* steps. Returns null for commands that should not be recorded (lifecycle,
|
|
63
|
+
* inspection, status). The mapping is intentionally narrow — when we cannot
|
|
64
|
+
* faithfully replay something, we omit it rather than emit a broken step.
|
|
65
|
+
*/
|
|
66
|
+
function commandToYamlStep(cmd, rest, argv) {
|
|
67
|
+
switch (cmd) {
|
|
68
|
+
case 'launch-app': {
|
|
69
|
+
const appId = rest[0];
|
|
70
|
+
if (!appId)
|
|
71
|
+
return null;
|
|
72
|
+
const lines = [`- launchApp:`, ` appId: ${appId}`];
|
|
73
|
+
if (argv['clear-state'])
|
|
74
|
+
lines.push(` clearState: true`);
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
case 'stop-app':
|
|
78
|
+
return rest[0] ? `- stopApp: ${rest[0]}` : `- stopApp`;
|
|
79
|
+
case 'clear-state':
|
|
80
|
+
return rest[0] ? `- clearState:\n appId: ${rest[0]}` : `- clearState`;
|
|
81
|
+
case 'tap-on': {
|
|
82
|
+
const text = rest.join(' ').trim();
|
|
83
|
+
const id = argv['id'];
|
|
84
|
+
const t = argv['text'];
|
|
85
|
+
if (id)
|
|
86
|
+
return `- tapOn:\n id: ${JSON.stringify(id)}`;
|
|
87
|
+
if (t)
|
|
88
|
+
return `- tapOn:\n text: ${JSON.stringify(t)}`;
|
|
89
|
+
if (text)
|
|
90
|
+
return `- tapOn: ${JSON.stringify(text)}`;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
case 'input-text':
|
|
94
|
+
return `- inputText: ${JSON.stringify(rest.join(' '))}`;
|
|
95
|
+
case 'erase-text': {
|
|
96
|
+
const n = rest[0] ?? argv['characters'] ?? '50';
|
|
97
|
+
return `- eraseText: ${n}`;
|
|
98
|
+
}
|
|
99
|
+
case 'back':
|
|
100
|
+
return `- back`;
|
|
101
|
+
case 'hide-keyboard':
|
|
102
|
+
return `- hideKeyboard`;
|
|
103
|
+
case 'press-key':
|
|
104
|
+
return `- pressKey: ${JSON.stringify(rest[0] ?? '')}`;
|
|
105
|
+
case 'scroll':
|
|
106
|
+
return `- scroll`;
|
|
107
|
+
case 'swipe': {
|
|
108
|
+
const dir = argv['direction'] ?? 'up';
|
|
109
|
+
return `- swipe:\n direction: ${dir}`;
|
|
110
|
+
}
|
|
111
|
+
case 'open-link':
|
|
112
|
+
return `- openLink: ${JSON.stringify(rest[0] ?? '')}`;
|
|
113
|
+
case 'set-orientation':
|
|
114
|
+
return `- setOrientation: ${rest[0] ?? argv['orientation'] ?? 'portrait'}`;
|
|
115
|
+
case 'set-location': {
|
|
116
|
+
const lat = argv['lat'] ?? argv['latitude'];
|
|
117
|
+
const lng = argv['lng'] ?? argv['longitude'];
|
|
118
|
+
if (lat === undefined || lng === undefined)
|
|
119
|
+
return null;
|
|
120
|
+
return `- setLocation:\n latitude: ${lat}\n longitude: ${lng}`;
|
|
121
|
+
}
|
|
122
|
+
case 'paste':
|
|
123
|
+
return `- runScript: |\n // paste — re-record manually if needed`;
|
|
124
|
+
case 'assert-visible': {
|
|
125
|
+
const text = rest.join(' ').trim();
|
|
126
|
+
return text ? `- assertVisible: ${JSON.stringify(text)}` : null;
|
|
127
|
+
}
|
|
128
|
+
case 'assert-not-visible': {
|
|
129
|
+
const text = rest.join(' ').trim();
|
|
130
|
+
return text ? `- assertNotVisible: ${JSON.stringify(text)}` : null;
|
|
131
|
+
}
|
|
132
|
+
default:
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -18,6 +18,7 @@ const ios_js_1 = require("./ios.js");
|
|
|
18
18
|
const android_js_1 = require("./android.js");
|
|
19
19
|
const web_js_1 = require("./web.js");
|
|
20
20
|
const wait_js_1 = require("./wait.js");
|
|
21
|
+
const direct_ios_selector_js_1 = require("./direct-ios-selector.js");
|
|
21
22
|
const perf_hooks_1 = require("perf_hooks");
|
|
22
23
|
const js_engine_js_1 = require("./js-engine.js");
|
|
23
24
|
const utils_js_1 = require("../utils.js");
|
|
@@ -147,7 +148,7 @@ async function waitForElement(driver, sel, timeoutMs, appIds, opts) {
|
|
|
147
148
|
const elSel = toElementSelector(sel);
|
|
148
149
|
if (driver instanceof ios_js_1.IOSDriver) {
|
|
149
150
|
const iosShouldAllow = opts?.output[OUTPUT_IOS_SHOULD_ALLOW];
|
|
150
|
-
return (0, wait_js_1.waitForIOSElement)(() => iosGetHierarchy(driver, appIds ?? [], iosShouldAllow), elSel, timeoutMs);
|
|
151
|
+
return (0, wait_js_1.waitForIOSElement)((o) => iosGetHierarchy(driver, appIds ?? [], iosShouldAllow, o?.cached), elSel, timeoutMs, undefined, (0, direct_ios_selector_js_1.makeIOSDirectResolver)(driver, elSel, appIds ?? []));
|
|
151
152
|
}
|
|
152
153
|
else if (driver instanceof web_js_1.WebDriver) {
|
|
153
154
|
return (0, wait_js_1.waitForWebElement)(() => driver.viewHierarchy(), elSel, timeoutMs);
|
|
@@ -211,10 +212,11 @@ async function tapPermissionDialog(driver, root, shouldAllow) {
|
|
|
211
212
|
* dialog (if present) before returning so the caller sees a clean hierarchy.
|
|
212
213
|
* The waitForIOSElement retry loop handles multiple dialogs across iterations.
|
|
213
214
|
*/
|
|
214
|
-
async function iosGetHierarchy(driver, appIds, shouldAllow) {
|
|
215
|
-
const root = (await driver.viewHierarchy(false, appIds)).axElement;
|
|
215
|
+
async function iosGetHierarchy(driver, appIds, shouldAllow, cached) {
|
|
216
|
+
const root = (await driver.viewHierarchy(false, appIds, { cache: cached })).axElement;
|
|
216
217
|
if (shouldAllow !== undefined) {
|
|
217
218
|
const tapped = await tapPermissionDialog(driver, root, shouldAllow);
|
|
219
|
+
// Re-fetch fresh after dismissing a dialog — the tap invalidated the cache.
|
|
218
220
|
if (tapped)
|
|
219
221
|
return (await driver.viewHierarchy(false, appIds)).axElement;
|
|
220
222
|
}
|