Skip to content
Open
28 changes: 19 additions & 9 deletions .semgrepignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,25 @@ packages/core/src/lock.js
# every join. Suppress at the file level with this rationale.
packages/core/src/archive.js

# api.js path-traversal guards live at the top of the /percy/maestro-screenshot
# route handler: `name` and `sessionId` are both validated against
# /^[a-zA-Z0-9_-]+$/ (SAFE_ID, line ~322) BEFORE any path.join() runs.
# Traversal sequences (`..`, `/`, `\0`) are rejected with 400 there. The
# fallback walker is also depth-capped at 15 levels. semgrep's
# path-join-resolve-traversal rule cannot follow the regex validation
# chain across the function body, so it flags the joins on lines 445
# and 462. Inline `// nosemgrep` directives (preceding and same-line)
# were not honored by the semgrep CI version in use — suppress at the
# maestro-screenshot-file.js owns the screenshot-location path joins. The
# handleMaestroScreenshot handler validates `name` and `sessionId` against
# /^[a-zA-Z0-9_-]+$/ (SAFE_ID) and rejects traversal sequences (`..`, `/`,
# `\0`) with 400 BEFORE calling locateScreenshot(); the fallback walker
# (manualScreenshotWalk) is additionally depth-capped at 15 levels. semgrep's
# path-join-resolve-traversal rule cannot follow that validation chain across
# the module boundary, so it flags the joins inside manualScreenshotWalk.
# Inline `// nosemgrep` directives (preceding and same-line) were not honored
# by the semgrep CI version in use — suppress at the file level with this
# rationale.
packages/core/src/maestro-screenshot-file.js

# api.js: createStaticServer builds an automatic sitemap by joining the
# operator-provided `baseUrl` config with locally-globbed `*.html` filenames
# via `path.posix.join('/', baseUrl, …)` to construct sitemap URLs (not a
# filesystem read of external request input). semgrep's
# path-join-resolve-traversal rule flags the join regardless of the source
# being trusted server config + locally-enumerated files. Inline `// nosemgrep`
# directives are not honored by the semgrep CI version in use — suppress at the
# file level with this rationale.
packages/core/src/api.js

Expand Down
622 changes: 5 additions & 617 deletions packages/core/src/api.js

Large diffs are not rendered by default.

110 changes: 110 additions & 0 deletions packages/core/src/comparison-upload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { normalize } from '@percy/config/utils';
import { ServerError } from './server.js';
import { encodeURLSearchParams } from './utils.js';
import Busboy from 'busboy';
import { Readable } from 'stream';

/* istanbul ignore next — multipart /percy/comparison/upload handler;
exercises Busboy stream parsing + PNG magic-byte validation + base64
encoding + percy.upload. Integration-tested via the regression suite
(real multipart POST) rather than the unit suite, which would require
constructing valid multipart bodies. */
export async function handleComparisonUpload(req, res, percy) {
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
const PNG_MAGIC_BYTES = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);

let contentType = req.headers['content-type'] || '';
if (!contentType.startsWith('multipart/form-data')) {
throw new ServerError(400, 'Content-Type must be multipart/form-data');
}

if (!req.body) {
throw new ServerError(400, 'Empty request body');
}

let fields = Object.create(null);
let fileBuffer = null;

await new Promise((resolve, reject) => {
let bb = Busboy({
headers: req.headers,
limits: { fileSize: MAX_FILE_SIZE }
});

bb.on('file', (fieldname, stream, info) => {
let chunks = [];
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('limit', () => {
reject(new ServerError(413, 'File size exceeds maximum of 50MB'));
});
stream.on('end', () => {
if (fieldname === 'screenshot') {
fileBuffer = Buffer.concat(chunks);
}
});
});

bb.on('field', (fieldname, value) => {
if (['name', 'tag', 'clientInfo', 'environmentInfo', 'testCase', 'labels'].includes(fieldname)) {
fields[fieldname] = value;
}
});

bb.on('close', resolve);
bb.on('error', reject);

let stream = Readable.from(req.body);
stream.on('error', reject);
stream.pipe(bb);
});

