@houwert/conductor 0.26.0 → 0.27.1

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.
@@ -0,0 +1,36 @@
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.addMedia = addMedia;
8
+ exports.HELP = ` add-media <path>... Add image/video files to the device gallery (media-picker testing)`;
9
+ const path_1 = __importDefault(require("path"));
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const runner_js_1 = require("../runner.js");
12
+ const output_js_1 = require("../output.js");
13
+ async function addMedia(files, opts = {}, sessionName = 'default') {
14
+ if (files.length === 0) {
15
+ (0, output_js_1.printError)('add-media requires at least one file path', opts);
16
+ return 1;
17
+ }
18
+ const resolved = files.map((f) => path_1.default.resolve(process.cwd(), f));
19
+ const missing = resolved.filter((f) => !fs_1.default.existsSync(f));
20
+ if (missing.length > 0) {
21
+ (0, output_js_1.printError)(`add-media: file(s) not found:\n${missing.join('\n')}`, opts);
22
+ return 1;
23
+ }
24
+ const result = await (0, runner_js_1.runDirect)(async (driver) => {
25
+ for (const f of resolved)
26
+ await driver.addMedia(f);
27
+ }, sessionName);
28
+ if (result.success) {
29
+ (0, output_js_1.printSuccess)(`add-media — added ${resolved.length} file(s)`, opts);
30
+ return 0;
31
+ }
32
+ else {
33
+ (0, output_js_1.printError)(`add-media — failed\n${result.stderr}`, opts);
34
+ return 1;
35
+ }
36
+ }
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP = void 0;
4
+ exports.setAirplaneMode = setAirplaneMode;
5
+ exports.toggleAirplaneMode = toggleAirplaneMode;
6
+ exports.HELP = ` set-airplane-mode <on|off> Enable/disable airplane mode (Android only)
7
+ toggle-airplane-mode Flip airplane mode (Android only)`;
8
+ const runner_js_1 = require("../runner.js");
9
+ const android_js_1 = require("../drivers/android.js");
10
+ const output_js_1 = require("../output.js");
11
+ async function setAirplaneMode(value, opts = {}, sessionName = 'default') {
12
+ const v = value.toLowerCase();
13
+ if (v !== 'on' && v !== 'off' && v !== 'enable' && v !== 'disable') {
14
+ (0, output_js_1.printError)('set-airplane-mode requires <on|off>', opts);
15
+ return 1;
16
+ }
17
+ const enabled = v === 'on' || v === 'enable';
18
+ const result = await (0, runner_js_1.runDirect)(async (driver) => {
19
+ await driver.setAirplaneMode(enabled);
20
+ }, sessionName);
21
+ if (result.success) {
22
+ (0, output_js_1.printSuccess)(`set-airplane-mode ${enabled ? 'on' : 'off'} — done`, opts);
23
+ return 0;
24
+ }
25
+ else {
26
+ (0, output_js_1.printError)(`set-airplane-mode ${enabled ? 'on' : 'off'} — failed\n${result.stderr}`, opts);
27
+ return 1;
28
+ }
29
+ }
30
+ async function toggleAirplaneMode(opts = {}, sessionName = 'default') {
31
+ const result = await (0, runner_js_1.runDirect)(async (driver) => {
32
+ if (!(driver instanceof android_js_1.AndroidDriver)) {
33
+ throw new Error('toggle-airplane-mode is only supported on Android');
34
+ }
35
+ const current = await driver.getAirplaneMode();
36
+ await driver.setAirplaneMode(!current);
37
+ }, sessionName);
38
+ if (result.success) {
39
+ (0, output_js_1.printSuccess)('toggle-airplane-mode — done', opts);
40
+ return 0;
41
+ }
42
+ else {
43
+ (0, output_js_1.printError)(`toggle-airplane-mode — failed\n${result.stderr}`, opts);
44
+ return 1;
45
+ }
46
+ }
@@ -0,0 +1,68 @@
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.assertScreenshot = assertScreenshot;
8
+ exports.HELP = ` assert-screenshot <reference.png> Visual regression: compare the screen to a reference image
9
+ --threshold <0-1> Max fraction of differing pixels allowed (default 0.01)
10
+ --update Write/overwrite the reference from the current screen and pass`;
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const pngjs_1 = require("pngjs");
14
+ const pixelmatch_1 = __importDefault(require("pixelmatch"));
15
+ const runner_js_1 = require("../runner.js");
16
+ const output_js_1 = require("../output.js");
17
+ async function assertScreenshot(reference, opts = {}, sessionName = 'default', flags = {}) {
18
+ if (!reference) {
19
+ (0, output_js_1.printError)('assert-screenshot requires a reference image path', opts);
20
+ return 1;
21
+ }
22
+ const refPath = path_1.default.resolve(process.cwd(), reference);
23
+ const threshold = flags.threshold ?? 0.01;
24
+ let shot;
25
+ const result = await (0, runner_js_1.runDirect)(async (driver) => {
26
+ shot = await driver.screenshot();
27
+ }, sessionName);
28
+ if (!result.success || !shot) {
29
+ (0, output_js_1.printError)(`assert-screenshot — failed to capture screen\n${result.stderr}`, opts);
30
+ return 1;
31
+ }
32
+ // Seed or refresh the baseline instead of comparing.
33
+ if (flags.update || !fs_1.default.existsSync(refPath)) {
34
+ fs_1.default.mkdirSync(path_1.default.dirname(refPath), { recursive: true });
35
+ fs_1.default.writeFileSync(refPath, shot);
36
+ const why = flags.update ? 'updated' : 'created (no baseline existed)';
37
+ (0, output_js_1.printSuccess)(`assert-screenshot — reference ${why}: ${refPath}`, opts);
38
+ return 0;
39
+ }
40
+ const actual = pngjs_1.PNG.sync.read(shot);
41
+ const expected = pngjs_1.PNG.sync.read(fs_1.default.readFileSync(refPath));
42
+ if (actual.width !== expected.width || actual.height !== expected.height) {
43
+ (0, output_js_1.printError)(`assert-screenshot — size mismatch: screen ${actual.width}x${actual.height} vs ` +
44
+ `reference ${expected.width}x${expected.height}`, opts);
45
+ return 1;
46
+ }
47
+ const { width, height } = expected;
48
+ const diff = new pngjs_1.PNG({ width, height });
49
+ const diffPixels = (0, pixelmatch_1.default)(expected.data, actual.data, diff.data, width, height, {
50
+ threshold: 0.1,
51
+ });
52
+ const total = width * height;
53
+ const ratio = diffPixels / total;
54
+ if (ratio > threshold) {
55
+ const diffPath = refPath.replace(/\.png$/i, '') + '.diff.png';
56
+ fs_1.default.writeFileSync(diffPath, pngjs_1.PNG.sync.write(diff));
57
+ if (opts.json)
58
+ (0, output_js_1.printData)({ passed: false, diffPixels, ratio, diffPath }, opts);
59
+ else
60
+ (0, output_js_1.printError)(`assert-screenshot — ${(ratio * 100).toFixed(2)}% differs (> ${(threshold * 100).toFixed(2)}% allowed); diff written to ${diffPath}`, opts);
61
+ return 1;
62
+ }
63
+ if (opts.json)
64
+ (0, output_js_1.printData)({ passed: true, diffPixels, ratio }, opts);
65
+ else
66
+ (0, output_js_1.printSuccess)(`assert-screenshot — matches (${(ratio * 100).toFixed(2)}% differ, within ${(threshold * 100).toFixed(2)}%)`, opts);
67
+ return 0;
68
+ }
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP = void 0;
4
+ exports.assertTrue = assertTrue;
5
+ exports.HELP = ` assert-true <expr> Assert a JavaScript expression evaluates truthy (exit 1 if not)
6
+ --env KEY=VALUE Expose an env var to the expression (repeatable)`;
7
+ const js_engine_js_1 = require("../drivers/js-engine.js");
8
+ const output_js_1 = require("../output.js");
9
+ async function assertTrue(expr, opts = {}, env = {}) {
10
+ if (!expr) {
11
+ (0, output_js_1.printError)('assert-true requires a JavaScript expression', opts);
12
+ return 1;
13
+ }
14
+ const output = {};
15
+ try {
16
+ // Evaluate in the same sandbox the flow runner uses for assertTrue, so
17
+ // expressions behave identically whether run inline or inside a flow.
18
+ await (0, js_engine_js_1.executeScript)(`output.__assertTrue = !!(${expr});`, env, output, 'assert-true');
19
+ }
20
+ catch (e) {
21
+ (0, output_js_1.printError)(`assert-true failed to evaluate: ${expr}\n${e.message}`, opts);
22
+ return 1;
23
+ }
24
+ if (output['__assertTrue']) {
25
+ (0, output_js_1.printSuccess)(`assert-true "${expr}" — passed`, opts);
26
+ return 0;
27
+ }
28
+ (0, output_js_1.printError)(`assert-true "${expr}" — failed`, opts);
29
+ return 1;
30
+ }
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP = void 0;
4
+ exports.copyTextFrom = copyTextFrom;
5
+ exports.HELP = ` copy-text-from <element> Read an element's text; print it and copy to the device clipboard
6
+ --id <id> Match by accessibility id instead of text
7
+ --text <text> Match by text only (not id)
8
+ --index <n> Pick the nth match (0-based)`;
9
+ const runner_js_1 = require("../runner.js");
10
+ const output_js_1 = require("../output.js");
11
+ const ios_js_1 = require("../drivers/ios.js");
12
+ const android_js_1 = require("../drivers/android.js");
13
+ const web_js_1 = require("../drivers/web.js");
14
+ const vega_js_1 = require("../drivers/vega.js");
15
+ const wait_js_1 = require("../drivers/wait.js");
16
+ const direct_ios_selector_js_1 = require("../drivers/direct-ios-selector.js");
17
+ async function copyTextFrom(query, opts = {}, sessionName = 'default', flags = {}) {
18
+ if (!query && !flags.id && !flags.text) {
19
+ (0, output_js_1.printError)('copy-text-from requires <element>, --id <id>, or --text <text>', opts);
20
+ return 1;
21
+ }
22
+ const sel = {
23
+ ...(flags.text ? { text: flags.text } : flags.id ? { id: flags.id } : { query }),
24
+ ...(flags.index !== undefined && { index: flags.index }),
25
+ };
26
+ const label = flags.text ? `text="${flags.text}"` : flags.id ? `id="${flags.id}"` : `"${query}"`;
27
+ let copied = '';
28
+ const result = await (0, runner_js_1.runDirect)(async (driver) => {
29
+ let el;
30
+ if (driver instanceof ios_js_1.IOSDriver) {
31
+ el = await (0, wait_js_1.waitForIOSElement)((o) => driver.viewHierarchy(false, [], { cache: o?.cached }).then((h) => h.axElement), sel, undefined, undefined, (0, direct_ios_selector_js_1.makeIOSDirectResolver)(driver, sel));
32
+ }
33
+ else if (driver instanceof web_js_1.WebDriver) {
34
+ el = await (0, wait_js_1.waitForWebElement)(() => driver.viewHierarchy(), sel);
35
+ }
36
+ else if (driver instanceof vega_js_1.VegaDriver) {
37
+ el = await (0, wait_js_1.waitForAndroidElement)(() => driver.viewHierarchy(), sel);
38
+ }
39
+ else if (driver instanceof android_js_1.AndroidDriver) {
40
+ el = await (0, wait_js_1.waitForAndroidElement)(() => driver.viewHierarchy(), sel);
41
+ }
42
+ else {
43
+ return;
44
+ }
45
+ copied = el.text ?? '';
46
+ // Mirror Maestro's copyTextFrom by also landing the value on the device clipboard
47
+ // where the platform supports it (iOS simulator). Best-effort — the text is the
48
+ // primary output regardless.
49
+ if (driver instanceof ios_js_1.IOSDriver) {
50
+ await driver.clipboardWrite(copied).catch(() => { });
51
+ }
52
+ }, sessionName);
53
+ if (result.success) {
54
+ // Print the raw text on stdout so callers can capture it (e.g. `$(conductor copy-text-from ...)`).
55
+ process.stdout.write(copied + '\n');
56
+ (0, output_js_1.printSuccess)(`copy-text-from ${label} — copied ${copied.length} chars`, opts);
57
+ return 0;
58
+ }
59
+ else {
60
+ (0, output_js_1.printError)(`copy-text-from ${label} — failed\n${result.stderr}`, opts);
61
+ return 1;
62
+ }
63
+ }
@@ -0,0 +1,126 @@
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.recordVideo = recordVideo;
8
+ exports.HELP = ` record-video start [--out <path>] Start a screen VIDEO recording (distinct from \`flow record\`)
9
+ record-video stop Stop recording and write the video file
10
+ iOS: HEVC .mov via simctl · Android: .mp4 via screenrecord`;
11
+ const fs_1 = __importDefault(require("fs"));
12
+ const os_1 = __importDefault(require("os"));
13
+ const path_1 = __importDefault(require("path"));
14
+ const child_process_1 = require("child_process");
15
+ const runner_js_1 = require("../runner.js");
16
+ const ios_js_1 = require("../drivers/ios.js");
17
+ const android_js_1 = require("../drivers/android.js");
18
+ const sdk_js_1 = require("../android/sdk.js");
19
+ const output_js_1 = require("../output.js");
20
+ const RECORDINGS_DIR = path_1.default.join(os_1.default.homedir(), '.conductor', 'recordings');
21
+ const ANDROID_REMOTE = '/sdcard/conductor_recording.mp4';
22
+ function stateFile(sessionName) {
23
+ return path_1.default.join(RECORDINGS_DIR, `${sessionName}.json`);
24
+ }
25
+ async function recordVideo(sub, opts, sessionName, flags = {}) {
26
+ if (sub === 'start')
27
+ return start(opts, sessionName, flags);
28
+ if (sub === 'stop')
29
+ return stop(opts, sessionName);
30
+ (0, output_js_1.printError)('record-video expects "start" or "stop"', opts);
31
+ return 1;
32
+ }
33
+ async function start(opts, sessionName, flags) {
34
+ const existing = stateFile(sessionName);
35
+ if (fs_1.default.existsSync(existing)) {
36
+ (0, output_js_1.printError)(`record-video start — a recording is already active for this session (run \`record-video stop\`)`, opts);
37
+ return 1;
38
+ }
39
+ let state = null;
40
+ const result = await (0, runner_js_1.runDirect)(async (driver) => {
41
+ if (driver instanceof ios_js_1.IOSDriver) {
42
+ const outPath = path_1.default.resolve(process.cwd(), flags.out ?? 'conductor-recording.mov');
43
+ const proc = (0, child_process_1.spawn)('xcrun', ['simctl', 'io', driver.deviceId ?? 'booted', 'recordVideo', '--codec', 'hevc', outPath], { detached: true, stdio: 'ignore' });
44
+ proc.unref();
45
+ state = { pid: proc.pid, platform: 'ios', outPath };
46
+ }
47
+ else if (driver instanceof android_js_1.AndroidDriver) {
48
+ const outPath = path_1.default.resolve(process.cwd(), flags.out ?? 'conductor-recording.mp4');
49
+ const proc = (0, child_process_1.spawn)((0, sdk_js_1.resolveAndroidTool)('adb'), ['-s', driver.serial, 'shell', 'screenrecord', ANDROID_REMOTE], { detached: true, stdio: 'ignore', env: (0, sdk_js_1.androidSpawnEnv)() });
50
+ proc.unref();
51
+ state = { pid: proc.pid, platform: 'android', outPath, serial: driver.serial };
52
+ }
53
+ else {
54
+ throw new Error('record-video is only supported on iOS and Android');
55
+ }
56
+ }, sessionName);
57
+ if (!result.success || !state) {
58
+ (0, output_js_1.printError)(`record-video start — failed\n${result.stderr}`, opts);
59
+ return 1;
60
+ }
61
+ fs_1.default.mkdirSync(RECORDINGS_DIR, { recursive: true });
62
+ fs_1.default.writeFileSync(stateFile(sessionName), JSON.stringify(state));
63
+ const outPath = state.outPath;
64
+ if (opts.json)
65
+ (0, output_js_1.printData)({ recording: true, outPath }, opts);
66
+ else
67
+ (0, output_js_1.printSuccess)(`record-video start — recording to ${outPath}`, opts);
68
+ return 0;
69
+ }
70
+ async function stop(opts, sessionName) {
71
+ const file = stateFile(sessionName);
72
+ if (!fs_1.default.existsSync(file)) {
73
+ (0, output_js_1.printError)('record-video stop — no active recording for this session', opts);
74
+ return 1;
75
+ }
76
+ const state = JSON.parse(fs_1.default.readFileSync(file, 'utf-8'));
77
+ try {
78
+ if (state.platform === 'ios') {
79
+ // SIGINT lets simctl flush and finalize the .mov it is writing directly to outPath.
80
+ try {
81
+ process.kill(state.pid, 'SIGINT');
82
+ }
83
+ catch {
84
+ /* already gone */
85
+ }
86
+ await sleep(800);
87
+ }
88
+ else {
89
+ const adb = (0, sdk_js_1.resolveAndroidTool)('adb');
90
+ const env = (0, sdk_js_1.androidSpawnEnv)();
91
+ // screenrecord runs on-device; interrupt it so it finalizes the mp4, then pull it.
92
+ await run(adb, ['-s', state.serial, 'shell', 'pkill', '-SIGINT', 'screenrecord'], env);
93
+ await sleep(2000); // allow the file to flush on device
94
+ try {
95
+ process.kill(state.pid, 'SIGINT');
96
+ }
97
+ catch {
98
+ /* local adb shell may have exited */
99
+ }
100
+ await run(adb, ['-s', state.serial, 'pull', ANDROID_REMOTE, state.outPath], env);
101
+ await run(adb, ['-s', state.serial, 'shell', 'rm', '-f', ANDROID_REMOTE], env);
102
+ }
103
+ }
104
+ finally {
105
+ fs_1.default.rmSync(file, { force: true });
106
+ }
107
+ if (!fs_1.default.existsSync(state.outPath)) {
108
+ (0, output_js_1.printError)(`record-video stop — recording stopped but no file at ${state.outPath}`, opts);
109
+ return 1;
110
+ }
111
+ if (opts.json)
112
+ (0, output_js_1.printData)({ recording: false, outPath: state.outPath }, opts);
113
+ else
114
+ (0, output_js_1.printSuccess)(`record-video stop — saved ${state.outPath}`, opts);
115
+ return 0;
116
+ }
117
+ function sleep(ms) {
118
+ return new Promise((r) => setTimeout(r, ms));
119
+ }
120
+ function run(cmd, args, env) {
121
+ return new Promise((resolve) => {
122
+ const p = (0, child_process_1.spawn)(cmd, args, { stdio: 'ignore', env });
123
+ p.on('close', () => resolve());
124
+ p.on('error', () => resolve());
125
+ });
126
+ }
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP = void 0;
4
+ exports.setPermissions = setPermissions;
5
+ exports.HELP = ` set-permissions <perm=value>... Grant/deny app permissions (e.g. camera=allow photos=deny)
6
+ [<appId>] Target app (defaults to the active session's app)
7
+ perm=value Repeatable. value is allow|deny|unset; perm may be "all"
8
+ (e.g. all=allow camera=deny)`;
9
+ const runner_js_1 = require("../runner.js");
10
+ const session_js_1 = require("../session.js");
11
+ const output_js_1 = require("../output.js");
12
+ async function setPermissions(args, opts = {}, sessionName = 'default') {
13
+ // Split positional args into an optional appId (no '=') and perm=value pairs.
14
+ const pairs = [];
15
+ let appIdArg;
16
+ for (const a of args) {
17
+ if (a.includes('='))
18
+ pairs.push(a);
19
+ else if (appIdArg === undefined)
20
+ appIdArg = a;
21
+ }
22
+ if (pairs.length === 0) {
23
+ (0, output_js_1.printError)('set-permissions requires at least one perm=value (e.g. camera=allow)', opts);
24
+ return 1;
25
+ }
26
+ const permissions = {};
27
+ for (const p of pairs) {
28
+ const idx = p.indexOf('=');
29
+ const key = p.slice(0, idx).trim();
30
+ const value = p.slice(idx + 1).trim();
31
+ if (key)
32
+ permissions[key] = value;
33
+ }
34
+ const session = await (0, session_js_1.getSession)(sessionName);
35
+ const appId = appIdArg ?? session.appId;
36
+ if (!appId) {
37
+ (0, output_js_1.printError)('set-permissions: no appId provided and no active session. Run launch-app first.', opts);
38
+ return 1;
39
+ }
40
+ const summary = Object.entries(permissions)
41
+ .map(([k, v]) => `${k}=${v}`)
42
+ .join(' ');
43
+ const result = await (0, runner_js_1.runDirect)(async (driver) => {
44
+ await driver.setPermissions(appId, permissions);
45
+ }, sessionName);
46
+ if (result.success) {
47
+ (0, output_js_1.printSuccess)(`set-permissions "${appId}" ${summary} — done`, opts);
48
+ return 0;
49
+ }
50
+ else {
51
+ (0, output_js_1.printError)(`set-permissions "${appId}" ${summary} — failed\n${result.stderr}`, opts);
52
+ return 1;
53
+ }
54
+ }
@@ -2,12 +2,17 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HELP = void 0;
4
4
  exports.tap = tap;
