@houwert/conductor 0.17.0 → 0.18.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/set-viewport.js +73 -0
- package/dist/commands/tap.js +15 -2
- package/dist/drivers/a11y.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 +27 -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);
|
|
@@ -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/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,
|
|
@@ -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,6 +509,26 @@ 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']);
|
|
@@ -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
|
+
}
|