@percy/core 1.32.0-beta.0 → 1.32.0-beta.2

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 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
- if (req.body) try {
45
- req.body = JSON.parse(req.body);
46
- } catch {}
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
- const snapshotPromise = new Promise((resolve, reject) => percy.upload(req.body, {
158
- resolve,
159
- reject
160
- }, 'app'));
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$build;
361
+ var _percy$build2;
173
362
  return [percy.client.apiUrl, '/comparisons/redirect?', encodeURLSearchParams(normalize({
174
- buildId: (_percy$build = percy.build) === null || _percy$build === void 0 ? void 0 : _percy$build.id,
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
- const snapshotPromise = new Promise((resolve, reject) => percy.upload(comparisonData, {
205
- resolve,
206
- reject
207
- }, 'automate'));
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$build2;
893
+ var _percy$build4;
220
894
  const body = percyBuildEventHandler(req, pkg.version);
221
- await percy.client.sendBuildEvents((_percy$build2 = percy.build) === null || _percy$build2 === void 0 ? void 0 : _percy$build2.id, body);
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
  });