5
- exports.HELP = ` tap-on <element> Tap element by text, id, or @eN snapshot ref
5
+ exports.HELP = ` tap-on [<element>] Tap element by text, id, @eN snapshot ref, or coordinate
6
+ --at <x,y> Tap a raw coordinate instead of an element.
7
+ Accepts px ("100,200"), percentages ("50%,50%"),
8
+ or 0-1 fractions ("0.5,0.5"). Skips element matching.
6
9
  --id <id> Match by accessibility id instead of text
7
10
  --text <text> Match by text only (not id)
8
11
  --index <n> Pick the nth match (0-based)
9
12
  --long-press Hold instead of tap
10
13
  --double-tap Double-tap the element
14
+ --repeat <n> Tap n times (default 1)
15
+ --delay <ms> Delay between repeated taps (default 100)
11
16
  --optional Do not fail if element is not found
12
17
  --focused Match only focused elements
13
18
  --enabled / --no-enabled Match by enabled state
@@ -26,12 +31,15 @@ const vega_js_1 = require("../drivers/vega.js");
26
31
  const wait_js_1 = require("../drivers/wait.js");
27
32
  const direct_ios_selector_js_1 = require("../drivers/direct-ios-selector.js");
28
33
  const snapshot_store_js_1 = require("../snapshot-store.js");
