@houwert/conductor 0.33.1 → 0.33.3
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.
|
@@ -4,6 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.HELP = void 0;
|
|
7
|
+
exports.captureScreen = captureScreen;
|
|
8
|
+
exports.iosHierarchySize = iosHierarchySize;
|
|
9
|
+
exports.cropRect = cropRect;
|
|
7
10
|
exports.screenshot = screenshot;
|
|
8
11
|
exports.HELP = ` take-screenshot [<element>] [--output <path>] [--full-page] [--display <panel>]
|
|
9
12
|
Take screenshot (--full-page: web only, capture entire scrollable page)
|
|
@@ -39,8 +42,8 @@ const roku_js_1 = require("../drivers/roku.js");
|
|
|
39
42
|
const wait_js_1 = require("../drivers/wait.js");
|
|
40
43
|
const direct_ios_selector_js_1 = require("../drivers/direct-ios-selector.js");
|
|
41
44
|
const png_crop_js_1 = require("../png-crop.js");
|
|
42
|
-
const DEFAULT_MARGIN_PX = 8;
|
|
43
45
|
const exec = (0, util_1.promisify)(child_process_1.execFile);
|
|
46
|
+
const DEFAULT_MARGIN_PX = 8;
|
|
44
47
|
/**
|
|
45
48
|
* Grab the screen, pointing at the right panel on a multi-display device.
|
|
46
49
|
*
|
|
@@ -50,15 +53,23 @@ const exec = (0, util_1.promisify)(child_process_1.execFile);
|
|
|
50
53
|
* the live one through simctl instead. Ordinary devices keep the driver path.
|
|
51
54
|
*/
|
|
52
55
|
async function captureScreen(driver, opts, displayOverride) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
if (!(driver instanceof ios_js_1.IOSDriver)) {
|
|
57
|
+
if (displayOverride) {
|
|
58
|
+
throw new Error('--display is iOS-only; other platforms expose a single screen');
|
|
59
|
+
}
|
|
60
|
+
return { buffer: await driver.screenshot(opts), redirected: false };
|
|
61
|
+
}
|
|
62
|
+
const deviceId = driver.deviceId;
|
|
63
|
+
if (!deviceId) {
|
|
64
|
+
if (displayOverride)
|
|
65
|
+
throw new Error('--display needs a device to query for its displays');
|
|
66
|
+
return { buffer: await driver.screenshot(opts), redirected: false };
|
|
56
67
|
}
|
|
57
68
|
const displays = await (0, devicectl_js_1.listDisplays)(deviceId).catch(() => []);
|
|
58
69
|
if (!displays.length) {
|
|
59
70
|
if (displayOverride)
|
|
60
71
|
throw new Error("could not read this device's displays");
|
|
61
|
-
return await driver.screenshot(opts);
|
|
72
|
+
return { buffer: await driver.screenshot(opts), redirected: false };
|
|
62
73
|
}
|
|
63
74
|
const choice = (0, ios_displays_js_1.pickCaptureDisplay)(displays, displayOverride);
|
|
64
75
|
if (choice.error)
|
|
@@ -66,7 +77,8 @@ async function captureScreen(driver, opts, displayOverride) {
|
|
|
66
77
|
const primary = displays.find((d) => d.primary);
|
|
67
78
|
// Nothing to redirect: the driver already captures the primary panel.
|
|
68
79
|
if (choice.displayId === null || (primary && choice.displayId === primary.displayId)) {
|
|
69
|
-
|
|
80
|
+
const live = choice.displayId === null || primary?.active !== false;
|
|
81
|
+
return { buffer: await driver.screenshot(opts), redirected: false, live };
|
|
70
82
|
}
|
|
71
83
|
const file = path_1.default.join(os_1.default.tmpdir(), `conductor-shot-${Date.now()}.png`);
|
|
72
84
|
try {
|
|
@@ -79,12 +91,53 @@ async function captureScreen(driver, opts, displayOverride) {
|
|
|
79
91
|
String(choice.displayId),
|
|
80
92
|
file,
|
|
81
93
|
]);
|
|
82
|
-
|
|
94
|
+
const panel = displays.find((d) => d.displayId === choice.displayId);
|
|
95
|
+
return {
|
|
96
|
+
buffer: await promises_1.default.readFile(file),
|
|
97
|
+
redirected: true,
|
|
98
|
+
scale: panel?.pointScale,
|
|
99
|
+
live: panel?.active,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
throw new Error(`could not capture display ${choice.displayId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
83
104
|
}
|
|
84
105
|
finally {
|
|
85
106
|
await promises_1.default.unlink(file).catch(() => { });
|
|
86
107
|
}
|
|
87
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* The size of the coordinate space the view hierarchy reports, in its own
|
|
111
|
+
* units, for an iOS capture.
|
|
112
|
+
*
|
|
113
|
+
* `deviceInfo()` describes `XCUIScreen.main`, which is the wrong panel for a
|
|
114
|
+
* redirected capture: on an unfolded foldable the hierarchy is in the inner
|
|
115
|
+
* panel's points while deviceInfo still reports the cover's. Fall back to the
|
|
116
|
+
* captured image divided by that panel's own scale, which is exact.
|
|
117
|
+
*/
|
|
118
|
+
function iosHierarchySize(capture, png, deviceInfo) {
|
|
119
|
+
if (capture.redirected && capture.scale) {
|
|
120
|
+
return { width: png.width / capture.scale, height: png.height / capture.scale };
|
|
121
|
+
}
|
|
122
|
+
return { width: deviceInfo.widthPoints, height: deviceInfo.heightPoints };
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Map element bounds onto screenshot pixels. The margin is in the same logical
|
|
126
|
+
* units as the bounds (points on iOS, pixels on Android/Web — what `inspect`
|
|
127
|
+
* prints), so it scales alongside them.
|
|
128
|
+
*/
|
|
129
|
+
function cropRect(bounds, hierarchy, png, margin) {
|
|
130
|
+
const scaleX = hierarchy.width > 0 ? png.width / hierarchy.width : 1;
|
|
131
|
+
const scaleY = hierarchy.height > 0 ? png.height / hierarchy.height : 1;
|
|
132
|
+
const marginX = margin * scaleX;
|
|
133
|
+
const marginY = margin * scaleY;
|
|
134
|
+
return {
|
|
135
|
+
x: Math.round(bounds.x * scaleX - marginX),
|
|
136
|
+
y: Math.round(bounds.y * scaleY - marginY),
|
|
137
|
+
width: Math.round(bounds.width * scaleX + marginX * 2),
|
|
138
|
+
height: Math.round(bounds.height * scaleY + marginY * 2),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
88
141
|
async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPage = false, query = '', flags = {}) {
|
|
89
142
|
const timestamp = Date.now();
|
|
90
143
|
const defaultName = `screenshot-${timestamp}.png`;
|
|
@@ -115,8 +168,13 @@ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPa
|
|
|
115
168
|
: '';
|
|
116
169
|
const margin = flags.margin ?? DEFAULT_MARGIN_PX;
|
|
117
170
|
const result = await (0, runner_js_1.runDirect)(async (driver) => {
|
|
118
|
-
const buf = await captureScreen(driver, { fullPage }, flags.display);
|
|
171
|
+
const { buffer: buf, redirected, scale, live, } = await captureScreen(driver, { fullPage }, flags.display);
|
|
119
172
|
let out = buf;
|
|
173
|
+
if (sel && live === false) {
|
|
174
|
+
throw new Error(`cannot crop to ${label} on this display: it is powered off, and the view ` +
|
|
175
|
+
'hierarchy describes whichever panel is live. Drop --display to capture ' +
|
|
176
|
+
'the live panel, or fold the device so this one takes over.');
|
|
177
|
+
}
|
|
120
178
|
if (sel) {
|
|
121
179
|
let el;
|
|
122
180
|
let hierarchyW;
|
|
@@ -126,10 +184,12 @@ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPa
|
|
|
126
184
|
// around the foreground app + status bars and has frame=.zero, so
|
|
127
185
|
// reading scale from it would always collapse to 1× and crop the
|
|
128
186
|
// wrong region on retina/4K screens. Use deviceInfo, which reports
|
|
129
|
-
// both points (AX space) and pixels (screenshot space)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
187
|
+
// both points (AX space) and pixels (screenshot space) — except for a
|
|
188
|
+
// redirected capture, where deviceInfo describes XCUIScreen.main and
|
|
189
|
+
// not the panel we captured, so derive the points from its own scale.
|
|
190
|
+
const size = iosHierarchySize({ redirected, scale }, (0, png_crop_js_1.readPngDimensions)(buf), await driver.deviceInfo());
|
|
191
|
+
hierarchyW = size.width;
|
|
192
|
+
hierarchyH = size.height;
|
|
133
193
|
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));
|
|
134
194
|
}
|
|
135
195
|
else if (driver instanceof web_js_1.WebDriver) {
|
|
@@ -153,21 +213,14 @@ async function screenshot(outputPath, opts = {}, sessionName = 'default', fullPa
|
|
|
153
213
|
throw new Error('selector cropping is not supported for this driver');
|
|
154
214
|
}
|
|
155
215
|
const { width: pngW, height: pngH } = (0, png_crop_js_1.readPngDimensions)(buf);
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const marginY = margin * scaleY;
|
|
163
|
-
const rectX = Math.round(el.bounds.x * scaleX - marginX);
|
|
164
|
-
const rectY = Math.round(el.bounds.y * scaleY - marginY);
|
|
165
|
-
const rectW = Math.round(el.bounds.width * scaleX + marginX * 2);
|
|
166
|
-
const rectH = Math.round(el.bounds.height * scaleY + marginY * 2);
|
|
167
|
-
if (rectX + rectW <= 0 || rectY + rectH <= 0 || rectX >= pngW || rectY >= pngH) {
|
|
168
|
-
throw new Error(`element ${label} bounds [${rectX},${rectY} ${rectW}x${rectH}] are outside the screenshot (${pngW}x${pngH})`);
|
|
216
|
+
const rect = cropRect(el.bounds, { width: hierarchyW, height: hierarchyH }, { width: pngW, height: pngH }, margin);
|
|
217
|
+
if (rect.x + rect.width <= 0 ||
|
|
218
|
+
rect.y + rect.height <= 0 ||
|
|
219
|
+
rect.x >= pngW ||
|
|
220
|
+
rect.y >= pngH) {
|
|
221
|
+
throw new Error(`element ${label} bounds [${rect.x},${rect.y} ${rect.width}x${rect.height}] are outside the screenshot (${pngW}x${pngH})`);
|
|
169
222
|
}
|
|
170
|
-
out = (0, png_crop_js_1.cropPng)(buf,
|
|
223
|
+
out = (0, png_crop_js_1.cropPng)(buf, rect);
|
|
171
224
|
}
|
|
172
225
|
await promises_1.default.writeFile(resolvedPath, out);
|
|
173
226
|
}, sessionName);
|
package/package.json
CHANGED
|
@@ -45,6 +45,11 @@ Pass `--display cover` or `--display inner` to pin it to one panel, or a display
|
|
|
45
45
|
id for anything else the device has attached (CarPlay, an external screen). An
|
|
46
46
|
unknown value lists that device's displays with their ids.
|
|
47
47
|
|
|
48
|
+
Cropping to an element (`take-screenshot <element>`) works on either panel. The
|
|
49
|
+
one thing it cannot do is crop against a panel that is powered off — the view
|
|
50
|
+
hierarchy always describes the live one — so combining a selector with a
|
|
51
|
+
`--display` that points at the dark panel is an error rather than a wrong crop.
|
|
52
|
+
|
|
48
53
|
Angles are in degrees, 0 (shut) to 180 (flat). `closed`/`book`/`open` map to
|
|
49
54
|
0/130/180. Named poses always swap the display; an arbitrary mid-way angle sets
|
|
50
55
|
the hinge but may leave the cover display active, because the system decides
|