@houwert/conductor 0.25.0 → 0.27.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/add-media.js +36 -0
- package/dist/commands/airplane-mode.js +46 -0
- package/dist/commands/assert-screenshot.js +68 -0
- package/dist/commands/assert-true.js +30 -0
- package/dist/commands/copy-text-from.js +63 -0
- package/dist/commands/record-video.js +126 -0
- package/dist/commands/set-permissions.js +54 -0
- package/dist/commands/stream-server.js +28 -0
- package/dist/commands/tap.js +41 -18
- package/dist/commands/travel.js +45 -0
- package/dist/daemon/h264-annexb.js +264 -0
- package/dist/daemon/server.js +47 -0
- package/dist/daemon/video-hub.js +58 -0
- package/dist/daemon/video-protocol.js +38 -0
- package/dist/daemon/video-server.js +88 -0
- package/dist/daemon/video-source.js +142 -0
- package/dist/drivers/android.js +4 -0
- package/dist/drivers/bootstrap.js +38 -0
- package/dist/drivers/flow-runner.js +1 -0
- package/dist/index.js +90 -0
- package/dist/runner.js +45 -0
- package/package.json +4 -1
- package/skills/conductor-device-interact/SKILL.md +24 -2
- package/skills/conductor-inspect/SKILL.md +3 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HELP = void 0;
|
|
4
|
+
exports.streamServer = streamServer;
|
|
5
|
+
exports.HELP = ` stream-server Start (if needed) and print the live video-stream WebSocket for the device`;
|
|
6
|
+
const runner_js_1 = require("../runner.js");
|
|
7
|
+
const output_js_1 = require("../output.js");
|
|
8
|
+
/**
|
|
9
|
+
* Ensure the device daemon + streaming-video socket are up and print the
|
|
10
|
+
* loopback WebSocket URL a viewer subscribes to for the live H.264 stream.
|
|
11
|
+
* See docs/device-video-stream.md.
|
|
12
|
+
*/
|
|
13
|
+
async function streamServer(opts = {}, sessionName = 'default') {
|
|
14
|
+
try {
|
|
15
|
+
const info = await (0, runner_js_1.streamServerInfo)(sessionName);
|
|
16
|
+
if (opts.json) {
|
|
17
|
+
(0, output_js_1.printData)(info, opts);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
console.log(`stream server [${info.device}] (${info.platform}, ${info.codec}): ${info.url}`);
|
|
21
|
+
}
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
(0, output_js_1.printError)(`stream-server — ${err instanceof Error ? err.message : String(err)}`, opts);
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
package/dist/commands/tap.js
CHANGED
|
@@ -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>
|
|
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
|
|
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.
|
|
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 (
|
|
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
|
-
|
|
83
|
-
if (
|
|
84
|
-
await
|
|
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
|
-
|
|
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
|
+
}
|