34
+ const flow_runner_js_1 = require("../drivers/flow-runner.js");
29
35
  const utils_js_1 = require("../utils.js");
30
36
  async function tap(query, opts = {}, sessionName = 'default', flags = {}) {
31
- if (!query && !flags.id && !flags.text) {
32
- (0, output_js_1.printError)('tap-on requires <element> or --id <id>', opts);
37
+ if (!query && !flags.id && !flags.text && !flags.at) {
38
+ (0, output_js_1.printError)('tap-on requires <element>, --id <id>, or --at <x,y>', opts);
33
39
  return 1;
34
40
  }
41
+ const repeat = flags.repeat && flags.repeat > 0 ? flags.repeat : 1;
42
+ const delay = flags.delay ?? 100;
35
43
  const sel = {
36
44
  ...(flags.text ? { text: flags.text } : flags.id ? { id: flags.id } : { query }),
37
45
  ...(flags.index !== undefined && { index: flags.index }),
@@ -47,14 +55,25 @@ async function tap(query, opts = {}, sessionName = 'default', flags = {}) {
47
55
  // A bare `@eN` query taps the cached coordinates from the last `capture-ui`
48
56
  // snapshot, skipping fuzzy text/id resolution. Explicit --text/--id win.
49
57
  const useRef = (0, snapshot_store_js_1.isRefQuery)(query) && !flags.text && !flags.id;
50
- const label = flags.text ? `text="${flags.text}"` : flags.id ? `id="${flags.id}"` : `"${query}"`;
58
+ const label = flags.at
59
+ ? `@${flags.at}`
60
+ : flags.text
61
+ ? `text="${flags.text}"`
62
+ : flags.id
63
+ ? `id="${flags.id}"`
64
+ : `"${query}"`;
51
65
  const result = await (0, runner_js_1.runDirect)(async (driver) => {
52
66
  if (driver instanceof ios_js_1.IOSDriver && driver.platform === 'tvos') {
53
67
  throw new Error('tap-on is not supported on tvOS — Apple TV uses focus-based navigation.\n' +
54
68
  'Use press-key to navigate (e.g. conductor press-key "Remote Dpad Center").');
55
69
  }
56
70
  let el;
57
- if (useRef) {
71
+ if (flags.at) {
72
+ // Coordinate tap: skip element resolution entirely.
73
+ const { x, y } = await (0, flow_runner_js_1.resolvePoint)(flags.at, driver);
74
+ el = { centerX: x, centerY: y };
75
+ }
76
+ else if (useRef) {
58
77
  const { entry, staleReason } = (0, snapshot_store_js_1.resolveRef)(await (0, snapshot_store_js_1.loadSnapshot)(sessionName), query, {
59
78
  deviceId: sessionName,
60
79
  });
@@ -79,23 +98,27 @@ async function tap(query, opts = {}, sessionName = 'default', flags = {}) {
79
98
  else {
80
99
  return;
81
100
  }
82
- if (flags.longPress) {
83
- if (driver instanceof android_js_1.AndroidDriver) {
84
- await driver.swipe(el.centerX, el.centerY, el.centerX, el.centerY, 1500);
101
+ for (let i = 0; i < repeat; i++) {
102
+ if (i > 0)
103
+ await (0, utils_js_1.sleep)(delay);
104
+ if (flags.longPress) {
105
+ if (driver instanceof android_js_1.AndroidDriver) {
106
+ await driver.swipe(el.centerX, el.centerY, el.centerX, el.centerY, 1500);
107
+ }
108
+ else {
109
+ // iOS, web, and vega express a long press as a held tap.
110
+ await driver.tap(el.centerX, el.centerY, 1.5);
111
+ }
112
+ }
113
+ else if (flags.doubleTap) {
114
+ await driver.tap(el.centerX, el.centerY);
115
+ await (0, utils_js_1.sleep)(100);
116
+ await driver.tap(el.centerX, el.centerY);
85
117
  }
86
118
  else {
87
- // iOS, web, and vega express a long press as a held tap.
88
- await driver.tap(el.centerX, el.centerY, 1.5);
119
+ await driver.tap(el.centerX, el.centerY);
89
120
  }
90
121
  }
91
- else if (flags.doubleTap) {
92
- await driver.tap(el.centerX, el.centerY);
93
- await (0, utils_js_1.sleep)(100);
94
- await driver.tap(el.centerX, el.centerY);
95
- }
96
- else {
97
- await driver.tap(el.centerX, el.centerY);
98
- }
99
122
  }, sessionName);
100
123
  const verb = flags.longPress ? 'long-press' : flags.doubleTap ? 'double-tap' : 'tap';
101
124
  if (result.success) {
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HELP = void 0;
4
+ exports.travel = travel;
5
+ exports.HELP = ` travel <lat,lng>... Move the device GPS through a series of coordinates
6
+ --speed <m/s> Walk between points at this speed (adds realistic delays)`;
7
+ const runner_js_1 = require("../runner.js");
8
+ const output_js_1 = require("../output.js");
9
+ const utils_js_1 = require("../utils.js");
10
+ const EARTH_RADIUS = 6371000; // meters
11
+ function haversine(a, b) {
12
+ const dLat = ((b.lat - a.lat) * Math.PI) / 180;
13
+ const dLon = ((b.lng - a.lng) * Math.PI) / 180;
14
+ const h = Math.sin(dLat / 2) ** 2 +
15
+ Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLon / 2) ** 2;
16
+ return 2 * EARTH_RADIUS * Math.asin(Math.sqrt(h));
17
+ }
18
+ async function travel(coords, opts = {}, sessionName = 'default', flags = {}) {
19
+ const points = coords.map((c) => {
20
+ const [lat, lng] = c.split(',').map((s) => Number(s.trim()));
21
+ return { lat, lng };
22
+ });
23
+ if (points.length === 0 || points.some((p) => Number.isNaN(p.lat) || Number.isNaN(p.lng))) {
24
+ (0, output_js_1.printError)('travel requires one or more "lat,lng" coordinates', opts);
25
+ return 1;
26
+ }
27
+ const speed = flags.speed;
28
+ const result = await (0, runner_js_1.runDirect)(async (driver) => {
29
+ for (let i = 0; i < points.length; i++) {
30
+ await driver.setLocation(points[i].lat, points[i].lng);
31
+ if (i < points.length - 1 && speed && speed > 0) {
32
+ const distM = haversine(points[i], points[i + 1]);
33
+ await (0, utils_js_1.sleep)(Math.min(Math.round((distM / speed) * 1000), 10000)); // cap 10s/step
34
+ }
35
+ }
36
+ }, sessionName);
37
+ if (result.success) {
38
+ (0, output_js_1.printSuccess)(`travel — visited ${points.length} point(s)`, opts);
39
+ return 0;
40
+ }
41
+ else {
42
+ (0, output_js_1.printError)(`travel — failed\n${result.stderr}`, opts);
43
+ return 1;
44
+ }
45
+ }
@@ -75,6 +75,10 @@ class AndroidDriver {
75
75
  this._recordingProcess = null;
76
76
  this._recordingOutputPath = '';
77
77
  }
78
+ /** adb serial of the target device (used by out-of-process helpers like record-video). */
79
+ get serial() {
80
+ return this.deviceId;
81
+ }
78
82
  async connect() {
79
83
  const packageDef = loadPackageDef();
80
84
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -6,11 +6,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.parseFlowFile = parseFlowFile;
7
7
  exports.parseFlowString = parseFlowString;
8
8
  exports.executeFlow = executeFlow;
9
+ exports.resolvePoint = resolvePoint;
10
+ exports.resolvePath = resolvePath;
9
11
  /**
10
12
  * Native Conductor YAML flow parser and executor.
11
13
  * Parses flow YAML files and executes commands directly using IOSDriver / AndroidDriver.
12
14
  */
13
15
  const promises_1 = __importDefault(require("fs/promises"));
16
+ const node_fs_1 = require("node:fs");
14
17
  const path_1 = __importDefault(require("path"));
15
18
  const node_vm_1 = __importDefault(require("node:vm"));
16
19
  const js_yaml_1 = __importDefault(require("js-yaml"));
@@ -1367,8 +1370,72 @@ async function executeCommandBody(key, val, driver, opts) {
1367
1370
  }
1368
1371
  }
1369
1372
  // ── Utilities ─────────────────────────────────────────────────────────────────
1373
+ // Resolve a `file:` reference from within a flow. A path starting with `@` is an
1374
+ // alias of the form `@name/rest`, where `name` maps to a directory declared under
1375
+ // `paths:` in the nearest `config.yaml`. Everything else keeps the historical
1376
+ // behavior: resolved relative to the flow file's directory (`cwd`).
1377
+ // Mirrors the plexinc/maestro FlowPathResolver.
1370
1378
  function resolvePath(filePath, cwd) {
1371
- return path_1.default.isAbsolute(filePath) ? filePath : path_1.default.join(cwd ?? process.cwd(), filePath);
1379
+ const base = cwd ?? process.cwd();
1380
+ if (filePath.startsWith('@'))
1381
+ return resolvePathAlias(filePath, base);
1382
+ return path_1.default.isAbsolute(filePath) ? filePath : path_1.default.join(base, filePath);
1383
+ }
1384
+ const CONFIG_FILE_NAMES = ['config.yaml', 'config.yml'];
1385
+ // Cache discovered config → paths map for the process lifetime (flows are short-lived).
1386
+ const configPathsCache = new Map();
1387
+ function resolvePathAlias(requestedPath, startDir) {
1388
+ const body = requestedPath.slice(1);
1389
+ const separator = body.indexOf('/');
1390
+ const alias = separator >= 0 ? body.slice(0, separator) : body;
1391
+ const remainder = separator >= 0 ? body.slice(separator + 1) : '';
1392
+ const configPath = findWorkspaceConfig(startDir);
1393
+ if (!configPath) {
1394
+ throw new Error(`Path alias '@${alias}' used but no config.yaml was found in any parent directory. ` +
1395
+ 'Declare aliases under `paths:` in a workspace config.yaml.');
1396
+ }
1397
+ const paths = readWorkspacePaths(configPath);
1398
+ const target = paths[alias];
1399
+ if (target === undefined) {
1400
+ const known = Object.keys(paths).sort();
1401
+ throw new Error(`Unknown path alias '@${alias}' referenced in a flow. ` +
1402
+ `Known aliases in ${configPath}: [${known.join(', ')}]`);
1403
+ }
1404
+ const configDir = path_1.default.dirname(path_1.default.resolve(configPath));
1405
+ const targetDir = path_1.default.normalize(path_1.default.resolve(configDir, target));
1406
+ if (!(0, node_fs_1.existsSync)(targetDir) || !(0, node_fs_1.statSync)(targetDir).isDirectory()) {
1407
+ throw new Error(`Path alias '@${alias}' points to '${targetDir}', which is not an existing directory.`);
1408
+ }
1409
+ return path_1.default.normalize(path_1.default.resolve(targetDir, remainder));
1410
+ }
1411
+ function findWorkspaceConfig(startDir) {
1412
+ let dir = path_1.default.resolve(startDir);
1413
+ while (dir) {
1414
+ for (const name of CONFIG_FILE_NAMES) {
1415
+ const candidate = path_1.default.join(dir, name);
1416
+ if ((0, node_fs_1.existsSync)(candidate))
1417
+ return candidate;
1418
+ }
1419
+ const parent = path_1.default.dirname(dir);
1420
+ dir = parent === dir ? null : parent;
1421
+ }
1422
+ return null;
1423
+ }
1424
+ function readWorkspacePaths(configPath) {
1425
+ const cached = configPathsCache.get(configPath);
1426
+ if (cached)
1427
+ return cached;
1428
+ let paths = {};
1429
+ try {
1430
+ const doc = js_yaml_1.default.load((0, node_fs_1.readFileSync)(configPath, 'utf-8'));
1431
+ if (doc && typeof doc.paths === 'object' && doc.paths)
1432
+ paths = doc.paths;
1433
+ }
1434
+ catch {
1435
+ // A malformed config leaves aliases unresolved; the unknown-alias error below is clearer.
1436
+ }
1437
+ configPathsCache.set(configPath, paths);
1438
+ return paths;
1372
1439
  }
1373
1440
  function resolveAppId(val, cmdName) {
1374
1441
  if (typeof val === 'string' && val)
package/dist/index.js CHANGED
@@ -66,6 +66,14 @@ const metro_js_1 = require("./commands/metro.js");
66
66
  const clipboard_js_1 = require("./commands/clipboard.js");
67
67
  const options_js_1 = require("./commands/options.js");
68
68
  const web_targets_js_1 = require("./commands/web-targets.js");
69
+ const copy_text_from_js_1 = require("./commands/copy-text-from.js");
70
+ const assert_true_js_1 = require("./commands/assert-true.js");
71
+ const set_permissions_js_1 = require("./commands/set-permissions.js");
72
+ const add_media_js_1 = require("./commands/add-media.js");
73
+ const airplane_mode_js_1 = require("./commands/airplane-mode.js");
74
+ const travel_js_1 = require("./commands/travel.js");
75
+ const record_video_js_1 = require("./commands/record-video.js");
76
+ const assert_screenshot_js_1 = require("./commands/assert-screenshot.js");
69
77
  const session_js_2 = require("./session.js");
70
78
  const device_picker_js_1 = require("./device-picker.js");
71
79
  const cdp_discovery_js_1 = require("./drivers/cdp-discovery.js");
@@ -109,6 +117,7 @@ const COMMAND_HELP = {
109
117
  'clear-state': clear_state_js_1.HELP,
110
118
  'uninstall-app': uninstall_app_js_1.HELP,
111
119
  'tap-on': tap_js_1.HELP,
120
+ 'copy-text-from': copy_text_from_js_1.HELP,
112
121
  'input-text': type_js_1.HELP,
113
122
  'erase-text': erase_text_js_1.HELP,
114
123
  back: back_js_1.HELP,
@@ -119,8 +128,15 @@ const COMMAND_HELP = {
119
128
  'scroll-until-visible': scroll_until_visible_js_1.HELP,
120
129
  'assert-visible': assert_visible_js_1.HELP,
121
130
  'assert-not-visible': assert_not_visible_js_1.HELP,
131
+ 'assert-true': assert_true_js_1.HELP,
132
+ 'assert-screenshot': assert_screenshot_js_1.HELP,
122
133
  'open-link': open_link_js_1.HELP,
123
134
  'set-location': set_location_js_1.HELP,
135
+ 'set-permissions': set_permissions_js_1.HELP,
136
+ 'add-media': add_media_js_1.HELP,
137
+ 'set-airplane-mode': airplane_mode_js_1.HELP,
138
+ travel: travel_js_1.HELP,
139
+ 'record-video': record_video_js_1.HELP,
124
140
  'set-orientation': set_orientation_js_1.HELP,
125
141
  'set-viewport': set_viewport_js_1.HELP,
126
142
  'take-screenshot': screenshot_js_1.HELP,
@@ -206,6 +222,7 @@ async function main() {
206
222
  'global',
207
223
  'force',
208
224
  'yes',
225
+ 'update',
209
226
  ],
210
227
  string: [
211
228
  'device',
@@ -270,6 +287,11 @@ async function main() {
270
287
  'react-tag',
271
288
  'path',
272
289
  'value',
290
+ 'repeat',
291
+ 'delay',
292
+ 'speed',
293
+ 'threshold',
294
+ 'reference',
273
295
  ],
274
296
  alias: { h: 'help', v: 'verbose', V: 'version', o: 'output', y: 'yes' },
275
297
  });
@@ -315,6 +337,8 @@ async function main() {
315
337
  'workspace',
316
338
  'list-options',
317
339
  'web-targets',
340
+ // assert-true evaluates a pure JS expression in the flow sandbox — no device involved.
341
+ 'assert-true',
318
342
  // `logs --list` and `logs --source metro` only query Metro on localhost — no device needed
319
343
  // `logs` always needs a device session — Metro discovery is device-scoped.
320
344
  // `daemon-stop --all` stops every daemon — no device needed
@@ -587,6 +611,9 @@ async function main() {
587
611
  exitCode = await (0, tap_js_1.tap)(element, opts, sessionName, {
588
612
  id: argv['id'],
589
613
  text: argv['text'],
614
+ at: argv['at'],
615
+ repeat: argv['repeat'] !== undefined ? Number(argv['repeat']) : undefined,
616
+ delay: argv['delay'] !== undefined ? Number(argv['delay']) : undefined,
590
617
  index: argv['index'] !== undefined ? Number(argv['index']) : undefined,
591
618
  longPress: argv['long-press'],
592
619
  doubleTap: argv['double-tap'],
@@ -702,6 +729,35 @@ async function main() {
702
729
  });
703
730
  break;
704
731
  }
732
+ case 'assert-true': {
733
+ const expr = rest.join(' ');
734
+ const env = {};
735
+ const envArgs = [].concat(argv['env'] ?? []);
736
+ for (const e of envArgs) {
737
+ const idx = e.indexOf('=');
738
+ if (idx > 0)
739
+ env[e.slice(0, idx)] = e.slice(idx + 1);
740
+ }
741
+ exitCode = await (0, assert_true_js_1.assertTrue)(expr, opts, env);
742
+ break;
743
+ }
744
+ case 'assert-screenshot': {
745
+ const reference = rest[0] ?? argv['reference'] ?? '';
746
+ exitCode = await (0, assert_screenshot_js_1.assertScreenshot)(reference, opts, sessionName, {
747
+ threshold: argv['threshold'] !== undefined ? Number(argv['threshold']) : undefined,
748
+ update: argv['update'],
749
+ });
750
+ break;
751
+ }
752
+ case 'copy-text-from': {
753
+ const element = rest.join(' ');
754
+ exitCode = await (0, copy_text_from_js_1.copyTextFrom)(element, opts, sessionName, {
755
+ id: argv['id'],
756
+ text: argv['text'],
757
+ index: argv['index'] !== undefined ? Number(argv['index']) : undefined,
758
+ });
759
+ break;
760
+ }
705
761
  case 'open-link': {
706
762
  const url = rest[0] ?? argv['url'] ?? '';
707
763
  exitCode = await (0, open_link_js_1.openLink)(url, opts, sessionName);
@@ -719,6 +775,35 @@ async function main() {
719
775
  }
720
776
  break;
721
777
  }
778
+ case 'set-permissions': {
779
+ exitCode = await (0, set_permissions_js_1.setPermissions)(rest.map(String), opts, sessionName);
780
+ break;
781
+ }
782
+ case 'add-media': {
783
+ exitCode = await (0, add_media_js_1.addMedia)(rest.map(String), opts, sessionName);
784
+ break;
785
+ }
786
+ case 'set-airplane-mode': {
787
+ const value = rest[0] ?? '';
788
+ exitCode = await (0, airplane_mode_js_1.setAirplaneMode)(value, opts, sessionName);
789
+ break;
790
+ }
791
+ case 'toggle-airplane-mode': {
792
+ exitCode = await (0, airplane_mode_js_1.toggleAirplaneMode)(opts, sessionName);
793
+ break;
794
+ }
795
+ case 'travel': {
796
+ exitCode = await (0, travel_js_1.travel)(rest.map(String), opts, sessionName, {
797
+ speed: argv['speed'] !== undefined ? Number(argv['speed']) : undefined,
798
+ });
799
+ break;
800
+ }
801
+ case 'record-video': {
802
+ exitCode = await (0, record_video_js_1.recordVideo)(rest[0] ?? '', opts, sessionName, {
803
+ out: argv['out'],
804
+ });
805
+ break;
806
+ }
722
807
  case 'set-orientation': {
723
808
  const orientation = (rest[0] ??
724
809
  argv['orientation'] ??
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@houwert/conductor",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "CLI tool for mobile app interactions — optimized for AI agents",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -33,13 +33,16 @@
33
33
  "@grpc/proto-loader": "^0.7.15",
34
34
  "js-yaml": "^4.1.1",
35
35
  "minimist": "^1.2.8",
36
+ "pixelmatch": "^7.2.0",
36
37
  "playwright-core": "^1.52.0",
38
+ "pngjs": "^7.0.0",
37
39
  "ws": "^8.20.0"
38
40
  },
39
41
  "devDependencies": {
40
42
  "@types/js-yaml": "^4.0.9",
41
43
  "@types/minimist": "^1.2.5",
42
44
  "@types/node": "^20.0.0",
45
+ "@types/pngjs": "^6.0.5",
43
46
  "@types/ws": "^8.18.1",
44
47
  "@typescript-eslint/eslint-plugin": "^8.56.1",
45
48
  "@typescript-eslint/parser": "^8.56.1",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: conductor-device-interact
3
- description: Drive a running iOS simulator, Android emulator, tvOS simulator, Vega (Amazon Fire TV) virtual device, or Playwright web app with the conductor CLI. Use when launching apps, tapping UI elements, typing text, scrolling/swiping, performing gestures, pressing hardware/keyboard/remote keys, opening URLs or deep links, navigating back, or verifying an app change in the real running app.
3
+ description: Drive a running iOS simulator, Android emulator, tvOS simulator, Vega (Amazon Fire TV) virtual device, or Playwright web app with the conductor CLI. Use when launching apps, tapping UI elements (by selector or raw coordinate), typing text, scrolling/swiping, performing gestures, pressing hardware/keyboard/remote keys, opening URLs or deep links, navigating back, granting/denying app permissions, adding media to the gallery, setting GPS location or a travel route, toggling airplane mode, recording a screen video, or verifying an app change in the real running app.
4
4
  ---
5
5
 
6
6
  # Conductor — device interaction
@@ -36,7 +36,9 @@ conductor assert-visible "Dashboard"
36
36
  |---|---|
37
37
  | `conductor launch-app <appId>` | Launch app (saved to session). `--no-stop-app` resumes; `--argument key=value` passes launch args |
38
38
  | `conductor stop-app [<appId>]` | Stop the app |
39
- | `conductor tap-on <element>` | Tap by text, id, or `@eN`. `--long-press`, `--double-tap`, `--optional`, `--index <n>` |
39
+ | `conductor tap-on <element>` | Tap by text, id, or `@eN`. `--long-press`, `--double-tap`, `--optional`, `--index <n>`, `--repeat <n> --delay <ms>` |
40
+ | `conductor tap-on --at <x,y>` | Tap a raw coordinate (px `100,200`, percent `50%,50%`, or `0-1` fraction) — no element match |
41
+ | `conductor copy-text-from <element>` | Print an element's text (and copy to the iOS clipboard) |
40
42
  | `conductor input-text <text>` | Type into the focused field |
41
43
  | `conductor erase-text [n]` | Erase n characters (default 50) |
42
44
  | `conductor press-key <key>` | Press a key (Enter, Backspace, Home, …) or a remote button (`Remote Dpad Up/Down/Left/Right/Center`, `Remote Menu`) for tvOS / Android TV / vega. `--long-press` / `--duration <seconds>` holds it |
@@ -49,6 +51,11 @@ conductor assert-visible "Dashboard"
49
51
  | `conductor pinch [--scale N] [--center x,y]` | Two-finger pinch (scale<1 out, >1 in) |
50
52
  | `conductor rotate-gesture [--degrees N] [--center x,y]` | Two-finger rotate |
51
53
  | `conductor gesture <json\|--file path>` | Play a multi-touch path |
54
+ | `conductor set-permissions <perm=value>...` | Grant/deny app permissions (`camera=allow photos=deny`, `all=allow`) |
55
+ | `conductor add-media <path>...` | Push image/video files into the device gallery |
56
+ | `conductor set-airplane-mode <on\|off>` / `toggle-airplane-mode` | Airplane mode (Android only) |
57
+ | `conductor travel <lat,lng>... [--speed <m/s>]` | Move GPS through a route of coordinates |
58
+ | `conductor record-video start [--out <path>]` / `record-video stop` | Record a screen **video** (iOS/Android). Distinct from `flow record` (which records a YAML flow) |
52
59
  | `conductor clipboard read` / `clipboard write <text>` / `paste` | Clipboard (iOS) |
53
60
  | `conductor list-options [command]` | List valid values for enumerated params |
54
61
  | `conductor input-server` | Start (if needed) and print the streaming-input WebSocket URL for the device |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: conductor-inspect
3
- description: Read the live UI state of a running app with the conductor CLI — view hierarchy, accessibility snapshot, screenshots, focused element, and element refs. Use when you need to see what's on screen, find an element's id/text/coordinates, take a screenshot, check focus, or assert that something is (or isn't) visible before or after acting.
3
+ description: Read the live UI state of a running app with the conductor CLI — view hierarchy, accessibility snapshot, screenshots, focused element, and element refs. Use when you need to see what's on screen, find an element's id/text/coordinates, take a screenshot, check focus, assert that something is (or isn't) visible, assert a JavaScript expression, or do visual-regression screenshot comparison before or after acting.
4
4
  ---
5
5
 
6
6
  # Conductor — inspection & assertions
@@ -78,6 +78,8 @@ conductor native-image 816,286,288,288 --output /tmp/avatar.png
78
78
  |---|---|
79
79
  | `conductor assert-visible <element> [--timeout ms]` | Assert element is visible (non-zero exit on failure) |
80
80
  | `conductor assert-not-visible <element> [--timeout ms]` | Assert element is absent |
81
+ | `conductor assert-true <expr> [--env K=V]` | Assert a JavaScript expression is truthy (no device needed) |
82
+ | `conductor assert-screenshot <reference.png> [--threshold <0-1>] [--update]` | Visual regression vs a baseline image; `--update` (re)writes the baseline. Writes `<ref>.diff.png` on mismatch |
81
83
 
82
84
  Both take the same selectors as `tap-on`: `--id`, `--text`, `--index`,
83
85
  `--below` / `--above` / `--left-of` / `--right-of`, `--focused`, `--enabled`,