if (!fileBuffer) {
throw new ServerError(400, 'Missing required file part: screenshot');
}

if (fileBuffer.length < 8 || !fileBuffer.subarray(0, 8).equals(PNG_MAGIC_BYTES)) {
throw new ServerError(400, 'File is not a valid PNG image');
}

if (!fields.name) throw new ServerError(400, 'Missing required field: name');
if (!fields.tag) throw new ServerError(400, 'Missing required field: tag');

let tag;
try {
tag = JSON.parse(fields.tag);
} catch {
throw new ServerError(400, 'Invalid JSON in tag field');
}

let base64Content = fileBuffer.toString('base64');

let payload = {
name: fields.name,
tag,
tiles: [{
content: base64Content,
statusBarHeight: 0,
navBarHeight: 0,
headerHeight: 0,
footerHeight: 0,
fullscreen: false
}],
clientInfo: fields.clientInfo || '',
environmentInfo: fields.environmentInfo || ''
};

if (fields.testCase) payload.testCase = fields.testCase;
if (fields.labels) payload.labels = fields.labels;

let upload = percy.upload(payload, null, 'app');
if (req.url.searchParams.has('await')) await upload;

let link = [
percy.client.apiUrl, '/comparisons/redirect?',
encodeURLSearchParams(normalize({
buildId: percy.build?.id, snapshot: { name: payload.name }, tag
}, { snake: true }))
].join('');

return res.json(200, { success: true, link });
}
245 changes: 245 additions & 0 deletions packages/core/src/maestro-regions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { ServerError } from './server.js';
import { dump as maestroDump, firstMatch as maestroFirstMatch, SELECTOR_KEYS_WHITELIST } from './maestro-hierarchy.js';

// Three parallel region input arrays share the same per-item shape; algorithm
// semantics differ per array (regions only — ignoreRegions/considerRegions are
// implicit).
const REGION_INPUT_FIELDS = ['regions', 'ignoreRegions', 'considerRegions'];

// Validate regions input shape early (before file I/O and ADB work) so
// malformed requests don't consume resolver/relay work. Throws ServerError(400)
// on the first shape violation.
export function validateRegionInputs(body) {
for (let fieldName of REGION_INPUT_FIELDS) {
let input = body[fieldName];
if (input === undefined) continue;
if (!Array.isArray(input)) {
throw new ServerError(400, `${fieldName} must be an array`);
}
if (input.length > 50) {
throw new ServerError(400, `${fieldName} exceeds maximum of 50`);
}
for (let [idx, region] of input.entries()) {
if (region && region.element !== undefined) {
if (typeof region.element !== 'object' || region.element === null || Array.isArray(region.element)) {
throw new ServerError(400, `${fieldName}[${idx}].element must be an object`);
}
let keys = Object.keys(region.element);
if (keys.length !== 1) {
throw new ServerError(400, `${fieldName}[${idx}].element must have exactly one selector key`);
}
let [key] = keys;
if (!SELECTOR_KEYS_WHITELIST.includes(key)) {
throw new ServerError(400, `${fieldName}[${idx}].element: unsupported selector key "${key}" (allowed: ${SELECTOR_KEYS_WHITELIST.join(', ')})`);
}
let value = region.element[key];
if (typeof value !== 'string' || value.length === 0) {
throw new ServerError(400, `${fieldName}[${idx}].element.${key} must be a non-empty string`);
}
if (value.length > 512) {
throw new ServerError(400, `${fieldName}[${idx}].element.${key} exceeds maximum length of 512`);
}
}
}
}
}

