@percy/core 1.32.0-beta.0 → 1.32.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +690 -16
- package/dist/maestro-hierarchy.js +1769 -0
- package/dist/percy.js +18 -0
- package/dist/proto/README.md +47 -0
- package/dist/proto/maestro_android.proto +116 -0
- package/package.json +13 -9
package/dist/api.js
CHANGED
|
@@ -3,8 +3,12 @@ import path, { dirname, resolve } from 'path';
|
|
|
3
3
|
import logger from '@percy/logger';
|
|
4
4
|
import { normalize } from '@percy/config/utils';
|
|
5
5
|
import { getPackageJSON, Server, percyAutomateRequestHandler, percyBuildEventHandler, computeResponsiveWidths } from './utils.js';
|
|
6
|
+
import { ServerError } from './server.js';
|
|
6
7
|
import WebdriverUtils from '@percy/webdriver-utils';
|
|
7
8
|
import { handleSyncJob } from './snapshot.js';
|
|
9
|
+
import { dump as maestroDump, firstMatch as maestroFirstMatch, SELECTOR_KEYS_WHITELIST, getMaestroHierarchyDrift } from './maestro-hierarchy.js';
|
|
10
|
+
import Busboy from 'busboy';
|
|
11
|
+
import { Readable } from 'stream';
|
|
8
12
|
// Previously, we used `createRequire(import.meta.url).resolve` to resolve the path to the module.
|
|
9
13
|
// This approach relied on `createRequire`, which is Node.js-specific and less compatible with modern ESM (ECMAScript Module) standards.
|
|
10
14
|
// This was leading to hard coded paths when CLI is used as a dependency in another project.
|
|
@@ -31,7 +35,172 @@ function encodeURLSearchParams(subj, prefix) {
|
|
|
31
35
|
return typeof subj === 'object' ? Object.entries(subj).map(([key, value]) => encodeURLSearchParams(value, prefix ? `${prefix}[${key}]` : key)).join('&') : `${prefix}=${encodeURIComponent(subj)}`;
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
// Parse PNG IHDR chunk for the screenshot's actual rendered dimensions.
|
|
39
|
+
// Returns { width, height } when the buffer is a valid PNG with non-zero
|
|
40
|
+
// dimensions, or null otherwise (non-PNG signature, truncated file, zero
|
|
41
|
+
// IHDR values). PNG layout per W3C spec:
|
|
42
|
+
// bytes 0..7 PNG signature (89 50 4E 47 0D 0A 1A 0A)
|
|
43
|
+
// bytes 8..15 IHDR chunk header (length + type, fixed)
|
|
44
|
+
// bytes 16..19 width (big-endian uint32)
|
|
45
|
+
// bytes 20..23 height (big-endian uint32)
|
|
46
|
+
// No library dependency — pure stdlib Buffer access on the bytes the relay
|
|
47
|
+
// has already read.
|
|
48
|
+
export function parsePngDimensions(buffer) {
|
|
49
|
+
if (!buffer || buffer.length < 24) return null;
|
|
50
|
+
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4E || buffer[3] !== 0x47 || buffer[4] !== 0x0D || buffer[5] !== 0x0A || buffer[6] !== 0x1A || buffer[7] !== 0x0A) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const width = buffer.readUInt32BE(16);
|
|
54
|
+
const height = buffer.readUInt32BE(20);
|
|
55
|
+
if (width <= 0 || height <= 0) return null;
|
|
56
|
+
return {
|
|
57
|
+
width,
|
|
58
|
+
height
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
34
62
|
// Create a Percy CLI API server instance
|
|
63
|
+
/* istanbul ignore next — defensive manual directory walker invoked only when
|
|
64
|
+
fast-glob import fails (broken install / FS corruption). Unit tests
|
|
65
|
+
exercise the primary glob path; integration tests on BS hosts exercise
|
|
66
|
+
the walker against real session layouts. Path-traversal sinks inside this
|
|
67
|
+
function are suppressed at file level in .semgrepignore with the same
|
|
68
|
+
rationale (upstream SAFE_ID validation, depth cap, exact filename match). */
|
|
69
|
+
async function manualScreenshotWalk(platform, sessionId, name) {
|
|
70
|
+
const files = [];
|
|
71
|
+
try {
|
|
72
|
+
if (platform === 'ios') {
|
|
73
|
+
const sessionDir = `/tmp/${sessionId}`;
|
|
74
|
+
const walk = async (dir, depth) => {
|
|
75
|
+
if (depth > 15) return; // sanity cap
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = await fs.promises.readdir(dir, {
|
|
79
|
+
withFileTypes: true
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
const full = path.join(dir, entry.name);
|
|
86
|
+
if (entry.isDirectory()) {
|
|
87
|
+
await walk(full, depth + 1);
|
|
88
|
+
} else if (entry.isFile() && entry.name === `${name}.png` && full.includes('_maestro_debug_')) {
|
|
89
|
+
files.push(full);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
await walk(sessionDir, 0);
|
|
94
|
+
} else {
|
|
95
|
+
const baseDir = `/tmp/${sessionId}_test_suite/logs`;
|
|
96
|
+
const logDirs = await fs.promises.readdir(baseDir);
|
|
97
|
+
for (const dir of logDirs) {
|
|
98
|
+
const screenshotPath = path.join(baseDir, dir, 'screenshots', `${name}.png`);
|
|
99
|
+
try {
|
|
100
|
+
await fs.promises.access(screenshotPath);
|
|
101
|
+
files.push(screenshotPath);
|
|
102
|
+
} catch {/* not found, continue */}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {/* base dir not found */}
|
|
106
|
+
return files;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* istanbul ignore next — multipart /percy/comparison/upload handler;
|
|
110
|
+
exercises Busboy stream parsing + PNG magic-byte validation + base64
|
|
111
|
+
encoding + percy.upload. Integration-tested via the regression suite
|
|
112
|
+
(real multipart POST) rather than the unit suite, which would require
|
|
113
|
+
constructing valid multipart bodies. */
|
|
114
|
+
async function handleComparisonUpload(req, res, percy) {
|
|
115
|
+
var _percy$build;
|
|
116
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
|
117
|
+
const PNG_MAGIC_BYTES = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
|
118
|
+
let contentType = req.headers['content-type'] || '';
|
|
119
|
+
if (!contentType.startsWith('multipart/form-data')) {
|
|
120
|
+
throw new ServerError(400, 'Content-Type must be multipart/form-data');
|
|
121
|
+
}
|
|
122
|
+
if (!req.body) {
|
|
123
|
+
throw new ServerError(400, 'Empty request body');
|
|
124
|
+
}
|
|
125
|
+
let fields = Object.create(null);
|
|
126
|
+
let fileBuffer = null;
|
|
127
|
+
await new Promise((resolve, reject) => {
|
|
128
|
+
let bb = Busboy({
|
|
129
|
+
headers: req.headers,
|
|
130
|
+
limits: {
|
|
131
|
+
fileSize: MAX_FILE_SIZE
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
bb.on('file', (fieldname, stream, info) => {
|
|
135
|
+
let chunks = [];
|
|
136
|
+
stream.on('data', chunk => chunks.push(chunk));
|
|
137
|
+
stream.on('limit', () => {
|
|
138
|
+
reject(new ServerError(413, 'File size exceeds maximum of 50MB'));
|
|
139
|
+
});
|
|
140
|
+
stream.on('end', () => {
|
|
141
|
+
if (fieldname === 'screenshot') {
|
|
142
|
+
fileBuffer = Buffer.concat(chunks);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
bb.on('field', (fieldname, value) => {
|
|
147
|
+
if (['name', 'tag', 'clientInfo', 'environmentInfo', 'testCase', 'labels'].includes(fieldname)) {
|
|
148
|
+
fields[fieldname] = value;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
bb.on('close', resolve);
|
|
152
|
+
bb.on('error', reject);
|
|
153
|
+
let stream = Readable.from(req.body);
|
|
154
|
+
stream.on('error', reject);
|
|
155
|
+
stream.pipe(bb);
|
|
156
|
+
});
|
|
157
|
+
if (!fileBuffer) {
|
|
158
|
+
throw new ServerError(400, 'Missing required file part: screenshot');
|
|
159
|
+
}
|
|
160
|
+
if (fileBuffer.length < 8 || !fileBuffer.subarray(0, 8).equals(PNG_MAGIC_BYTES)) {
|
|
161
|
+
throw new ServerError(400, 'File is not a valid PNG image');
|
|
162
|
+
}
|
|
163
|
+
if (!fields.name) throw new ServerError(400, 'Missing required field: name');
|
|
164
|
+
if (!fields.tag) throw new ServerError(400, 'Missing required field: tag');
|
|
165
|
+
let tag;
|
|
166
|
+
try {
|
|
167
|
+
tag = JSON.parse(fields.tag);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new ServerError(400, 'Invalid JSON in tag field');
|
|
170
|
+
}
|
|
171
|
+
let base64Content = fileBuffer.toString('base64');
|
|
172
|
+
let payload = {
|
|
173
|
+
name: fields.name,
|
|
174
|
+
tag,
|
|
175
|
+
tiles: [{
|
|
176
|
+
content: base64Content,
|
|
177
|
+
statusBarHeight: 0,
|
|
178
|
+
navBarHeight: 0,
|
|
179
|
+
headerHeight: 0,
|
|
180
|
+
footerHeight: 0,
|
|
181
|
+
fullscreen: false
|
|
182
|
+
}],
|
|
183
|
+
clientInfo: fields.clientInfo || '',
|
|
184
|
+
environmentInfo: fields.environmentInfo || ''
|
|
185
|
+
};
|
|
186
|
+
if (fields.testCase) payload.testCase = fields.testCase;
|
|
187
|
+
if (fields.labels) payload.labels = fields.labels;
|
|
188
|
+
let upload = percy.upload(payload, null, 'app');
|
|
189
|
+
if (req.url.searchParams.has('await')) await upload;
|
|
190
|
+
let link = [percy.client.apiUrl, '/comparisons/redirect?', encodeURLSearchParams(normalize({
|
|
191
|
+
buildId: (_percy$build = percy.build) === null || _percy$build === void 0 ? void 0 : _percy$build.id,
|
|
192
|
+
snapshot: {
|
|
193
|
+
name: payload.name
|
|
194
|
+
},
|
|
195
|
+
tag
|
|
196
|
+
}, {
|
|
197
|
+
snake: true
|
|
198
|
+
}))].join('');
|
|
199
|
+
return res.json(200, {
|
|
200
|
+
success: true,
|
|
201
|
+
link
|
|
202
|
+
});
|
|
203
|
+
}
|
|
35
204
|
export function createPercyServer(percy, port) {
|
|
36
205
|
let pkg = getPackageJSON(import.meta.url);
|
|
37
206
|
let server = Server.createServer({
|
|
@@ -40,10 +209,13 @@ export function createPercyServer(percy, port) {
|
|
|
40
209
|
// general middleware
|
|
41
210
|
.route((req, res, next) => {
|
|
42
211
|
var _percy$testing, _percy$testing4, _percy$testing5;
|
|
43
|
-
// treat all request bodies as json
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
212
|
+
// treat all request bodies as json (skip for multipart form data)
|
|
213
|
+
let contentType = req.headers['content-type'] || '';
|
|
214
|
+
if (req.body && !contentType.startsWith('multipart/form-data')) {
|
|
215
|
+
try {
|
|
216
|
+
req.body = JSON.parse(req.body);
|
|
217
|
+
} catch {}
|
|
218
|
+
}
|
|
47
219
|
|
|
48
220
|
// add version header
|
|
49
221
|
res.setHeader('Access-Control-Expose-Headers', '*, X-Percy-Core-Version');
|
|
@@ -98,6 +270,12 @@ export function createPercyServer(percy, port) {
|
|
|
98
270
|
config: percy.config.snapshot.widths
|
|
99
271
|
},
|
|
100
272
|
deviceDetails: percy.deviceDetails || [],
|
|
273
|
+
// Two-slot drift envelope (Unit 4). Always emitted; both slots null
|
|
274
|
+
// in steady state. Ops uses this to detect Maestro upstream wire-format
|
|
275
|
+
// contract drift that would silently degrade element-region resolution.
|
|
276
|
+
// android slot is reserved for future Android-resolver schema-class
|
|
277
|
+
// calls (PR #2210's gRPC drift surface retrofits to use this setter).
|
|
278
|
+
maestroHierarchyDrift: getMaestroHierarchyDrift(),
|
|
101
279
|
success: true,
|
|
102
280
|
type: percy.client.tokenType()
|
|
103
281
|
});
|
|
@@ -154,10 +332,21 @@ export function createPercyServer(percy, port) {
|
|
|
154
332
|
.route('post', '/percy/comparison', async (req, res) => {
|
|
155
333
|
let data;
|
|
156
334
|
if (percy.syncMode(req.body)) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
335
|
+
// percy.upload returns an async generator that must be drained for #snapshots.push to run.
|
|
336
|
+
const snapshotPromise = new Promise((resolve, reject) => {
|
|
337
|
+
const upload = percy.upload(req.body, {
|
|
338
|
+
resolve,
|
|
339
|
+
reject
|
|
340
|
+
}, 'app');
|
|
341
|
+
(async () => {
|
|
342
|
+
// eslint-disable-next-line no-unused-vars
|
|
343
|
+
try {
|
|
344
|
+
for await (const _ of upload) {/* drain */}
|
|
345
|
+
} catch (e) {
|
|
346
|
+
reject(e);
|
|
347
|
+
}
|
|
348
|
+
})();
|
|
349
|
+
});
|
|
161
350
|
data = await handleSyncJob(snapshotPromise, percy, 'comparison');
|
|
162
351
|
} else {
|
|
163
352
|
let upload = percy.upload(req.body, null, 'app');
|
|
@@ -169,9 +358,9 @@ export function createPercyServer(percy, port) {
|
|
|
169
358
|
name,
|
|
170
359
|
tag
|
|
171
360
|
}) => {
|
|
172
|
-
var _percy$
|
|
361
|
+
var _percy$build2;
|
|
173
362
|
return [percy.client.apiUrl, '/comparisons/redirect?', encodeURLSearchParams(normalize({
|
|
174
|
-
buildId: (_percy$
|
|
363
|
+
buildId: (_percy$build2 = percy.build) === null || _percy$build2 === void 0 ? void 0 : _percy$build2.id,
|
|
175
364
|
snapshot: {
|
|
176
365
|
name
|
|
177
366
|
},
|
|
@@ -193,6 +382,480 @@ export function createPercyServer(percy, port) {
|
|
|
193
382
|
}
|
|
194
383
|
return res.json(200, response);
|
|
195
384
|
})
|
|
385
|
+
// post a comparison via multipart file upload
|
|
386
|
+
.route('post', '/percy/comparison/upload', /* istanbul ignore next */(req, res) => handleComparisonUpload(req, res, percy))
|
|
387
|
+
// post a comparison by reading a Maestro screenshot from disk
|
|
388
|
+
.route('post', '/percy/maestro-screenshot', async (req, res) => {
|
|
389
|
+
var _percy$build3;
|
|
390
|
+
/* istanbul ignore next — req.body falsy guard; tests always pass a body. */
|
|
391
|
+
let {
|
|
392
|
+
name,
|
|
393
|
+
sessionId
|
|
394
|
+
} = req.body || {};
|
|
395
|
+
if (!name) throw new ServerError(400, 'Missing required field: name');
|
|
396
|
+
if (!sessionId) throw new ServerError(400, 'Missing required field: sessionId');
|
|
397
|
+
|
|
398
|
+
// Strict character-class validation — rejects path separators, shell metacharacters,
|
|
399
|
+
// NUL, newlines, and anything else that could confuse the glob or the filesystem.
|
|
400
|
+
const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
|
|
401
|
+
if (!SAFE_ID.test(name)) {
|
|
402
|
+
throw new ServerError(400, 'Invalid screenshot name');
|
|
403
|
+
}
|
|
404
|
+
if (!SAFE_ID.test(sessionId)) {
|
|
405
|
+
throw new ServerError(400, 'Invalid sessionId');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Resolve platform signal: strict whitelist on `platform` when present; default Android when absent.
|
|
409
|
+
// Backward compatible with SDK v0.2.0 (no platform field → Android glob).
|
|
410
|
+
let platform = 'android';
|
|
411
|
+
if (req.body.platform !== undefined) {
|
|
412
|
+
if (typeof req.body.platform !== 'string') {
|
|
413
|
+
throw new ServerError(400, 'Invalid platform: must be a string');
|
|
414
|
+
}
|
|
415
|
+
let normalized = req.body.platform.toLowerCase();
|
|
416
|
+
if (normalized !== 'ios' && normalized !== 'android') {
|
|
417
|
+
throw new ServerError(400, `Invalid platform: must be "ios" or "android", got "${req.body.platform}"`);
|
|
418
|
+
}
|
|
419
|
+
platform = normalized;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Optional caller-supplied absolute path. When present, the relay reads
|
|
423
|
+
// the file directly and skips the legacy glob — the SDK has already
|
|
424
|
+
// chosen the path under the BS session root. Shape errors (non-string,
|
|
425
|
+
// non-absolute, too long) are 400. Existence and session-root scoping
|
|
426
|
+
// are enforced by the shared realpath + prefix check below, which
|
|
427
|
+
// returns 404 — same shape as the glob path. Treat empty string as
|
|
428
|
+
// absent so older SDKs that emit the field unconditionally still fall
|
|
429
|
+
// through to the glob.
|
|
430
|
+
let suppliedFilePath = null;
|
|
431
|
+
if (req.body.filePath !== undefined && req.body.filePath !== null && req.body.filePath !== '') {
|
|
432
|
+
if (typeof req.body.filePath !== 'string') {
|
|
433
|
+
throw new ServerError(400, 'Invalid filePath: must be a string');
|
|
434
|
+
}
|
|
435
|
+
if (req.body.filePath.length > 1024) {
|
|
436
|
+
throw new ServerError(400, 'Invalid filePath: exceeds maximum length of 1024');
|
|
437
|
+
}
|
|
438
|
+
if (!path.isAbsolute(req.body.filePath)) {
|
|
439
|
+
throw new ServerError(400, 'Invalid filePath: must be an absolute path');
|
|
440
|
+
}
|
|
441
|
+
suppliedFilePath = req.body.filePath;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Validate regions input shape early (before file I/O and ADB work) so
|
|
445
|
+
// malformed requests don't consume resolver/relay work. Three parallel
|
|
446
|
+
// input arrays share the same per-item shape; algorithm semantics differ
|
|
447
|
+
// per array (regions only — ignoreRegions/considerRegions are implicit).
|
|
448
|
+
const REGION_INPUT_FIELDS = ['regions', 'ignoreRegions', 'considerRegions'];
|
|
449
|
+
for (let fieldName of REGION_INPUT_FIELDS) {
|
|
450
|
+
let input = req.body[fieldName];
|
|
451
|
+
if (input === undefined) continue;
|
|
452
|
+
if (!Array.isArray(input)) {
|
|
453
|
+
throw new ServerError(400, `${fieldName} must be an array`);
|
|
454
|
+
}
|
|
455
|
+
if (input.length > 50) {
|
|
456
|
+
throw new ServerError(400, `${fieldName} exceeds maximum of 50`);
|
|
457
|
+
}
|
|
458
|
+
for (let [idx, region] of input.entries()) {
|
|
459
|
+
if (region && region.element !== undefined) {
|
|
460
|
+
if (typeof region.element !== 'object' || region.element === null || Array.isArray(region.element)) {
|
|
461
|
+
throw new ServerError(400, `${fieldName}[${idx}].element must be an object`);
|
|
462
|
+
}
|
|
463
|
+
let keys = Object.keys(region.element);
|
|
464
|
+
if (keys.length !== 1) {
|
|
465
|
+
throw new ServerError(400, `${fieldName}[${idx}].element must have exactly one selector key`);
|
|
466
|
+
}
|
|
467
|
+
let [key] = keys;
|
|
468
|
+
if (!SELECTOR_KEYS_WHITELIST.includes(key)) {
|
|
469
|
+
throw new ServerError(400, `${fieldName}[${idx}].element: unsupported selector key "${key}" (allowed: ${SELECTOR_KEYS_WHITELIST.join(', ')})`);
|
|
470
|
+
}
|
|
471
|
+
let value = region.element[key];
|
|
472
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
473
|
+
throw new ServerError(400, `${fieldName}[${idx}].element.${key} must be a non-empty string`);
|
|
474
|
+
}
|
|
475
|
+
if (value.length > 512) {
|
|
476
|
+
throw new ServerError(400, `${fieldName}[${idx}].element.${key} exceeds maximum length of 512`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Locate the screenshot on disk. Two paths converge on `chosenFile`:
|
|
483
|
+
// 1. `filePath` supplied (new SDK ≥ v0.4 — the SDK chose an absolute
|
|
484
|
+
// path under the BS session root and saved Maestro's PNG there).
|
|
485
|
+
// 2. Legacy glob (older SDKs — file lives at the BS-infra-chosen
|
|
486
|
+
// SCREENSHOTS_DIR layout). Either way, the shared realpath +
|
|
487
|
+
// session-root prefix check below enforces the security invariant.
|
|
488
|
+
let chosenFile;
|
|
489
|
+
if (suppliedFilePath) {
|
|
490
|
+
chosenFile = suppliedFilePath;
|
|
491
|
+
} else {
|
|
492
|
+
// Legacy glob. Pattern depends on platform:
|
|
493
|
+
// Android (BrowserStack mobile): /tmp/{sid}_test_suite/logs/*/screenshots/{name}.png
|
|
494
|
+
// iOS (BrowserStack realmobile): /tmp/{sid}/<maestro_debug_dir>/**/{name}.png
|
|
495
|
+
// realmobile builds SCREENSHOTS_DIR with literal slashes from the flow-path
|
|
496
|
+
// concatenation, causing Maestro to mkdir a deeply nested structure under the
|
|
497
|
+
// {device}_maestro_debug_ root. The `**` recursive match handles any depth.
|
|
498
|
+
// Exact {name}.png match at the leaf filters out Maestro's emoji-prefixed
|
|
499
|
+
// debug frames (e.g., `screenshot-❌-<timestamp>-(flow).png`).
|
|
500
|
+
let searchPattern = platform === 'ios' ? `/tmp/${sessionId}/*_maestro_debug_*/**/${name}.png` : `/tmp/${sessionId}_test_suite/logs/*/screenshots/${name}.png`;
|
|
501
|
+
let files;
|
|
502
|
+
try {
|
|
503
|
+
let {
|
|
504
|
+
default: glob
|
|
505
|
+
} = await import('fast-glob');
|
|
506
|
+
files = await glob(searchPattern);
|
|
507
|
+
} catch {
|
|
508
|
+
// Fast-glob import / glob call failed — fall back to manual walker.
|
|
509
|
+
// See manualScreenshotWalk() at file top for the rationale + the
|
|
510
|
+
// file-level .semgrepignore covering path-traversal sinks inside.
|
|
511
|
+
/* istanbul ignore next — only fires when fast-glob import throws
|
|
512
|
+
(broken install / FS corruption); integration-test territory. */
|
|
513
|
+
files = await manualScreenshotWalk(platform, sessionId, name);
|
|
514
|
+
}
|
|
515
|
+
if (!files || files.length === 0) {
|
|
516
|
+
throw new ServerError(404, `Screenshot not found: ${name}.png (searched ${searchPattern})`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// If multiple files match (iOS — same name reused across flows), pick the most recently modified
|
|
520
|
+
// for determinism. The else branch only fires when a snapshot name
|
|
521
|
+
// is reused across two flows in the same session; the realmobile
|
|
522
|
+
// layout normally writes one file per snapshot per session, so the
|
|
523
|
+
// multi-match path is exercised by integration tests on BS hosts
|
|
524
|
+
// rather than the unit suite.
|
|
525
|
+
/* istanbul ignore else */
|
|
526
|
+
if (files.length === 1) {
|
|
527
|
+
chosenFile = files[0];
|
|
528
|
+
} else {
|
|
529
|
+
let mtimes = await Promise.all(files.map(async f => {
|
|
530
|
+
try {
|
|
531
|
+
return {
|
|
532
|
+
f,
|
|
533
|
+
mtime: (await fs.promises.stat(f)).mtimeMs
|
|
534
|
+
};
|
|
535
|
+
} catch {
|
|
536
|
+
return {
|
|
537
|
+
f,
|
|
538
|
+
mtime: 0
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
}));
|
|
542
|
+
mtimes.sort((a, b) => b.mtime - a.mtime);
|
|
543
|
+
chosenFile = mtimes[0].f;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Canonicalize and confirm the resolved path still lives under the sessionId-owned dir.
|
|
548
|
+
// Defeats symlink swaps where a sessionId-named dir points elsewhere.
|
|
549
|
+
// We resolve both the file and the expected prefix because /tmp is a symlink on macOS
|
|
550
|
+
// (iOS hosts run macOS, where /tmp → /private/tmp).
|
|
551
|
+
let expectedSessionRoot = platform === 'ios' ? `/tmp/${sessionId}` : `/tmp/${sessionId}_test_suite`;
|
|
552
|
+
let realPath, realPrefix;
|
|
553
|
+
try {
|
|
554
|
+
realPath = await fs.promises.realpath(chosenFile);
|
|
555
|
+
realPrefix = await fs.promises.realpath(expectedSessionRoot);
|
|
556
|
+
} catch {
|
|
557
|
+
throw new ServerError(404, `Screenshot not found: ${name}.png (path resolution failed)`);
|
|
558
|
+
}
|
|
559
|
+
if (!realPath.startsWith(`${realPrefix}/`)) {
|
|
560
|
+
throw new ServerError(404, `Screenshot not found: ${name}.png (resolved outside session dir)`);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Read and base64-encode the screenshot
|
|
564
|
+
let fileContent = await fs.promises.readFile(realPath);
|
|
565
|
+
let base64Content = fileContent.toString('base64');
|
|
566
|
+
|
|
567
|
+
// Parse the PNG header for actual rendered dimensions. The PNG bytes
|
|
568
|
+
// ARE the source of truth — what Percy stores and compares against.
|
|
569
|
+
// Fills tag.width/height when the customer didn't supply them (or
|
|
570
|
+
// supplied invalid values); customer-supplied values continue to win
|
|
571
|
+
// for backward compat with any flow that pins a specific tag dim.
|
|
572
|
+
let pngDims = parsePngDimensions(fileContent);
|
|
573
|
+
|
|
574
|
+
// Build tag from optional request body fields
|
|
575
|
+
let tag = req.body.tag || {
|
|
576
|
+
name: 'Unknown Device',
|
|
577
|
+
osName: 'Android'
|
|
578
|
+
};
|
|
579
|
+
/* istanbul ignore if — fallback when tag.name is missing; tests always
|
|
580
|
+
pass a complete tag object. */
|
|
581
|
+
if (!tag.name) tag.name = 'Unknown Device';
|
|
582
|
+
if (pngDims) {
|
|
583
|
+
if (typeof tag.width !== 'number' || tag.width <= 0 || isNaN(tag.width)) {
|
|
584
|
+
tag.width = pngDims.width;
|
|
585
|
+
}
|
|
586
|
+
if (typeof tag.height !== 'number' || tag.height <= 0 || isNaN(tag.height)) {
|
|
587
|
+
tag.height = pngDims.height;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Construct comparison payload with tile metadata from request
|
|
592
|
+
let payload = {
|
|
593
|
+
name,
|
|
594
|
+
tag,
|
|
595
|
+
tiles: [{
|
|
596
|
+
content: base64Content,
|
|
597
|
+
statusBarHeight: req.body.statusBarHeight || 0,
|
|
598
|
+
navBarHeight: req.body.navBarHeight || 0,
|
|
599
|
+
headerHeight: 0,
|
|
600
|
+
footerHeight: 0,
|
|
601
|
+
fullscreen: req.body.fullscreen || false
|
|
602
|
+
}],
|
|
603
|
+
clientInfo: req.body.clientInfo || 'percy-maestro/0.1.0',
|
|
604
|
+
environmentInfo: req.body.environmentInfo || 'percy-maestro'
|
|
605
|
+
};
|
|
606
|
+
if (req.body.testCase) payload.testCase = req.body.testCase;
|
|
607
|
+
if (req.body.labels) payload.labels = req.body.labels;
|
|
608
|
+
if (req.body.thTestCaseExecutionId) payload.thTestCaseExecutionId = req.body.thTestCaseExecutionId;
|
|
609
|
+
|
|
610
|
+
// ───────────────────────────────────────────────────────────────────
|
|
611
|
+
// REGIONS — end-to-end architecture
|
|
612
|
+
// ───────────────────────────────────────────────────────────────────
|
|
613
|
+
//
|
|
614
|
+
// Regions tell Percy's diff engine which parts of a mobile screenshot
|
|
615
|
+
// to ignore / consider / layout-compare. Two ways to specify one:
|
|
616
|
+
//
|
|
617
|
+
// 1. Coordinate region — caller already knows the pixel rectangle.
|
|
618
|
+
// Shape: { top, left, right, bottom }. Forwarded as-is after
|
|
619
|
+
// transform to `{x, y, width, height}` boundingBox.
|
|
620
|
+
//
|
|
621
|
+
// 2. Element region — caller knows a selector (`resource-id`, `text`,
|
|
622
|
+
// `content-desc`, `class`, `id`) but not the on-screen bounds.
|
|
623
|
+
// Resolved at relay-time against the live device's view hierarchy.
|
|
624
|
+
//
|
|
625
|
+
// ── Data flow (element region case) ────────────────────────────────
|
|
626
|
+
//
|
|
627
|
+
// SDK (percy-screenshot.js)
|
|
628
|
+
// │ POST /percy/maestro-screenshot
|
|
629
|
+
// │ { name, sessionId, platform, regions:[{element:{...}}], ... }
|
|
630
|
+
// ▼
|
|
631
|
+
// Relay (this handler)
|
|
632
|
+
// │ validate selector shape (SELECTOR_KEYS_WHITELIST)
|
|
633
|
+
// │ maestroDump({ platform, sessionId, grpcClientCache }) ← lazy + memoized per request
|
|
634
|
+
// │ │
|
|
635
|
+
// │ ├─ Android cascade (maestro-hierarchy.js)
|
|
636
|
+
// │ │ gRPC primary → maestro-CLI → adb uiautomator
|
|
637
|
+
// │ │ Three-class taxonomy: schema-class (drift bit, no
|
|
638
|
+
// │ │ fallback) / channel-broken (evict cache, fall back) /
|
|
639
|
+
// │ │ contention-class (keep cache, skip CLI → adb).
|
|
640
|
+
// │ │
|
|
641
|
+
// │ └─ iOS cascade
|
|
642
|
+
// │ HTTP primary (Maestro XCTestRunner /viewHierarchy)
|
|
643
|
+
// │ → maestro-CLI shell-out. AUT-root detection skips
|
|
644
|
+
// │ SpringBoard frames.
|
|
645
|
+
// │
|
|
646
|
+
// │ firstMatch(nodes, selector) → bbox or null (warn-skip).
|
|
647
|
+
// │ payload.regions[i].elementSelector.boundingBox = bbox
|
|
648
|
+
// ▼
|
|
649
|
+
// Percy backend — compares masked regions across builds.
|
|
650
|
+
//
|
|
651
|
+
// ── Observability ──────────────────────────────────────────────────
|
|
652
|
+
//
|
|
653
|
+
// /percy/healthcheck exposes maestroHierarchyDrift per platform:
|
|
654
|
+
// { lastFailureClass, fallbackCount, succeededVia, code?, reason?, firstSeenAt? }
|
|
655
|
+
// Every primary→fallback transition also emits one info-level line:
|
|
656
|
+
// [percy] hierarchy: <primary> failed (<class>: <reason>) → falling back to <next>
|
|
657
|
+
//
|
|
658
|
+
// ── Failure shape ──────────────────────────────────────────────────
|
|
659
|
+
//
|
|
660
|
+
// Element regions degrade gracefully: resolver failure → warn-skip
|
|
661
|
+
// those regions only; the snapshot itself still uploads. Coordinate
|
|
662
|
+
// regions don't depend on the resolver and always pass through.
|
|
663
|
+
//
|
|
664
|
+
// ───────────────────────────────────────────────────────────────────
|
|
665
|
+
// Shared resolver state across regions/ignoreRegions/considerRegions —
|
|
666
|
+
// one hierarchy dump per request, one warn-once skip notice.
|
|
667
|
+
let cachedDump = null;
|
|
668
|
+
let elementSkipWarned = false;
|
|
669
|
+
const totalElementRegionCount = REGION_INPUT_FIELDS.reduce((sum, f) => {
|
|
670
|
+
let arr = req.body[f];
|
|
671
|
+
return sum + (Array.isArray(arr) ? arr.filter(r => r && r.element).length : 0);
|
|
672
|
+
}, 0);
|
|
673
|
+
|
|
674
|
+
// Resolve one region input to {x, y, width, height}, or null when the
|
|
675
|
+
// region is invalid or the resolver couldn't match it. Mutates the
|
|
676
|
+
// shared cachedDump / warn-flag state above.
|
|
677
|
+
async function resolveBbox(region) {
|
|
678
|
+
if (region.top != null && region.bottom != null && region.left != null && region.right != null) {
|
|
679
|
+
return {
|
|
680
|
+
x: region.left,
|
|
681
|
+
y: region.top,
|
|
682
|
+
width: region.right - region.left,
|
|
683
|
+
height: region.bottom - region.top
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
/* istanbul ignore else — region.element false branch falls through
|
|
687
|
+
to the istanbul-ignored "Invalid region format" warn below. */
|
|
688
|
+
if (region.element) {
|
|
689
|
+
/* istanbul ignore else — cachedDump === null only on first
|
|
690
|
+
element-region per request; subsequent regions hit the cache. */
|
|
691
|
+
if (cachedDump === null) {
|
|
692
|
+
// Thread the per-Percy gRPC client cache so the Android gRPC
|
|
693
|
+
// primary path can reuse channels across snapshots in the same
|
|
694
|
+
// session (D9 of 2026-05-07-002 plan). iOS path ignores it.
|
|
695
|
+
cachedDump = await maestroDump({
|
|
696
|
+
platform,
|
|
697
|
+
sessionId,
|
|
698
|
+
grpcClientCache: percy.grpcClientCache
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
/* istanbul ignore else — branch where dump resolves to hierarchy is
|
|
702
|
+
happy-path element-region territory, integration-tested only. */
|
|
703
|
+
if (cachedDump.kind !== 'hierarchy') {
|
|
704
|
+
/* istanbul ignore else — elementSkipWarned latches after first
|
|
705
|
+
warn; second+ iterations take the no-op branch. */
|
|
706
|
+
if (!elementSkipWarned) {
|
|
707
|
+
percy.log.warn(`Element-region resolver ${cachedDump.kind} (${cachedDump.reason}) — skipping ${totalElementRegionCount} element regions`);
|
|
708
|
+
elementSkipWarned = true;
|
|
709
|
+
}
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
/* istanbul ignore next */
|
|
713
|
+
let bbox = maestroFirstMatch(cachedDump.nodes, region.element);
|
|
714
|
+
/* istanbul ignore next */
|
|
715
|
+
if (!bbox) {
|
|
716
|
+
percy.log.warn(`Element region not found: ${JSON.stringify(region.element)} — skipping`);
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
/* istanbul ignore next — element-region happy path requires a
|
|
720
|
+
non-stub maestroDump returning hierarchy nodes; unit tests run
|
|
721
|
+
with stubbed resolver (env-missing), happy path covered by the
|
|
722
|
+
cross-platform-parity integration harness against fixture data. */
|
|
723
|
+
return bbox;
|
|
724
|
+
}
|
|
725
|
+
/* istanbul ignore next */
|
|
726
|
+
percy.log.warn('Invalid region format, skipping');
|
|
727
|
+
/* istanbul ignore next — region shape is validated upstream by the
|
|
728
|
+
SDK before posting; this is a defensive catch-all for regions that
|
|
729
|
+
lack both coordinate fields AND an element selector. */
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// regions[]: comparison-shape items with algorithm. Default algorithm is
|
|
734
|
+
// 'ignore' (back-compat with SDK ≤ 0.3).
|
|
735
|
+
if (Array.isArray(req.body.regions)) {
|
|
736
|
+
let resolvedRegions = [];
|
|
737
|
+
for (let region of req.body.regions) {
|
|
738
|
+
let bbox = await resolveBbox(region);
|
|
739
|
+
if (!bbox) continue;
|
|
740
|
+
let resolved = {
|
|
741
|
+
elementSelector: {
|
|
742
|
+
boundingBox: bbox
|
|
743
|
+
},
|
|
744
|
+
algorithm: region.algorithm || 'ignore'
|
|
745
|
+
};
|
|
746
|
+
/* istanbul ignore if — region.configuration optional field; only
|
|
747
|
+
passed when SDK opts in to per-region config overrides. */
|
|
748
|
+
if (region.configuration) resolved.configuration = region.configuration;
|
|
749
|
+
/* istanbul ignore if — region.padding optional field. */
|
|
750
|
+
if (region.padding) resolved.padding = region.padding;
|
|
751
|
+
/* istanbul ignore if — region.assertion optional field. */
|
|
752
|
+
if (region.assertion) resolved.assertion = region.assertion;
|
|
753
|
+
resolvedRegions.push(resolved);
|
|
754
|
+
}
|
|
755
|
+
/* istanbul ignore else — empty resolvedRegions branch only fires when
|
|
756
|
+
ALL regions failed to resolve; happy path resolves at least one. */
|
|
757
|
+
if (resolvedRegions.length > 0) payload.regions = resolvedRegions;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ignoreRegions[] and considerRegions[]: parallel top-level payload
|
|
761
|
+
// fields. Each item is shaped per regionsSchema (config.js:792) —
|
|
762
|
+
// { coOrdinates: {top, left, bottom, right} } with an optional selector
|
|
763
|
+
// hint preserved when the caller supplied an element selector.
|
|
764
|
+
const REGION_OUTPUT_MAP = {
|
|
765
|
+
ignoreRegions: {
|
|
766
|
+
payloadKey: 'ignoredElementsData',
|
|
767
|
+
innerKey: 'ignoreElementsData'
|
|
768
|
+
},
|
|
769
|
+
considerRegions: {
|
|
770
|
+
payloadKey: 'consideredElementsData',
|
|
771
|
+
innerKey: 'considerElementsData'
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
for (let [inputField, {
|
|
775
|
+
payloadKey,
|
|
776
|
+
innerKey
|
|
777
|
+
}] of Object.entries(REGION_OUTPUT_MAP)) {
|
|
778
|
+
let input = req.body[inputField];
|
|
779
|
+
if (!Array.isArray(input)) continue;
|
|
780
|
+
let resolved = [];
|
|
781
|
+
for (let region of input) {
|
|
782
|
+
let bbox = await resolveBbox(region);
|
|
783
|
+
/* istanbul ignore if — null bbox skip in ignoreRegions/considerRegions
|
|
784
|
+
loop; tests cover the happy path where every region resolves. */
|
|
785
|
+
if (!bbox) continue;
|
|
786
|
+
let item = {
|
|
787
|
+
coOrdinates: {
|
|
788
|
+
top: bbox.y,
|
|
789
|
+
left: bbox.x,
|
|
790
|
+
bottom: bbox.y + bbox.height,
|
|
791
|
+
right: bbox.x + bbox.width
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
/* istanbul ignore if — element selector echo on resolved region;
|
|
795
|
+
only fires when resolveBbox returned a bbox for an element region,
|
|
796
|
+
which itself is integration-test territory (see resolveBbox
|
|
797
|
+
above for the resolver-mock rationale). */
|
|
798
|
+
if (region.element) {
|
|
799
|
+
let [key] = Object.keys(region.element);
|
|
800
|
+
item.selector = `${key}=${region.element[key]}`;
|
|
801
|
+
}
|
|
802
|
+
resolved.push(item);
|
|
803
|
+
}
|
|
804
|
+
/* istanbul ignore else — empty resolved branch only fires when ALL
|
|
805
|
+
regions in this category failed to resolve; happy path resolves
|
|
806
|
+
at least one. */
|
|
807
|
+
if (resolved.length > 0) payload[payloadKey] = {
|
|
808
|
+
[innerKey]: resolved
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Upload via percy — sync or fire-and-forget
|
|
813
|
+
if (req.body.sync === true) payload.sync = true;
|
|
814
|
+
let data;
|
|
815
|
+
if (percy.syncMode(payload)) {
|
|
816
|
+
// percy.upload returns an async generator that must be drained for #snapshots.push to run.
|
|
817
|
+
// See docs/solutions/best-practices/2026-05-20-maestro-sync-promise-bug-investigation.md.
|
|
818
|
+
const snapshotPromise = new Promise((resolve, reject) => {
|
|
819
|
+
const upload = percy.upload(payload, {
|
|
820
|
+
resolve,
|
|
821
|
+
reject
|
|
822
|
+
}, 'app');
|
|
823
|
+
(async () => {
|
|
824
|
+
// eslint-disable-next-line no-unused-vars
|
|
825
|
+
try {
|
|
826
|
+
for await (const _ of upload) {/* drain */}
|
|
827
|
+
} catch (e) {
|
|
828
|
+
reject(e);
|
|
829
|
+
}
|
|
830
|
+
})();
|
|
831
|
+
});
|
|
832
|
+
data = await handleSyncJob(snapshotPromise, percy, 'comparison');
|
|
833
|
+
return res.json(200, {
|
|
834
|
+
success: true,
|
|
835
|
+
data
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
let upload = percy.upload(payload, null, 'app');
|
|
839
|
+
/* istanbul ignore if — ?await=true URL flag triggers fire-and-wait;
|
|
840
|
+
tests cover both syncMode and fire-and-forget but not the explicit
|
|
841
|
+
?await query-param variant. */
|
|
842
|
+
if (req.url.searchParams.has('await')) await upload;
|
|
843
|
+
|
|
844
|
+
// Generate redirect link
|
|
845
|
+
let link = [percy.client.apiUrl, '/comparisons/redirect?', encodeURLSearchParams(normalize({
|
|
846
|
+
buildId: (_percy$build3 = percy.build) === null || _percy$build3 === void 0 ? void 0 : _percy$build3.id,
|
|
847
|
+
snapshot: {
|
|
848
|
+
name
|
|
849
|
+
},
|
|
850
|
+
tag
|
|
851
|
+
}, {
|
|
852
|
+
snake: true
|
|
853
|
+
}))].join('');
|
|
854
|
+
return res.json(200, {
|
|
855
|
+
success: true,
|
|
856
|
+
link
|
|
857
|
+
});
|
|
858
|
+
})
|
|
196
859
|
// flushes one or more snapshots from the internal queue
|
|
197
860
|
.route('post', '/percy/flush', async (req, res) => res.json(200, {
|
|
198
861
|
success: await percy.flush(req.body).then(() => true)
|
|
@@ -201,10 +864,21 @@ export function createPercyServer(percy, port) {
|
|
|
201
864
|
percyAutomateRequestHandler(req, percy);
|
|
202
865
|
let comparisonData = await WebdriverUtils.captureScreenshot(req.body);
|
|
203
866
|
if (percy.syncMode(comparisonData)) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
867
|
+
// percy.upload returns an async generator that must be drained for #snapshots.push to run.
|
|
868
|
+
const snapshotPromise = new Promise((resolve, reject) => {
|
|
869
|
+
const upload = percy.upload(comparisonData, {
|
|
870
|
+
resolve,
|
|
871
|
+
reject
|
|
872
|
+
}, 'automate');
|
|
873
|
+
(async () => {
|
|
874
|
+
// eslint-disable-next-line no-unused-vars
|
|
875
|
+
try {
|
|
876
|
+
for await (const _ of upload) {/* drain */}
|
|
877
|
+
} catch (e) {
|
|
878
|
+
reject(e);
|
|
879
|
+
}
|
|
880
|
+
})();
|
|
881
|
+
});
|
|
208
882
|
data = await handleSyncJob(snapshotPromise, percy, 'comparison');
|
|
209
883
|
} else {
|
|
210
884
|
percy.upload(comparisonData, null, 'automate');
|
|
@@ -216,9 +890,9 @@ export function createPercyServer(percy, port) {
|
|
|
216
890
|
})
|
|
217
891
|
// Receives events from sdk's.
|
|
218
892
|
.route('post', '/percy/events', async (req, res) => {
|
|
219
|
-
var _percy$
|
|
893
|
+
var _percy$build4;
|
|
220
894
|
const body = percyBuildEventHandler(req, pkg.version);
|
|
221
|
-
await percy.client.sendBuildEvents((_percy$
|
|
895
|
+
await percy.client.sendBuildEvents((_percy$build4 = percy.build) === null || _percy$build4 === void 0 ? void 0 : _percy$build4.id, body);
|
|
222
896
|
res.json(200, {
|
|
223
897
|
success: true
|
|
224
898
|
});
|