@houwert/conductor 0.17.0 → 0.19.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/capture-ui.js +4 -0
- package/dist/commands/clear-state.js +7 -1
- package/dist/commands/launch-app.js +12 -2
- package/dist/commands/screenshot.js +89 -5
- package/dist/commands/set-viewport.js +73 -0
- package/dist/commands/tap.js +15 -2
- package/dist/daemon/server.js +4 -3
- package/dist/drivers/a11y.js +3 -0
- package/dist/drivers/bootstrap.js +42 -18
- package/dist/drivers/direct-ios-selector.js +1 -0
- package/dist/drivers/element-resolver.js +3 -0
- package/dist/drivers/flow-recorder.js +4 -4
- package/dist/drivers/log-sources/metro-discovery.js +12 -6
- package/dist/drivers/metro-cdp.js +25 -11
- package/dist/drivers/web.js +3 -0
- package/dist/index.js +42 -1
- package/dist/png-crop.js +191 -0
- package/dist/snapshot-store.js +96 -0
- package/package.json +1 -1
|
@@ -14,6 +14,7 @@ const ios_js_1 = require("../drivers/ios.js");
|
|
|
14
14
|
const android_js_1 = require("../drivers/android.js");
|
|
15
15
|
const web_js_1 = require("../drivers/web.js");
|
|
16
16
|
const a11y_js_1 = require("../drivers/a11y.js");
|
|
17
|
+
const snapshot_store_js_1 = require("../snapshot-store.js");
|
|
17
18
|
async function captureUI(outputPath, opts = {}, sessionName = 'default') {
|
|
18
19
|
try {
|
|
19
20
|
const driver = await (0, runner_js_1.getDriver)(sessionName);
|
|
@@ -87,6 +88,9 @@ async function captureUI(outputPath, opts = {}, sessionName = 'default') {
|
|
|
87
88
|
a11ySnapshot,
|
|
88
89
|
capabilities: { perViewPixels: false, depthData: false },
|
|
89
90
|
};
|
|
91
|
+
// Persist `@eN` refs so `tap-on @e3` can act on this capture without a
|
|
92
|
+
// re-query. Keyed by session — see snapshot-store.ts.
|
|
93
|
+
await (0, snapshot_store_js_1.saveSnapshot)(sessionName, (0, snapshot_store_js_1.buildStoredSnapshot)(a11ySnapshot, { deviceId: sessionName, platform }));
|
|
90
94
|
const json = JSON.stringify(bundle);
|
|
91
95
|
if (outputPath) {
|
|
92
96
|
const resolved = path_1.default.resolve(outputPath);
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.HELP = void 0;
|
|
4
4
|
exports.clearState = clearState;
|
|
5
|
-
exports.HELP = ` clear-state [<appId>]
|
|
5
|
+
exports.HELP = ` clear-state [<appId>] DESTRUCTIVE: wipe app data and signed-in state.
|
|
6
|
+
On iOS this uninstall+reinstalls the app, which also drops
|
|
7
|
+
the app's keychain items — the user will be signed out and
|
|
8
|
+
cannot be recovered without their credentials. Do not use to
|
|
9
|
+
reset focus or navigation state.`;
|
|
6
10
|
const runner_js_1 = require("../runner.js");
|
|
7
11
|
const session_js_1 = require("../session.js");
|
|
8
12
|
const output_js_1 = require("../output.js");
|
|
@@ -13,6 +17,8 @@ async function clearState(appId, opts = {}, sessionName = 'default') {
|
|
|
13
17
|
(0, output_js_1.printError)('clear-state: no appId provided and no active session. Run launch-app first.', opts);
|
|
14
18
|
return 1;
|
|
15
19
|
}
|
|
20
|
+
process.stderr.write('warning: clear-state wipes app data AND signed-in state; the user will be signed out ' +
|
|
21
|
+
'and cannot be recovered without their credentials.\n');
|
|
16
22
|
const result = await (0, runner_js_1.runDirect)(async (driver) => {
|
|
17
23
|
await driver.clearAppState(resolvedAppId);
|
|
18
24
|
}, sessionName);
|
|
@@ -3,8 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.HELP = void 0;
|
|
4
4
|
exports.launchApp = launchApp;
|
|
5
5
|
exports.HELP = ` launch-app <appId> Launch app (saves to session)
|
|
6
|
-
--clear-state
|
|
7
|
-
|
|
6
|
+
--clear-state DESTRUCTIVE: wipe app data AND signed-in state before launching.
|
|
7
|
+
On iOS this uninstall+reinstalls the app, which also drops the
|
|
8
|
+
app's keychain items — the user will be signed out and you
|
|
9
|
+
cannot undo it without their credentials. Do not use to reset
|
|
10
|
+
focus or navigation state; relaunch without this flag instead.
|
|
11
|
+
--clear-keychain DESTRUCTIVE: wipe the device keychain before launching. Signs
|
|
12
|
+
the user out of every app on the simulator. Cannot be undone
|
|
13
|
+
without re-entering credentials.
|
|
8
14
|
--no-stop-app Do not stop the app before launching (resume instead of restart)
|
|
9
15
|
--argument key=value Set launch argument (repeatable)`;
|
|
10
16
|
const runner_js_1 = require("../runner.js");
|
|
@@ -19,6 +25,10 @@ async function launchApp(appId, deviceId, opts = {}, sessionName = 'default', fl
|
|
|
19
25
|
return 1;
|
|
20
26
|
}
|
|
21
27
|
await (0, session_js_1.updateSession)({ appId, ...(deviceId ? { deviceId } : {}) }, sessionName);
|
|
28
|
+
if (flags.clearState || flags.clearKeychain) {
|
|
29
|
+
process.stderr.write('warning: --clear-state / --clear-keychain wipes app data AND signed-in state; ' +
|
|
30
|
+
'the user will be signed out and cannot be recovered without their credentials.\n');
|
|
31
|
+
}
|
|
22
32
|
const result = await (0, runner_js_1.runDirect)(async (driver) => {
|
|
23
33
|
if (flags.clearKeychain)
|
|
24
34
|
await driver.clearKeychain();
|
|
@@ -5,24 +5,108 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.HELP = void 0;
|
|
7
7
|
exports.screenshot = screenshot;
|
|
8
|
-
exports.HELP = ` take-screenshot [--output <path>] [--full-page]
|
|
9
|
-
Take screenshot (--full-page: web only, capture entire scrollable page)
|
|
8
|
+
exports.HELP = ` take-screenshot [<element>] [--output <path>] [--full-page]
|
|
9
|
+
Take screenshot (--full-page: web only, capture entire scrollable page)
|
|
10
|
+
<element> Crop to the element matched by text (positional)
|
|
11
|
+
--id <id> Crop to the element matched by accessibility id
|
|
12
|
+
--text <text> Crop to the element matched by text only (not id)
|
|
13
|
+
--index <n> Pick the nth match (0-based)
|
|
14
|
+
--margin <px> Extra pixels around the crop (default 8) to capture shadows
|
|
15
|
+
--focused Match only focused elements
|
|
16
|
+
--enabled / --no-enabled Match by enabled state
|
|
17
|
+
--checked / --no-checked Match by checked state
|
|
18
|
+
--selected / --no-selected Match by selected state
|
|
19
|
+
--below <text> Match element below the given reference
|
|
20
|
+
--above <text> Match element above the given reference
|
|
21
|
+
--left-of <text> Match element left of the given reference
|
|
22
|
+
--right-of <text> Match element right of the given reference`;
|
|
10
23
|
const path_1 = __importDefault(require("path"));
|
|
11
24
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
12
25
|
const runner_js_1 = require("../runner.js");
|
|
13
26
|
const output_js_1 = require("../output.js");
|
|
14
|
-
|
|
27
|
+
const ios_js_1 = require("../drivers/ios.js");
|
|
28
|
+
const android_js_1 = require("../drivers/android.js");
|
|
29
|
+
const web_js_1 = require("../drivers/web.js");
|
|
30
|
+
const wait_js_1 = require("../drivers/wait.js");
|
|
31
|
+
const direct_ios_selector_js_1 = require("../drivers/direct-ios-selector.js");
|
|
32
|
+
const png_crop_js_1 = require("../png-crop.js");
|
|
33
|
+
const DEFAULT_MARGIN_PX = 8;
|
|
34
|
+
async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPage = false, query = '', flags = {}) {
|
|
15
35
|
const timestamp = Date.now();
|
|
16
36
|
const defaultName = `screenshot-${timestamp}.png`;
|
|
17
37
|
const resolvedPath = outputPath
|
|
18
38
|
? path_1.default.resolve(outputPath)
|
|
19
39
|
: path_1.default.resolve(process.cwd(), defaultName);
|
|
40
|
+
const hasSelector = !!(query || flags.id || flags.text);
|
|
41
|
+
const sel = hasSelector
|
|
42
|
+
? {
|
|
43
|
+
...(flags.text ? { text: flags.text } : flags.id ? { id: flags.id } : { query }),
|
|
44
|
+
...(flags.index !== undefined && { index: flags.index }),
|
|
45
|
+
...(flags.focused !== undefined && { focused: flags.focused }),
|
|
46
|
+
...(flags.enabled !== undefined && { enabled: flags.enabled }),
|
|
47
|
+
...(flags.checked !== undefined && { checked: flags.checked }),
|
|
48
|
+
...(flags.selected !== undefined && { selected: flags.selected }),
|
|
49
|
+
...(flags.below && { below: { query: flags.below } }),
|
|
50
|
+
...(flags.above && { above: { query: flags.above } }),
|
|
51
|
+
...(flags.leftOf && { leftOf: { query: flags.leftOf } }),
|
|
52
|
+
...(flags.rightOf && { rightOf: { query: flags.rightOf } }),
|
|
53
|
+
}
|
|
54
|
+
: null;
|
|
55
|
+
const label = flags.text
|
|
56
|
+
? `text="${flags.text}"`
|
|
57
|
+
: flags.id
|
|
58
|
+
? `id="${flags.id}"`
|
|
59
|
+
: query
|
|
60
|
+
? `"${query}"`
|
|
61
|
+
: '';
|
|
62
|
+
const margin = flags.margin ?? DEFAULT_MARGIN_PX;
|
|
20
63
|
const result = await (0, runner_js_1.runDirect)(async (driver) => {
|
|
21
64
|
const buf = await driver.screenshot({ fullPage });
|
|
22
|
-
|
|
65
|
+
let out = buf;
|
|
66
|
+
if (sel) {
|
|
67
|
+
let el;
|
|
68
|
+
let hierarchyW;
|
|
69
|
+
let hierarchyH;
|
|
70
|
+
if (driver instanceof ios_js_1.IOSDriver) {
|
|
71
|
+
const h = await driver.viewHierarchy(false, [], { cache: false });
|
|
72
|
+
hierarchyW = h.axElement.frame.Width;
|
|
73
|
+
hierarchyH = h.axElement.frame.Height;
|
|
74
|
+
el = await (0, wait_js_1.waitForIOSElement)((o) => driver.viewHierarchy(false, [], { cache: o?.cached }).then((x) => x.axElement), sel, undefined, undefined, (0, direct_ios_selector_js_1.makeIOSDirectResolver)(driver, sel));
|
|
75
|
+
}
|
|
76
|
+
else if (driver instanceof web_js_1.WebDriver) {
|
|
77
|
+
const info = await driver.deviceInfo();
|
|
78
|
+
hierarchyW = info.widthPixels;
|
|
79
|
+
hierarchyH = info.heightPixels;
|
|
80
|
+
el = await (0, wait_js_1.waitForWebElement)(() => driver.viewHierarchy(), sel);
|
|
81
|
+
}
|
|
82
|
+
else if (driver instanceof android_js_1.AndroidDriver) {
|
|
83
|
+
const xml = await driver.viewHierarchy();
|
|
84
|
+
// Android XML root bounds: derive from the first parseable <node bounds="[0,0][W,H]">
|
|
85
|
+
const m = xml.match(/<node[^>]*bounds="\[0,0\]\[(\d+),(\d+)\]"/);
|
|
86
|
+
hierarchyW = m ? +m[1] : 0;
|
|
87
|
+
hierarchyH = m ? +m[2] : 0;
|
|
88
|
+
el = await (0, wait_js_1.waitForAndroidElement)(() => driver.viewHierarchy(), sel);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
throw new Error('selector cropping is not supported for this driver');
|
|
92
|
+
}
|
|
93
|
+
const { width: pngW, height: pngH } = (0, png_crop_js_1.readPngDimensions)(buf);
|
|
94
|
+
const scaleX = hierarchyW > 0 ? pngW / hierarchyW : 1;
|
|
95
|
+
const scaleY = hierarchyH > 0 ? pngH / hierarchyH : 1;
|
|
96
|
+
const rectX = Math.round(el.bounds.x * scaleX - margin);
|
|
97
|
+
const rectY = Math.round(el.bounds.y * scaleY - margin);
|
|
98
|
+
const rectW = Math.round(el.bounds.width * scaleX + margin * 2);
|
|
99
|
+
const rectH = Math.round(el.bounds.height * scaleY + margin * 2);
|
|
100
|
+
if (rectX + rectW <= 0 || rectY + rectH <= 0 || rectX >= pngW || rectY >= pngH) {
|
|
101
|
+
throw new Error(`element ${label} bounds [${rectX},${rectY} ${rectW}x${rectH}] are outside the screenshot (${pngW}x${pngH})`);
|
|
102
|
+
}
|
|
103
|
+
out = (0, png_crop_js_1.cropPng)(buf, { x: rectX, y: rectY, width: rectW, height: rectH });
|
|
104
|
+
}
|
|
105
|
+
await promises_1.default.writeFile(resolvedPath, out);
|
|
23
106
|
}, sessionName);
|
|
24
107
|
if (result.success) {
|
|
25
|
-
|
|
108
|
+
const suffix = sel ? ` (${label})` : '';
|
|
109
|
+
(0, output_js_1.printSuccess)(`screenshot saved to ${resolvedPath}${suffix}`, opts);
|
|
26
110
|
return 0;
|
|
27
111
|
}
|
|
28
112
|
else {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HELP = void 0;
|
|
4
|
+
exports.setViewport = setViewport;
|
|
5
|
+
exports.HELP = ` set-viewport [<width> <height>] Resize the web browser viewport (web only)
|
|
6
|
+
--preset <mobile|tablet|desktop> Use a device preset instead of explicit width/height
|
|
7
|
+
--width <n> / --height <n> Viewport size in CSS pixels
|
|
8
|
+
--scale <n> Device scale factor (default: 2 for mobile/tablet, else 1)
|
|
9
|
+
--mobile / --no-mobile Emulate a mobile device (touch + mobile UA hints)
|
|
10
|
+
--user-agent <str> Override the user agent string
|
|
11
|
+
--color-scheme <dark|light> Emulate prefers-color-scheme`;
|
|
12
|
+
const runner_js_1 = require("../runner.js");
|
|
13
|
+
const web_js_1 = require("../drivers/web.js");
|
|
14
|
+
const output_js_1 = require("../output.js");
|
|
15
|
+
const PRESETS = {
|
|
16
|
+
mobile: { width: 390, height: 844, deviceScaleFactor: 3, isMobile: true },
|
|
17
|
+
tablet: { width: 820, height: 1180, deviceScaleFactor: 2, isMobile: true },
|
|
18
|
+
desktop: { width: 1280, height: 800, deviceScaleFactor: 1, isMobile: false },
|
|
19
|
+
};
|
|
20
|
+
async function setViewport(flags, opts = {}, sessionName = 'default') {
|
|
21
|
+
let width = flags.width;
|
|
22
|
+
let height = flags.height;
|
|
23
|
+
let isMobile = flags.mobile;
|
|
24
|
+
let scale = flags.scale;
|
|
25
|
+
if (flags.preset !== undefined) {
|
|
26
|
+
const preset = PRESETS[flags.preset.toLowerCase()];
|
|
27
|
+
if (!preset) {
|
|
28
|
+
(0, output_js_1.printError)(`--preset must be one of: ${Object.keys(PRESETS).join(', ')}`, opts);
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
width ?? (width = preset.width);
|
|
32
|
+
height ?? (height = preset.height);
|
|
33
|
+
scale ?? (scale = preset.deviceScaleFactor);
|
|
34
|
+
isMobile ?? (isMobile = preset.isMobile);
|
|
35
|
+
}
|
|
36
|
+
if (width === undefined || height === undefined) {
|
|
37
|
+
(0, output_js_1.printError)('set-viewport requires --preset or both width and height', opts);
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
41
|
+
(0, output_js_1.printError)('set-viewport width and height must be positive numbers', opts);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
if (flags.colorScheme !== undefined &&
|
|
45
|
+
flags.colorScheme !== 'dark' &&
|
|
46
|
+
flags.colorScheme !== 'light') {
|
|
47
|
+
(0, output_js_1.printError)('--color-scheme must be "dark" or "light"', opts);
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
const result = await (0, runner_js_1.runDirect)(async (driver) => {
|
|
51
|
+
if (!(driver instanceof web_js_1.WebDriver)) {
|
|
52
|
+
throw new Error('set-viewport is only supported on web devices');
|
|
53
|
+
}
|
|
54
|
+
await driver.setViewport({
|
|
55
|
+
width: width,
|
|
56
|
+
height: height,
|
|
57
|
+
...(scale !== undefined ? { deviceScaleFactor: scale } : {}),
|
|
58
|
+
...(isMobile !== undefined ? { isMobile } : {}),
|
|
59
|
+
...(flags.userAgent !== undefined ? { userAgent: flags.userAgent } : {}),
|
|
60
|
+
...(flags.colorScheme !== undefined
|
|
61
|
+
? { colorScheme: flags.colorScheme }
|
|
62
|
+
: {}),
|
|
63
|
+
});
|
|
64
|
+
}, sessionName);
|
|
65
|
+
if (result.success) {
|
|
66
|
+
(0, output_js_1.printSuccess)(`set-viewport ${width}x${height} — done`, opts);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
(0, output_js_1.printError)(`set-viewport ${width}x${height} — failed\n${result.stderr}`, opts);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/dist/commands/tap.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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 or
|
|
5
|
+
exports.HELP = ` tap-on <element> Tap element by text, id, or @eN snapshot ref
|
|
6
6
|
--id <id> Match by accessibility id instead of text
|
|
7
7
|
--text <text> Match by text only (not id)
|
|
8
8
|
--index <n> Pick the nth match (0-based)
|
|
@@ -24,6 +24,7 @@ const android_js_1 = require("../drivers/android.js");
|
|
|
24
24
|
const web_js_1 = require("../drivers/web.js");
|
|
25
25
|
const wait_js_1 = require("../drivers/wait.js");
|
|
26
26
|
const direct_ios_selector_js_1 = require("../drivers/direct-ios-selector.js");
|
|
27
|
+
const snapshot_store_js_1 = require("../snapshot-store.js");
|
|
27
28
|
const utils_js_1 = require("../utils.js");
|
|
28
29
|
async function tap(query, opts = {}, sessionName = 'default', flags = {}) {
|
|
29
30
|
if (!query && !flags.id && !flags.text) {
|
|
@@ -42,6 +43,9 @@ async function tap(query, opts = {}, sessionName = 'default', flags = {}) {
|
|
|
42
43
|
...(flags.leftOf && { leftOf: { query: flags.leftOf } }),
|
|
43
44
|
...(flags.rightOf && { rightOf: { query: flags.rightOf } }),
|
|
44
45
|
};
|
|
46
|
+
// A bare `@eN` query taps the cached coordinates from the last `capture-ui`
|
|
47
|
+
// snapshot, skipping fuzzy text/id resolution. Explicit --text/--id win.
|
|
48
|
+
const useRef = (0, snapshot_store_js_1.isRefQuery)(query) && !flags.text && !flags.id;
|
|
45
49
|
const label = flags.text ? `text="${flags.text}"` : flags.id ? `id="${flags.id}"` : `"${query}"`;
|
|
46
50
|
const result = await (0, runner_js_1.runDirect)(async (driver) => {
|
|
47
51
|
if (driver instanceof ios_js_1.IOSDriver && driver.platform === 'tvos') {
|
|
@@ -49,7 +53,16 @@ async function tap(query, opts = {}, sessionName = 'default', flags = {}) {
|
|
|
49
53
|
'Use press-key to navigate (e.g. conductor press-key "Remote Dpad Center").');
|
|
50
54
|
}
|
|
51
55
|
let el;
|
|
52
|
-
if (
|
|
56
|
+
if (useRef) {
|
|
57
|
+
const { entry, staleReason } = (0, snapshot_store_js_1.resolveRef)(await (0, snapshot_store_js_1.loadSnapshot)(sessionName), query, {
|
|
58
|
+
deviceId: sessionName,
|
|
59
|
+
});
|
|
60
|
+
if (staleReason) {
|
|
61
|
+
process.stderr.write(`warning: ${query} — ${staleReason}; re-run capture-ui if the tap misses\n`);
|
|
62
|
+
}
|
|
63
|
+
el = { centerX: entry.centerX, centerY: entry.centerY };
|
|
64
|
+
}
|
|
65
|
+
else if (driver instanceof ios_js_1.IOSDriver) {
|
|
53
66
|
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));
|
|
54
67
|
}
|
|
55
68
|
else if (driver instanceof web_js_1.WebDriver) {
|
package/dist/daemon/server.js
CHANGED
|
@@ -110,7 +110,7 @@ async function ensureDriverRunning() {
|
|
|
110
110
|
}
|
|
111
111
|
else if (driverPlatform === 'tvos') {
|
|
112
112
|
// Health-check restart — don't dismiss, to avoid disrupting user's app
|
|
113
|
-
await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /*
|
|
113
|
+
await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* restoreFocusAfterLaunch */ false);
|
|
114
114
|
}
|
|
115
115
|
else if (driverPlatform === 'web') {
|
|
116
116
|
await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
|
|
@@ -357,8 +357,9 @@ async function main() {
|
|
|
357
357
|
await (0, bootstrap_js_1.startIOSDriver)(sessionName, driverPort);
|
|
358
358
|
}
|
|
359
359
|
else if (platform === 'tvos') {
|
|
360
|
-
// First install —
|
|
361
|
-
|
|
360
|
+
// First install — the runner takes foreground; ask it to hand
|
|
361
|
+
// focus back to whatever app the user had open.
|
|
362
|
+
await (0, bootstrap_js_1.startTvOSDriver)(sessionName, driverPort, /* restoreFocusAfterLaunch */ true);
|
|
362
363
|
}
|
|
363
364
|
else if (platform === 'web') {
|
|
364
365
|
await (0, web_server_js_1.startWebServer)(driverPort, (0, bootstrap_js_1.webBrowserName)(sessionName), dlog, cdpUrl, cdpTargetId);
|
package/dist/drivers/a11y.js
CHANGED
|
@@ -79,6 +79,7 @@ function buildIOSA11y(root) {
|
|
|
79
79
|
accessibilityOrder = order++;
|
|
80
80
|
snapshot.push({
|
|
81
81
|
nodeId: path,
|
|
82
|
+
ref: `@e${accessibilityOrder + 1}`,
|
|
82
83
|
order: accessibilityOrder,
|
|
83
84
|
frame: {
|
|
84
85
|
x: node.frame.X,
|
|
@@ -281,6 +282,7 @@ function buildAndroidA11y(xml) {
|
|
|
281
282
|
accessibilityOrder = order++;
|
|
282
283
|
snapshot.push({
|
|
283
284
|
nodeId: path,
|
|
285
|
+
ref: `@e${accessibilityOrder + 1}`,
|
|
284
286
|
order: accessibilityOrder,
|
|
285
287
|
frame: {
|
|
286
288
|
x: n.bounds.x1,
|
|
@@ -382,6 +384,7 @@ function buildWebA11y(hierarchy) {
|
|
|
382
384
|
accessibilityOrder = order++;
|
|
383
385
|
snapshot.push({
|
|
384
386
|
nodeId: path,
|
|
387
|
+
ref: `@e${accessibilityOrder + 1}`,
|
|
385
388
|
order: accessibilityOrder,
|
|
386
389
|
frame: { x: n.bounds.x, y: n.bounds.y, w: n.bounds.width, h: n.bounds.height },
|
|
387
390
|
label: n.name,
|
|
@@ -547,11 +547,16 @@ async function setupTvOSDriverCache() {
|
|
|
547
547
|
* Start the tvOS XCTest driver via `xcodebuild test-without-building`.
|
|
548
548
|
* Mirrors startIOSDriver but targets the tvOS xctestrun.
|
|
549
549
|
*
|
|
550
|
-
* On first launch the runner app appears in the foreground,
|
|
551
|
-
*
|
|
552
|
-
*
|
|
550
|
+
* On first launch the runner app appears in the foreground, displacing whatever
|
|
551
|
+
* app the user had open. When `restoreFocusAfterLaunch` is set we ask the driver
|
|
552
|
+
* to re-activate that app once the server is up; otherwise the runner stays in
|
|
553
|
+
* front and commands like `inspect` run against the wrong target. The driver
|
|
554
|
+
* falls back to pressing home when no candidate app is found.
|
|
555
|
+
*
|
|
556
|
+
* Subsequent restarts (e.g. health-check recovery) skip the restore to avoid
|
|
557
|
+
* disrupting whatever the user is doing.
|
|
553
558
|
*/
|
|
554
|
-
async function startTvOSDriver(deviceId, port = TVOS_BASE_PORT,
|
|
559
|
+
async function startTvOSDriver(deviceId, port = TVOS_BASE_PORT, restoreFocusAfterLaunch = false) {
|
|
555
560
|
if (await isPortOpen(port)) {
|
|
556
561
|
(0, verbose_js_1.log)(`tvOS driver already running on port ${port}`);
|
|
557
562
|
return;
|
|
@@ -579,13 +584,18 @@ async function startTvOSDriver(deviceId, port = TVOS_BASE_PORT, dismissAfterLaun
|
|
|
579
584
|
await (0, utils_js_1.sleep)(TVOS_STARTUP_POLL_MS);
|
|
580
585
|
if (await isPortOpen(port)) {
|
|
581
586
|
(0, verbose_js_1.log)(`tvOS driver ready on port ${port}`);
|
|
582
|
-
if (
|
|
587
|
+
if (restoreFocusAfterLaunch) {
|
|
583
588
|
try {
|
|
584
|
-
await
|
|
585
|
-
(
|
|
589
|
+
const restored = await restoreFocusViaDriver(port);
|
|
590
|
+
if (restored) {
|
|
591
|
+
(0, verbose_js_1.log)(`Restored tvOS focus to ${restored}`);
|
|
592
|
+
}
|
|
593
|
+
else {
|
|
594
|
+
(0, verbose_js_1.log)('Dismissed tvOS driver app (no previous app to restore)');
|
|
595
|
+
}
|
|
586
596
|
}
|
|
587
597
|
catch {
|
|
588
|
-
(0, verbose_js_1.log)('Could not
|
|
598
|
+
(0, verbose_js_1.log)('Could not restore tvOS focus (non-fatal)');
|
|
589
599
|
}
|
|
590
600
|
}
|
|
591
601
|
return;
|
|
@@ -827,25 +837,39 @@ async function uninstallDriver(deviceId, platform) {
|
|
|
827
837
|
}
|
|
828
838
|
}
|
|
829
839
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
830
|
-
/**
|
|
831
|
-
|
|
840
|
+
/**
|
|
841
|
+
* Ask the driver to restore foreground focus to whatever app was active before
|
|
842
|
+
* the runner launched. Returns the bundle ID that was activated, or null when
|
|
843
|
+
* the driver fell back to pressing home because no previous app was found.
|
|
844
|
+
*/
|
|
845
|
+
function restoreFocusViaDriver(port) {
|
|
832
846
|
return new Promise((resolve, reject) => {
|
|
833
|
-
const body = JSON.stringify({ button });
|
|
834
847
|
const options = {
|
|
835
848
|
hostname: '127.0.0.1',
|
|
836
849
|
port,
|
|
837
|
-
path: '/
|
|
850
|
+
path: '/restoreFocus',
|
|
838
851
|
method: 'POST',
|
|
839
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length':
|
|
852
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': 0 },
|
|
840
853
|
};
|
|
841
854
|
const req = http_1.default.request(options, (res) => {
|
|
842
|
-
|
|
843
|
-
res.on('
|
|
844
|
-
|
|
845
|
-
|
|
855
|
+
const chunks = [];
|
|
856
|
+
res.on('data', (c) => chunks.push(c));
|
|
857
|
+
res.on('end', () => {
|
|
858
|
+
if (!res.statusCode || res.statusCode >= 300) {
|
|
859
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
864
|
+
const parsed = JSON.parse(body);
|
|
865
|
+
resolve(parsed.restoredBundleId ? parsed.restoredBundleId : null);
|
|
866
|
+
}
|
|
867
|
+
catch (err) {
|
|
868
|
+
reject(err);
|
|
869
|
+
}
|
|
870
|
+
});
|
|
846
871
|
});
|
|
847
872
|
req.on('error', reject);
|
|
848
|
-
req.write(body);
|
|
849
873
|
req.end();
|
|
850
874
|
});
|
|
851
875
|
}
|
|
@@ -63,6 +63,7 @@ function makeIOSDirectResolver(driver, sel, appIds = []) {
|
|
|
63
63
|
return {
|
|
64
64
|
centerX: X + Width / 2,
|
|
65
65
|
centerY: Y + Height / 2,
|
|
66
|
+
bounds: { x: X, y: Y, width: Width, height: Height },
|
|
66
67
|
text: n.label || n.title || n.value || n.placeholderValue || undefined,
|
|
67
68
|
id: n.identifier || undefined,
|
|
68
69
|
};
|
|
@@ -204,6 +204,7 @@ function findIOSElement(root, sel) {
|
|
|
204
204
|
return {
|
|
205
205
|
centerX: X + Width / 2,
|
|
206
206
|
centerY: Y + Height / 2,
|
|
207
|
+
bounds: { x: X, y: Y, width: Width, height: Height },
|
|
207
208
|
text: iosTextOf(node) || undefined,
|
|
208
209
|
id: node.identifier || undefined,
|
|
209
210
|
};
|
|
@@ -353,6 +354,7 @@ function findAndroidElement(xml, sel) {
|
|
|
353
354
|
return {
|
|
354
355
|
centerX: (x1 + x2) / 2,
|
|
355
356
|
centerY: (y1 + y2) / 2,
|
|
357
|
+
bounds: { x: x1, y: y1, width: x2 - x1, height: y2 - y1 },
|
|
356
358
|
text: androidTextOf(node) || undefined,
|
|
357
359
|
id: node.resourceId || undefined,
|
|
358
360
|
};
|
|
@@ -558,6 +560,7 @@ function findWebElement(hierarchy, sel) {
|
|
|
558
560
|
return {
|
|
559
561
|
centerX: b.x + b.width / 2,
|
|
560
562
|
centerY: b.y + b.height / 2,
|
|
563
|
+
bounds: { x: b.x, y: b.y, width: b.width, height: b.height },
|
|
561
564
|
text: node.name || undefined,
|
|
562
565
|
id: node.ref || undefined,
|
|
563
566
|
};
|
|
@@ -38,17 +38,17 @@ async function startRecording(sessionName, out, appId) {
|
|
|
38
38
|
return target;
|
|
39
39
|
}
|
|
40
40
|
async function finishRecording(sessionName) {
|
|
41
|
-
const session =
|
|
41
|
+
const session = await (0, session_js_1.getSession)(sessionName);
|
|
42
42
|
if (!session.recordingPath)
|
|
43
43
|
return null;
|
|
44
44
|
const out = session.recordingPath;
|
|
45
45
|
fs_1.default.appendFileSync(out, `# Recording finished ${new Date().toISOString()}\n`);
|
|
46
|
-
|
|
47
|
-
await (0, session_js_1.updateSession)(
|
|
46
|
+
// Setting to undefined clears the key on save — JSON.stringify drops it.
|
|
47
|
+
await (0, session_js_1.updateSession)({ recordingPath: undefined }, sessionName);
|
|
48
48
|
return out;
|
|
49
49
|
}
|
|
50
50
|
async function getActiveRecording(sessionName) {
|
|
51
|
-
const session =
|
|
51
|
+
const session = await (0, session_js_1.getSession)(sessionName);
|
|
52
52
|
return session.recordingPath ?? null;
|
|
53
53
|
}
|
|
54
54
|
function appendStep(filePath, yamlStep) {
|
|
@@ -25,13 +25,19 @@ exports.targetsForDevice = targetsForDevice;
|
|
|
25
25
|
const child_process_1 = require("child_process");
|
|
26
26
|
const metro_js_1 = require("./metro.js");
|
|
27
27
|
const sdk_js_1 = require("../../android/sdk.js");
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Heuristic to reject ports that obviously aren't a Metro dev server (system
|
|
30
|
+
* ports, well-known services). Candidates that pass are always verified via
|
|
31
|
+
* {@link fetchTargets} before being returned, so this filter only exists to
|
|
32
|
+
* keep the number of verification probes bounded.
|
|
33
|
+
*/
|
|
33
34
|
function isMetroPort(port) {
|
|
34
|
-
|
|
35
|
+
if (port < 1024 || port > 65535)
|
|
36
|
+
return false;
|
|
37
|
+
// Skip a handful of common services that share the localhost loopback to
|
|
38
|
+
// avoid pointless /json probes against them.
|
|
39
|
+
const wellKnownNonMetro = new Set([3306, 5432, 6379, 27017, 9200, 11211]);
|
|
40
|
+
return !wellKnownNonMetro.has(port);
|
|
35
41
|
}
|
|
36
42
|
function spawnCapture(cmd, args) {
|
|
37
43
|
return new Promise((resolve, reject) => {
|
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.MetroCdpClient = void 0;
|
|
7
|
+
exports.selectDebuggerUrl = selectDebuggerUrl;
|
|
7
8
|
exports.resolveDebuggerUrl = resolveDebuggerUrl;
|
|
8
9
|
exports.cdpCall = cdpCall;
|
|
9
10
|
/**
|
|
@@ -20,13 +21,15 @@ const ws_1 = __importDefault(require("ws"));
|
|
|
20
21
|
const metro_js_1 = require("./log-sources/metro.js");
|
|
21
22
|
const metro_discovery_js_1 = require("./log-sources/metro-discovery.js");
|
|
22
23
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
24
|
+
* Pick a debugger `webSocketDebuggerUrl` from an already-fetched target list.
|
|
25
|
+
* Pure — the async `fetchTargets` / `getDeviceDisplayName` calls happen in
|
|
26
|
+
* `resolveDebuggerUrl`. `displayName` is the device's resolved display name,
|
|
27
|
+
* used for device-scoped selection when present. Throws with a clear message
|
|
28
|
+
* when no target matches.
|
|
25
29
|
*/
|
|
26
|
-
|
|
30
|
+
function selectDebuggerUrl(targets, opts, displayName) {
|
|
27
31
|
const port = opts.port ?? 8081;
|
|
28
32
|
const host = opts.host ?? 'localhost';
|
|
29
|
-
const targets = await (0, metro_js_1.fetchTargets)(port, host);
|
|
30
33
|
const withWs = targets.filter((t) => t.webSocketDebuggerUrl);
|
|
31
34
|
if (withWs.length === 0) {
|
|
32
35
|
throw new Error(`Metro on ${host}:${port} returned no debugger targets. Is an app running on a device/simulator?`);
|
|
@@ -37,18 +40,29 @@ async function resolveDebuggerUrl(opts) {
|
|
|
37
40
|
}
|
|
38
41
|
return withWs[opts.targetIndex].webSocketDebuggerUrl;
|
|
39
42
|
}
|
|
40
|
-
if (
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
if (target)
|
|
45
|
-
return target.webSocketDebuggerUrl;
|
|
46
|
-
}
|
|
43
|
+
if (displayName) {
|
|
44
|
+
const target = (0, metro_discovery_js_1.selectTargetForDevice)(withWs, displayName);
|
|
45
|
+
if (target)
|
|
46
|
+
return target.webSocketDebuggerUrl;
|
|
47
47
|
}
|
|
48
48
|
// Prefer the Hermes/React target by title, otherwise first.
|
|
49
49
|
const target = withWs.find((t) => t.title && /hermes|react/i.test(t.title)) ?? withWs[0];
|
|
50
50
|
return target.webSocketDebuggerUrl;
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a Metro target's `webSocketDebuggerUrl` honoring deviceId / targetIndex.
|
|
54
|
+
* Throws with a clear message if Metro is unreachable or no target matches.
|
|
55
|
+
*/
|
|
56
|
+
async function resolveDebuggerUrl(opts) {
|
|
57
|
+
const port = opts.port ?? 8081;
|
|
58
|
+
const host = opts.host ?? 'localhost';
|
|
59
|
+
const targets = await (0, metro_js_1.fetchTargets)(port, host);
|
|
60
|
+
let displayName;
|
|
61
|
+
if (opts.deviceId && opts.platform) {
|
|
62
|
+
displayName = (await (0, metro_discovery_js_1.getDeviceDisplayName)(opts.platform, opts.deviceId)) ?? undefined;
|
|
63
|
+
}
|
|
64
|
+
return selectDebuggerUrl(targets, opts, displayName);
|
|
65
|
+
}
|
|
52
66
|
/**
|
|
53
67
|
* Open a short-lived CDP socket, send a single method, return the result.
|
|
54
68
|
* Closes the socket whether the call succeeds or throws.
|
package/dist/drivers/web.js
CHANGED
|
@@ -157,6 +157,9 @@ class WebDriver {
|
|
|
157
157
|
async eraseAllText(count = 50) {
|
|
158
158
|
await this.post('eraseText', { count });
|
|
159
159
|
}
|
|
160
|
+
async setViewport(opts) {
|
|
161
|
+
await this.post('setViewport', opts);
|
|
162
|
+
}
|
|
160
163
|
// ── Stub methods for mobile-only features ──────────────────────────────────
|
|
161
164
|
// These throw clear errors rather than silently no-op so the user knows
|
|
162
165
|
// the command isn't applicable to web.
|
package/dist/index.js
CHANGED
|
@@ -51,6 +51,7 @@ const hide_keyboard_js_1 = require("./commands/hide-keyboard.js");
|
|
|
51
51
|
const scroll_until_visible_js_1 = require("./commands/scroll-until-visible.js");
|
|
52
52
|
const set_location_js_1 = require("./commands/set-location.js");
|
|
53
53
|
const set_orientation_js_1 = require("./commands/set-orientation.js");
|
|
54
|
+
const set_viewport_js_1 = require("./commands/set-viewport.js");
|
|
54
55
|
const start_device_js_1 = require("./commands/start-device.js");
|
|
55
56
|
const stop_device_js_1 = require("./commands/stop-device.js");
|
|
56
57
|
const delete_device_js_1 = require("./commands/delete-device.js");
|
|
@@ -91,6 +92,7 @@ const COMMAND_HELP = {
|
|
|
91
92
|
'open-link': open_link_js_1.HELP,
|
|
92
93
|
'set-location': set_location_js_1.HELP,
|
|
93
94
|
'set-orientation': set_orientation_js_1.HELP,
|
|
95
|
+
'set-viewport': set_viewport_js_1.HELP,
|
|
94
96
|
'take-screenshot': screenshot_js_1.HELP,
|
|
95
97
|
'capture-ui': capture_ui_js_1.HELP,
|
|
96
98
|
inspect: inspect_js_1.HELP,
|
|
@@ -214,6 +216,11 @@ async function main() {
|
|
|
214
216
|
'interval',
|
|
215
217
|
'app',
|
|
216
218
|
'since',
|
|
219
|
+
'preset',
|
|
220
|
+
'width',
|
|
221
|
+
'height',
|
|
222
|
+
'user-agent',
|
|
223
|
+
'color-scheme',
|
|
217
224
|
],
|
|
218
225
|
alias: { h: 'help', v: 'verbose', V: 'version' },
|
|
219
226
|
});
|
|
@@ -502,10 +509,44 @@ async function main() {
|
|
|
502
509
|
exitCode = await (0, set_orientation_js_1.setOrientation)(orientation, opts, sessionName);
|
|
503
510
|
break;
|
|
504
511
|
}
|
|
512
|
+
case 'set-viewport': {
|
|
513
|
+
exitCode = await (0, set_viewport_js_1.setViewport)({
|
|
514
|
+
preset: argv['preset'],
|
|
515
|
+
width: argv['width'] !== undefined
|
|
516
|
+
? Number(argv['width'])
|
|
517
|
+
: rest[0] !== undefined
|
|
518
|
+
? Number(rest[0])
|
|
519
|
+
: undefined,
|
|
520
|
+
height: argv['height'] !== undefined
|
|
521
|
+
? Number(argv['height'])
|
|
522
|
+
: rest[1] !== undefined
|
|
523
|
+
? Number(rest[1])
|
|
524
|
+
: undefined,
|
|
525
|
+
scale: argv['scale'] !== undefined ? Number(argv['scale']) : undefined,
|
|
526
|
+
mobile: argv['mobile'],
|
|
527
|
+
userAgent: argv['user-agent'],
|
|
528
|
+
colorScheme: argv['color-scheme'],
|
|
529
|
+
}, opts, sessionName);
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
505
532
|
case 'take-screenshot': {
|
|
506
533
|
const outPath = argv['output'];
|
|
507
534
|
const fullPage = Boolean(argv['full-page']);
|
|
508
|
-
|
|
535
|
+
const element = rest.join(' ');
|
|
536
|
+
exitCode = await (0, screenshot_js_1.screenshot)(outPath, opts, sessionName, fullPage, element, {
|
|
537
|
+
id: argv['id'],
|
|
538
|
+
text: argv['text'],
|
|
539
|
+
index: argv['index'] !== undefined ? Number(argv['index']) : undefined,
|
|
540
|
+
margin: argv['margin'] !== undefined ? Number(argv['margin']) : undefined,
|
|
541
|
+
focused: argv['focused'] !== undefined ? argv['focused'] : undefined,
|
|
542
|
+
enabled: argv['enabled'] !== undefined ? argv['enabled'] : undefined,
|
|
543
|
+
checked: argv['checked'] !== undefined ? argv['checked'] : undefined,
|
|
544
|
+
selected: argv['selected'] !== undefined ? argv['selected'] : undefined,
|
|
545
|
+
below: argv['below'],
|
|
546
|
+
above: argv['above'],
|
|
547
|
+
leftOf: argv['left-of'],
|
|
548
|
+
rightOf: argv['right-of'],
|
|
549
|
+
});
|
|
509
550
|
break;
|
|
510
551
|
}
|
|
511
552
|
case 'capture-ui': {
|
package/dist/png-crop.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
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.readPngDimensions = readPngDimensions;
|
|
7
|
+
exports.cropPng = cropPng;
|
|
8
|
+
/**
|
|
9
|
+
* Minimal PNG cropper — decodes 8-bit RGB / RGBA / grayscale / grayscale+alpha
|
|
10
|
+
* PNGs, crops to a rect, re-encodes with filter type 0 (None).
|
|
11
|
+
*
|
|
12
|
+
* Used by `take-screenshot --id/--text/<query>` to return only the pixels
|
|
13
|
+
* inside a resolved element's bounds.
|
|
14
|
+
*/
|
|
15
|
+
const zlib_1 = __importDefault(require("zlib"));
|
|
16
|
+
const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
17
|
+
const CRC_TABLE = (() => {
|
|
18
|
+
const table = new Uint32Array(256);
|
|
19
|
+
for (let n = 0; n < 256; n++) {
|
|
20
|
+
let c = n;
|
|
21
|
+
for (let k = 0; k < 8; k++)
|
|
22
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
23
|
+
table[n] = c >>> 0;
|
|
24
|
+
}
|
|
25
|
+
return table;
|
|
26
|
+
})();
|
|
27
|
+
function crc32(buf) {
|
|
28
|
+
let c = 0xffffffff;
|
|
29
|
+
for (let i = 0; i < buf.length; i++)
|
|
30
|
+
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
|
31
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
32
|
+
}
|
|
33
|
+
/** Read width/height from a PNG IHDR chunk. Throws if `buf` is not a PNG. */
|
|
34
|
+
function readPngDimensions(buf) {
|
|
35
|
+
if (buf.length < 24 || !SIGNATURE.equals(buf.subarray(0, 8))) {
|
|
36
|
+
throw new Error('not a PNG');
|
|
37
|
+
}
|
|
38
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
39
|
+
}
|
|
40
|
+
function bytesPerPixel(colorType) {
|
|
41
|
+
switch (colorType) {
|
|
42
|
+
case 0:
|
|
43
|
+
return 1; // grayscale
|
|
44
|
+
case 2:
|
|
45
|
+
return 3; // RGB
|
|
46
|
+
case 3:
|
|
47
|
+
return 1; // palette (unsupported below)
|
|
48
|
+
case 4:
|
|
49
|
+
return 2; // grayscale + alpha
|
|
50
|
+
case 6:
|
|
51
|
+
return 4; // RGBA
|
|
52
|
+
default:
|
|
53
|
+
throw new Error(`unsupported PNG color type ${colorType}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function paeth(a, b, c) {
|
|
57
|
+
const p = a + b - c;
|
|
58
|
+
const pa = Math.abs(p - a);
|
|
59
|
+
const pb = Math.abs(p - b);
|
|
60
|
+
const pc = Math.abs(p - c);
|
|
61
|
+
if (pa <= pb && pa <= pc)
|
|
62
|
+
return a;
|
|
63
|
+
if (pb <= pc)
|
|
64
|
+
return b;
|
|
65
|
+
return c;
|
|
66
|
+
}
|
|
67
|
+
function unfilter(filtered, w, h, bpp) {
|
|
68
|
+
const stride = w * bpp;
|
|
69
|
+
const out = Buffer.alloc(stride * h);
|
|
70
|
+
let inOff = 0;
|
|
71
|
+
for (let y = 0; y < h; y++) {
|
|
72
|
+
const filter = filtered[inOff++];
|
|
73
|
+
const rowOff = y * stride;
|
|
74
|
+
for (let x = 0; x < stride; x++) {
|
|
75
|
+
const raw = filtered[inOff++];
|
|
76
|
+
const left = x >= bpp ? out[rowOff + x - bpp] : 0;
|
|
77
|
+
const up = y > 0 ? out[rowOff - stride + x] : 0;
|
|
78
|
+
const upLeft = x >= bpp && y > 0 ? out[rowOff - stride + x - bpp] : 0;
|
|
79
|
+
let v;
|
|
80
|
+
switch (filter) {
|
|
81
|
+
case 0:
|
|
82
|
+
v = raw;
|
|
83
|
+
break;
|
|
84
|
+
case 1:
|
|
85
|
+
v = raw + left;
|
|
86
|
+
break;
|
|
87
|
+
case 2:
|
|
88
|
+
v = raw + up;
|
|
89
|
+
break;
|
|
90
|
+
case 3:
|
|
91
|
+
v = raw + ((left + up) >> 1);
|
|
92
|
+
break;
|
|
93
|
+
case 4:
|
|
94
|
+
v = raw + paeth(left, up, upLeft);
|
|
95
|
+
break;
|
|
96
|
+
default:
|
|
97
|
+
throw new Error(`unsupported PNG filter ${filter}`);
|
|
98
|
+
}
|
|
99
|
+
out[rowOff + x] = v & 0xff;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function writeChunk(out, type, data) {
|
|
105
|
+
const len = Buffer.alloc(4);
|
|
106
|
+
len.writeUInt32BE(data.length, 0);
|
|
107
|
+
const typeBuf = Buffer.from(type, 'ascii');
|
|
108
|
+
const crcInput = Buffer.concat([typeBuf, data]);
|
|
109
|
+
const crc = Buffer.alloc(4);
|
|
110
|
+
crc.writeUInt32BE(crc32(crcInput), 0);
|
|
111
|
+
out.push(len, typeBuf, data, crc);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Crop a PNG buffer to the given rect. Coordinates are in PNG pixel space;
|
|
115
|
+
* `x`/`y`/`width`/`height` are clamped to the PNG canvas before cropping.
|
|
116
|
+
* Throws if the rect lies fully outside the canvas.
|
|
117
|
+
*/
|
|
118
|
+
function cropPng(buf, rect) {
|
|
119
|
+
if (buf.length < 8 || !SIGNATURE.equals(buf.subarray(0, 8))) {
|
|
120
|
+
throw new Error('not a PNG');
|
|
121
|
+
}
|
|
122
|
+
let off = 8;
|
|
123
|
+
let ihdr = null;
|
|
124
|
+
const idatParts = [];
|
|
125
|
+
while (off < buf.length) {
|
|
126
|
+
const len = buf.readUInt32BE(off);
|
|
127
|
+
off += 4;
|
|
128
|
+
const type = buf.subarray(off, off + 4).toString('ascii');
|
|
129
|
+
off += 4;
|
|
130
|
+
const data = buf.subarray(off, off + len);
|
|
131
|
+
off += len;
|
|
132
|
+
off += 4; // CRC
|
|
133
|
+
if (type === 'IHDR') {
|
|
134
|
+
ihdr = {
|
|
135
|
+
width: data.readUInt32BE(0),
|
|
136
|
+
height: data.readUInt32BE(4),
|
|
137
|
+
bitDepth: data.readUInt8(8),
|
|
138
|
+
colorType: data.readUInt8(9),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
else if (type === 'IDAT') {
|
|
142
|
+
idatParts.push(data);
|
|
143
|
+
}
|
|
144
|
+
else if (type === 'IEND') {
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!ihdr)
|
|
149
|
+
throw new Error('PNG missing IHDR');
|
|
150
|
+
if (ihdr.bitDepth !== 8) {
|
|
151
|
+
throw new Error(`unsupported PNG bit depth ${ihdr.bitDepth} (only 8 supported)`);
|
|
152
|
+
}
|
|
153
|
+
if (ihdr.colorType === 3) {
|
|
154
|
+
throw new Error('palette PNGs are not supported for cropping');
|
|
155
|
+
}
|
|
156
|
+
const bpp = bytesPerPixel(ihdr.colorType);
|
|
157
|
+
// Clamp crop rect to canvas bounds
|
|
158
|
+
const cx = Math.max(0, Math.min(Math.floor(rect.x), ihdr.width));
|
|
159
|
+
const cy = Math.max(0, Math.min(Math.floor(rect.y), ihdr.height));
|
|
160
|
+
const cx2 = Math.max(0, Math.min(Math.floor(rect.x + rect.width), ihdr.width));
|
|
161
|
+
const cy2 = Math.max(0, Math.min(Math.floor(rect.y + rect.height), ihdr.height));
|
|
162
|
+
const cw = cx2 - cx;
|
|
163
|
+
const ch = cy2 - cy;
|
|
164
|
+
if (cw <= 0 || ch <= 0) {
|
|
165
|
+
throw new Error('crop rect is outside the screenshot canvas');
|
|
166
|
+
}
|
|
167
|
+
const filtered = zlib_1.default.inflateSync(Buffer.concat(idatParts));
|
|
168
|
+
const raw = unfilter(filtered, ihdr.width, ihdr.height, bpp);
|
|
169
|
+
const srcStride = ihdr.width * bpp;
|
|
170
|
+
const dstStride = cw * bpp;
|
|
171
|
+
// New filtered scanlines with filter byte 0 (None) prefix.
|
|
172
|
+
const filteredOut = Buffer.alloc(ch * (dstStride + 1));
|
|
173
|
+
for (let y = 0; y < ch; y++) {
|
|
174
|
+
filteredOut[y * (dstStride + 1)] = 0;
|
|
175
|
+
raw.copy(filteredOut, y * (dstStride + 1) + 1, (cy + y) * srcStride + cx * bpp, (cy + y) * srcStride + cx * bpp + dstStride);
|
|
176
|
+
}
|
|
177
|
+
const idat = zlib_1.default.deflateSync(filteredOut);
|
|
178
|
+
const chunks = [SIGNATURE];
|
|
179
|
+
const ihdrData = Buffer.alloc(13);
|
|
180
|
+
ihdrData.writeUInt32BE(cw, 0);
|
|
181
|
+
ihdrData.writeUInt32BE(ch, 4);
|
|
182
|
+
ihdrData.writeUInt8(ihdr.bitDepth, 8);
|
|
183
|
+
ihdrData.writeUInt8(ihdr.colorType, 9);
|
|
184
|
+
ihdrData.writeUInt8(0, 10); // compression
|
|
185
|
+
ihdrData.writeUInt8(0, 11); // filter method
|
|
186
|
+
ihdrData.writeUInt8(0, 12); // interlace
|
|
187
|
+
writeChunk(chunks, 'IHDR', ihdrData);
|
|
188
|
+
writeChunk(chunks, 'IDAT', idat);
|
|
189
|
+
writeChunk(chunks, 'IEND', Buffer.alloc(0));
|
|
190
|
+
return Buffer.concat(chunks);
|
|
191
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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.SNAPSHOT_STALE_MS = void 0;
|
|
7
|
+
exports.snapshotFilePath = snapshotFilePath;
|
|
8
|
+
exports.isRefQuery = isRefQuery;
|
|
9
|
+
exports.buildStoredSnapshot = buildStoredSnapshot;
|
|
10
|
+
exports.saveSnapshot = saveSnapshot;
|
|
11
|
+
exports.loadSnapshot = loadSnapshot;
|
|
12
|
+
exports.resolveRef = resolveRef;
|
|
13
|
+
/**
|
|
14
|
+
* Snapshot-scoped ephemeral element refs.
|
|
15
|
+
*
|
|
16
|
+
* `capture-ui` assigns each accessible element a short ref (`@e1`, `@e2`, …) and
|
|
17
|
+
* persists its resolved screen coordinates here, keyed by session. `tap-on @e3`
|
|
18
|
+
* then taps the cached point directly — no fuzzy text/id matching.
|
|
19
|
+
*
|
|
20
|
+
* Refs are deliberately ephemeral: a stale snapshot warns (it does not hard-fail),
|
|
21
|
+
* and the agent is expected to re-run `capture-ui` and act on fresh refs.
|
|
22
|
+
*/
|
|
23
|
+
const promises_1 = __importDefault(require("fs/promises"));
|
|
24
|
+
const os_1 = __importDefault(require("os"));
|
|
25
|
+
const path_1 = __importDefault(require("path"));
|
|
26
|
+
const SNAPSHOTS_DIR = path_1.default.join(os_1.default.homedir(), '.conductor', 'snapshots');
|
|
27
|
+
/** A snapshot older than this is considered stale. */
|
|
28
|
+
exports.SNAPSHOT_STALE_MS = 60000;
|
|
29
|
+
function snapshotFilePath(sessionName = 'default') {
|
|
30
|
+
return path_1.default.join(SNAPSHOTS_DIR, `${sessionName}.json`);
|
|
31
|
+
}
|
|
32
|
+
/** True when `s` looks like an ephemeral element ref (`@e3`). */
|
|
33
|
+
function isRefQuery(s) {
|
|
34
|
+
return /^@e\d+$/i.test(s.trim());
|
|
35
|
+
}
|
|
36
|
+
/** Build a `StoredSnapshot` from a freshly built a11y snapshot. */
|
|
37
|
+
function buildStoredSnapshot(entries, device) {
|
|
38
|
+
const refs = {};
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
refs[e.ref] = {
|
|
41
|
+
ref: e.ref,
|
|
42
|
+
centerX: e.frame.x + e.frame.w / 2,
|
|
43
|
+
centerY: e.frame.y + e.frame.h / 2,
|
|
44
|
+
frame: e.frame,
|
|
45
|
+
label: e.label,
|
|
46
|
+
nodeId: e.nodeId,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
version: 1,
|
|
51
|
+
capturedAt: new Date().toISOString(),
|
|
52
|
+
deviceId: device.deviceId,
|
|
53
|
+
platform: device.platform,
|
|
54
|
+
refs,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async function saveSnapshot(sessionName, snapshot) {
|
|
58
|
+
await promises_1.default.mkdir(SNAPSHOTS_DIR, { recursive: true });
|
|
59
|
+
await promises_1.default.writeFile(snapshotFilePath(sessionName), JSON.stringify(snapshot, null, 2));
|
|
60
|
+
}
|
|
61
|
+
async function loadSnapshot(sessionName) {
|
|
62
|
+
try {
|
|
63
|
+
const data = await promises_1.default.readFile(snapshotFilePath(sessionName), 'utf-8');
|
|
64
|
+
return JSON.parse(data);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve an `@eN` ref against the session's last `capture-ui` snapshot.
|
|
72
|
+
* Throws when there is no snapshot or the ref is unknown. `staleReason` is
|
|
73
|
+
* advisory — callers warn but still act, since refs are explicitly ephemeral.
|
|
74
|
+
*/
|
|
75
|
+
function resolveRef(snapshot, ref, ctx) {
|
|
76
|
+
if (!snapshot) {
|
|
77
|
+
throw new Error(`no snapshot for this session — run \`conductor capture-ui\` before using ${ref}`);
|
|
78
|
+
}
|
|
79
|
+
const norm = ref.trim().toLowerCase();
|
|
80
|
+
const key = Object.keys(snapshot.refs).find((k) => k.toLowerCase() === norm);
|
|
81
|
+
if (!key) {
|
|
82
|
+
const avail = Object.keys(snapshot.refs);
|
|
83
|
+
const shown = avail.slice(0, 8).join(', ');
|
|
84
|
+
throw new Error(`${ref} is not in the last snapshot ` +
|
|
85
|
+
`(${avail.length} ref${avail.length === 1 ? '' : 's'}: ${shown}${avail.length > 8 ? ', …' : ''})`);
|
|
86
|
+
}
|
|
87
|
+
let staleReason = null;
|
|
88
|
+
const ageMs = Date.now() - new Date(snapshot.capturedAt).getTime();
|
|
89
|
+
if (ageMs > exports.SNAPSHOT_STALE_MS) {
|
|
90
|
+
staleReason = `snapshot is ${Math.round(ageMs / 1000)}s old`;
|
|
91
|
+
}
|
|
92
|
+
else if (ctx?.deviceId && snapshot.deviceId && ctx.deviceId !== snapshot.deviceId) {
|
|
93
|
+
staleReason = `snapshot was captured on a different device (${snapshot.deviceId})`;
|
|
94
|
+
}
|
|
95
|
+
return { entry: snapshot.refs[key], staleReason };
|
|
96
|
+
}
|