// ───────────────────────────────────────────────────────────────────
// REGIONS — end-to-end architecture
// ───────────────────────────────────────────────────────────────────
//
// Regions tell Percy's diff engine which parts of a mobile screenshot
// to ignore / consider / layout-compare. Two ways to specify one:
//
// 1. Coordinate region — caller already knows the pixel rectangle.
// Shape: { top, left, right, bottom }. Forwarded as-is after
// transform to `{x, y, width, height}` boundingBox.
//
// 2. Element region — caller knows a selector (`resource-id`, `text`,
// `content-desc`, `class`, `id`) but not the on-screen bounds.
// Resolved at relay-time against the live device's view hierarchy.
//
// ── Data flow (element region case) ────────────────────────────────
//
// SDK (percy-screenshot.js)
// │ POST /percy/maestro-screenshot
// │ { name, sessionId, platform, regions:[{element:{...}}], ... }
// ▼
// Relay (maestro-screenshot.js → resolveRegions)
// │ validate selector shape (SELECTOR_KEYS_WHITELIST)
// │ maestroDump({ platform, sessionId, grpcClientCache }) ← lazy + memoized per request
// │ │
// │ ├─ Android cascade (maestro-hierarchy.js)
// │ │ gRPC primary → maestro-CLI → adb uiautomator
// │ │ Three-class taxonomy: schema-class (drift bit, no
// │ │ fallback) / channel-broken (evict cache, fall back) /
// │ │ contention-class (keep cache, skip CLI → adb).
// │ │
// │ └─ iOS cascade
// │ HTTP primary (Maestro XCTestRunner /viewHierarchy)
// │ → maestro-CLI shell-out. AUT-root detection skips
// │ SpringBoard frames.
// │
// │ firstMatch(nodes, selector) → bbox or null (warn-skip).
// │ payload.regions[i].elementSelector.boundingBox = bbox
// ▼
// Percy backend — compares masked regions across builds.
//
// ── Observability ──────────────────────────────────────────────────
//
// /percy/healthcheck exposes maestroHierarchyDrift per platform:
// { lastFailureClass, fallbackCount, succeededVia, code?, reason?, firstSeenAt? }
// Every primary→fallback transition also emits one info-level line:
// [percy] hierarchy: <primary> failed (<class>: <reason>) → falling back to <next>
//
// ── Failure shape ──────────────────────────────────────────────────
//
// Element regions degrade gracefully: resolver failure → warn-skip
// those regions only; the snapshot itself still uploads. Coordinate
// regions don't depend on the resolver and always pass through.
//
// ───────────────────────────────────────────────────────────────────
// Resolve all region inputs to comparison-payload fragments. Returns an object
// with only the populated keys among { regions, ignoredElementsData,
// consideredElementsData }; merge it into the comparison payload. The hierarchy
// dump and the warn-once flag are request-scoped (call-local) — one dump per
// request, one warn-once skip notice.
export async function resolveRegions({ body, platform, sessionId, percy }) {
let out = {};

let cachedDump = null;
let elementSkipWarned = false;
const totalElementRegionCount = REGION_INPUT_FIELDS.reduce((sum, f) => {
let arr = body[f];
return sum + (Array.isArray(arr) ? arr.filter(r => r && r.element).length : 0);
}, 0);

// Resolve one region input to {x, y, width, height}, or null when the
// region is invalid or the resolver couldn't match it. Mutates the
// shared cachedDump / warn-flag state above.
async function resolveBbox(region) {
if (region.top != null && region.bottom != null && region.left != null && region.right != null) {
return {
x: region.left,
y: region.top,
width: region.right - region.left,
height: region.bottom - region.top
};
}
/* istanbul ignore else — region.element false branch falls through
to the istanbul-ignored "Invalid region format" warn below. */
if (region.element) {
/* istanbul ignore else — cachedDump === null only on first
element-region per request; subsequent regions hit the cache. */
if (cachedDump === null) {
// Thread the per-Percy gRPC client cache so the Android gRPC
// primary path can reuse channels across snapshots in the same
// session (D9 of 2026-05-07-002 plan). iOS path ignores it.
cachedDump = await maestroDump({
platform,
sessionId,
grpcClientCache: percy.grpcClientCache
});
}
/* istanbul ignore else — branch where dump resolves to hierarchy is
happy-path element-region territory, integration-tested only. */
if (cachedDump.kind !== 'hierarchy') {
/* istanbul ignore else — elementSkipWarned latches after first
warn; second+ iterations take the no-op branch. */
if (!elementSkipWarned) {
percy.log.warn(
`Element-region resolver ${cachedDump.kind} (${cachedDump.reason}) — skipping ${totalElementRegionCount} element regions`
);
elementSkipWarned = true;
}
return null;
}
/* istanbul ignore next */
let bbox = maestroFirstMatch(cachedDump.nodes, region.element);
/* istanbul ignore next */
if (!bbox) {
percy.log.warn(`Element region not found: ${JSON.stringify(region.element)} — skipping`);
return null;
}
/* istanbul ignore next — element-region happy path requires a
non-stub maestroDump returning hierarchy nodes; unit tests run
with stubbed resolver (env-missing), happy path covered by the
cross-platform-parity integration harness against fixture data. */
return bbox;
}
/* istanbul ignore next */
percy.log.warn('Invalid region format, skipping');
/* istanbul ignore next — region shape is validated upstream by the
SDK before posting; this is a defensive catch-all for regions that
lack both coordinate fields AND an element selector. */
return null;
}

// regions[]: comparison-shape items with algorithm. Default algorithm is
// 'ignore' (back-compat with SDK ≤ 0.3).
if (Array.isArray(body.regions)) {
let resolvedRegions = [];
for (let region of body.regions) {
let bbox = await resolveBbox(region);
if (!bbox) continue;
let resolved = {
elementSelector: { boundingBox: bbox },
algorithm: region.algorithm || 'ignore'
};
/* istanbul ignore if — region.configuration optional field; only
passed when SDK opts in to per-region config overrides. */
if (region.configuration) resolved.configuration = region.configuration;
/* istanbul ignore if — region.padding optional field. */
if (region.padding) resolved.padding = region.padding;
/* istanbul ignore if — region.assertion optional field. */
if (region.assertion) resolved.assertion = region.assertion;
resolvedRegions.push(resolved);
}
/* istanbul ignore else — empty resolvedRegions branch only fires when
ALL regions failed to resolve; happy path resolves at least one. */
if (resolvedRegions.length > 0) out.regions = resolvedRegions;
}

// ignoreRegions[] and considerRegions[]: parallel top-level payload
// fields. Each item is shaped per regionsSchema (config.js:792) —
// { coOrdinates: {top, left, bottom, right} } with an optional selector
// hint preserved when the caller supplied an element selector.
const REGION_OUTPUT_MAP = {
ignoreRegions: { payloadKey: 'ignoredElementsData', innerKey: 'ignoreElementsData' },
considerRegions: { payloadKey: 'consideredElementsData', innerKey: 'considerElementsData' }
};
for (let [inputField, { payloadKey, innerKey }] of Object.entries(REGION_OUTPUT_MAP)) {
let input = body[inputField];
if (!Array.isArray(input)) continue;
let resolved = [];
for (let region of input) {
let bbox = await resolveBbox(region);
/* istanbul ignore if — null bbox skip in ignoreRegions/considerRegions
loop; tests cover the happy path where every region resolves. */
if (!bbox) continue;
let item = {
coOrdinates: {
top: bbox.y,
left: bbox.x,
bottom: bbox.y + bbox.height,
right: bbox.x + bbox.width
}
};
/* istanbul ignore if — element selector echo on resolved region;
only fires when resolveBbox returned a bbox for an element region,
which itself is integration-test territory (see resolveBbox
above for the resolver-mock rationale). */
if (region.element) {
let [key] = Object.keys(region.element);
item.selector = `${key}=${region.element[key]}`;
}
resolved.push(item);
}
/* istanbul ignore else — empty resolved branch only fires when ALL
regions in this category failed to resolve; happy path resolves
at least one. */
if (resolved.length > 0) out[payloadKey] = { [innerKey]: resolved };
}

return out;
}
Loading
Loading