Skip to content

Commit 0905319

Browse files
Sriram567claude
andauthored
refactor(core,cli-app): decompose merged self-hosted Maestro relay + app:exec injection (#2315)
* refactor(core): extract /percy/comparison/upload handler into comparison-upload.js Move handleComparisonUpload out of api.js into its own module (handleSyncJob extraction pattern); promote the shared encodeURLSearchParams helper to utils.js so both api.js and the new module use it without a circular import. No behavior change. * refactor(core): decompose /percy/maestro-screenshot relay (incl. self-hosted) into modules Extract the ~517-line maestro-screenshot handler out of api.js into cohesive modules, folding in #2261's self-hosted branches: - maestro-screenshot.js — handleMaestroScreenshot orchestrator + parsePngDimensions; runtime detection (selfHosted = !sessionId), conditional sessionId validation, PERCY_MAESTRO_SCREENSHOT_DIR scope-root resolution + filePath rejection. - maestro-screenshot-file.js — locateScreenshot({...,scopeRoot,selfHosted}): BS session glob + self-hosted recursive dot:true glob (Windows-normalized) + manualScreenshotWalk (BS only) + realpath/scopeRoot prefix check. - maestro-regions.js — validateRegionInputs + resolveRegions (explicit-key merge). api.js is now a route table (1072 → 382 lines) delegating via named imports; dead ServerError/maestro-hierarchy imports dropped. semgrep path-traversal suppression retargeted to maestro-screenshot-file.js (+ api.js createStaticServer). Behavior-preserving: api.test.js (BS + self-hosted) passes unchanged. * refactor(cli-app): extract Maestro argv/env injection into maestro-inject.js Move maybeInjectMaestroServer + maybeInjectScreenshotDir (and the private argv helpers findTestSubcommandIdx / hasExistingPercyServerFlag / findTestOutputDirValue + constants) out of exec.js into a dedicated maestro-inject.js. exec.js returns to a thin command (211 → 47 lines) whose app:exec callback calls the injectors; index.js re-exports them from the new module. No behavior change — exec.test.js 43/43 unchanged (imports via the @percy/cli-app barrel). * feat(core): add device system-bar inset derivation to maestro-hierarchy Port deriveDeviceInsets (+ findStatusBarFrameHeight, deriveIosInsets, parseStableInsets, deriveAndroidInsets) from PR #2286 onto the decomposed modules: iOS status bar from /viewHierarchy statusBars frame × empirical PNG scale; Android status+nav via adb dumpsys mStableInsets; iOS navBar always 0; null on any failure. Additive; dump()/resolver cascade untouched. ~25 specs. * feat(core): per-session maestroInsetCache on the Percy instance Add this.maestroInsetCache (Map keyed by sessionId), constructed alongside grpcClientCache and cleared in stop() — insets are device-constant within a session, so the relay derives once and reuses (incl. a null outcome). Ported from PR #2286. * feat(core): wire device-inset derivation into the Maestro relay handleMaestroScreenshot derives insets once per session (cached) and uses them for the tile's statusBarHeight/navBarHeight, authoritative over the SDK static defaults; iOS navBar=0; any failure falls back to the SDK value. Relay override/fallback/iOS-0/derive-and-cache specs + the beforeEach cache-seed seam, ported from PR #2286 onto the self-hosted-inclusive orchestrator. * feat(cli-app): auto-inject --driver-host-port on Maestro 2.6+ (iOS) Maestro 2.6.0 changed the iOS driver to bind an OS-assigned ephemeral port (ServerSocket(0)); <= 2.4.0 bound the deterministic 7001. The @percy/core relay resolves the iOS /viewHierarchy port via PERCY_IOS_DRIVER_HOST_PORT, then a 127.0.0.1:7001 probe — which finds nothing on 2.6+, so self-hosted iOS element regions (and device insets, whose relay path is env-only with no probe) degrade. maybeInjectDriverHostPort picks a free port, pins Maestro's iOS driver to it via --driver-host-port <port>, and mirrors it to process.env.PERCY_IOS_DRIVER_HOST_PORT so the relay (same Node process) targets it deterministically. Version-gated: the flag is a fatal Unknown option on <= 2.4.0 (the reason 13616a8 was reverted), so it fires only on Maestro >= 2.6.0; older versions no-op and the relay's 7001 probe serves them. Conservatively gated to iOS (--platform=ios/-p ios), skips sharded runs (a single pinned port collides), respects customer overrides, and no-ops on any version-detection failure. BrowserStack is unaffected (host-injected port; never routes through app:exec). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cli-app): degrade gracefully if free-port reservation fails Address code-review findings on the --driver-host-port injector: - Wrap the pickFreePort() call in try/catch — an OS listen(0) failure now warns and no-ops (falling back to the relay's 127.0.0.1:7001 probe) instead of crashing app:exec with an unattributed rejection and leaving Maestro 2.6+ iOS with no port at all. Matches the graceful-degradation pattern used by every other failure path in the helper. - Start findDriverHostPortValue's scan at i=1 for consistency with the sibling finders (skip args[0], the executable). - Refresh the stale "two helpers" comment in exec.js (now three; iOS argv shape). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(core): cover canonicalHost malformed-IPv6 catch branch (utils.js:103) The CORS/SSRF hardening in #2282 added canonicalHost's `catch { return bare }` fallback for colon-bearing hosts that are not valid IPv6 literals, but no test exercised it — leaving utils.js line 103 uncovered and dropping @percy/core below the 100% NYC gate (a failure pre-existing on master's HEAD). Add an isMetadataIP('gg::1') case: the invalid IPv6 makes `new URL` throw, so canonicalHost returns the raw value and the non-metadata address is allowed through (null). Restores 100% coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3f32984 commit 0905319

17 files changed

Lines changed: 1992 additions & 876 deletions

.semgrepignore

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,24 @@ packages/core/src/lock.js
2020
# every join. Suppress at the file level with this rationale.
2121
packages/core/src/archive.js
2222

23-
# api.js path-traversal guards live at the top of the /percy/maestro-screenshot
24-
# route handler: `name` and `sessionId` are both validated against
25-
# /^[a-zA-Z0-9_-]+$/ (SAFE_ID, line ~322) BEFORE any path.join() runs.
26-
# Traversal sequences (`..`, `/`, `\0`) are rejected with 400 there. The
27-
# fallback walker is also depth-capped at 15 levels. semgrep's
28-
# path-join-resolve-traversal rule cannot follow the regex validation
29-
# chain across the function body, so it flags the joins on lines 445
30-
# and 462. Inline `// nosemgrep` directives (preceding and same-line)
31-
# were not honored by the semgrep CI version in use — suppress at the
32-
# file level with this rationale.
23+
# maestro-screenshot-file.js owns the screenshot-location path joins. The
24+
# handleMaestroScreenshot handler validates `name`/`sessionId` against
25+
# /^[a-zA-Z0-9_-]+$/ (SAFE_ID) and rejects traversal sequences with 400 BEFORE
26+
# calling locateScreenshot(); the fallback walker (manualScreenshotWalk) is
27+
# additionally depth-capped at 15 levels, and the realpath + scopeRoot prefix
28+
# check enforces containment. semgrep's path-join-resolve-traversal rule cannot
29+
# follow that validation chain across the module boundary, so it flags the joins
30+
# inside manualScreenshotWalk. Inline `// nosemgrep` is not honored by the CI
31+
# semgrep version — suppress at the file level with this rationale.
32+
packages/core/src/maestro-screenshot-file.js
33+
34+
# api.js: createStaticServer builds an automatic sitemap by joining the
35+
# operator-provided `baseUrl` config with locally-globbed `*.html` filenames via
36+
# `path.posix.join('/', baseUrl, …)` (sitemap-URL construction, not a filesystem
37+
# read of external request input). semgrep's path-join-resolve-traversal rule
38+
# flags the join regardless of the trusted sources. Inline `// nosemgrep` is not
39+
# honored by the CI semgrep version — suppress at the file level with this
40+
# rationale.
3341
packages/core/src/api.js
3442

3543
# Test files load fixtures from hardcoded subpaths under

packages/cli-app/src/exec.js

Lines changed: 14 additions & 173 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import fs from 'fs';
2-
import os from 'os';
3-
import path from 'path';
41
import command from '@percy/cli-command';
52
import * as ExecPlugin from '@percy/cli-exec';
3+
import { maybeInjectMaestroServer, maybeInjectScreenshotDir, maybeInjectDriverHostPort } from './maestro-inject.js';
64

75
export const ping = ExecPlugin.ping;
86
export const stop = ExecPlugin.stop;
@@ -18,168 +16,6 @@ export const start = command('start', {
1816
}
1917
}, ExecPlugin.start.callback);
2018

21-
// Locate the `test` subcommand in argv. Maestro accepts global parent
22-
// flags before the subcommand, e.g.:
23-
// maestro test flow.yaml
24-
// maestro --udid <serial> test flow.yaml
25-
// maestro --platform=android test flow.yaml
26-
// maestro --verbose --no-ansi test flow.yaml
27-
// We must find `test` by scanning rather than checking args[1] === 'test',
28-
// or our injects silently no-op when the customer pins a device.
29-
//
30-
// Returns the index of the `test` literal, or -1 if not present. Skips
31-
// over the value of known value-taking parent flags so a literal `test`
32-
// supplied as a flag value (e.g. `--udid test`) isn't mistaken for the
33-
// subcommand. Equals-form (`--flag=value`) doesn't need a skip — the
34-
// value is part of the same argv token.
35-
const MAESTRO_PARENT_VALUE_FLAGS = new Set([
36-
'--udid', '--device', '-p', '--platform',
37-
'--host', '--port', '--driver-host-port'
38-
]);
39-
function findTestSubcommandIdx(args) {
40-
for (let i = 1; i < args.length; i++) {
41-
const tok = args[i];
42-
if (tok === 'test') return i;
43-
if (typeof tok === 'string' && MAESTRO_PARENT_VALUE_FLAGS.has(tok)) {
44-
i++; // skip the value of this flag
45-
}
46-
}
47-
return -1;
48-
}
49-
50-
function hasExistingPercyServerFlag(args, testIdx) {
51-
for (let i = testIdx + 1; i < args.length - 1; i++) {
52-
if (args[i] === '-e' && /^PERCY_SERVER=/.test(args[i + 1])) return true;
53-
}
54-
return false;
55-
}
56-
57-
// Returns the customer-supplied value of `--test-output-dir` (whether the
58-
// space-form `--test-output-dir <path>` or the equals-form
59-
// `--test-output-dir=<path>` — both are valid picocli syntax), or null if
60-
// absent. Returning the value (not just an index) lets the screenshot-dir
61-
// helper align PERCY_MAESTRO_SCREENSHOT_DIR with the customer's value in
62-
// either form without re-deriving it.
63-
//
64-
// An empty value (space-form value === '' or equals-form `--test-output-dir=`
65-
// with nothing after the `=`) is treated as ABSENT — returning the empty
66-
// string would tell the caller "customer set this" and bypass the
67-
// auto-resolve fallback, leaving Maestro to default its output location
68-
// while PERCY_MAESTRO_SCREENSHOT_DIR stays unset. That mismatch silently
69-
// produces all-404 snapshots, so fall through to the auto-resolve path
70-
// instead.
71-
const TEST_OUTPUT_DIR_EQ_PREFIX = '--test-output-dir=';
72-
function findTestOutputDirValue(args, testIdx) {
73-
for (let i = testIdx + 1; i < args.length; i++) {
74-
const tok = args[i];
75-
if (tok === '--test-output-dir' && i + 1 < args.length) {
76-
const val = args[i + 1];
77-
if (typeof val === 'string' && val.length > 0) return val;
78-
} else if (typeof tok === 'string' && tok.startsWith(TEST_OUTPUT_DIR_EQ_PREFIX)) {
79-
const val = tok.slice(TEST_OUTPUT_DIR_EQ_PREFIX.length);
80-
if (val.length > 0) return val;
81-
}
82-
}
83-
return null;
84-
}
85-
86-
// Maestro's GraalJS sandbox does NOT inherit the parent process's env,
87-
// so `PERCY_SERVER_ADDRESS` exported by app:exec is invisible to the
88-
// SDK. When wrapping `maestro test`, surface the CLI address through
89-
// Maestro's only env channel — `-e KEY=VALUE` flags — so the SDK
90-
// healthcheck can find the local CLI without the customer having to
91-
// pair ports manually. No-op when the customer already supplied their
92-
// own `-e PERCY_SERVER=...`.
93-
//
94-
// When percy?.address() is falsy (percy disabled, start failed), emit a
95-
// WARN so the customer is not surprised by a silent zero-snapshot build.
96-
// The customer-override skip case (their own `-e PERCY_SERVER=...` is in
97-
// argv) does NOT warn — that's intentional flow control, not a problem.
98-
export function maybeInjectMaestroServer(ctx, log) {
99-
const args = ctx?.argv;
100-
if (!Array.isArray(args) || args.length < 2) return;
101-
if (path.basename(args[0]) !== 'maestro') return;
102-
const testIdx = findTestSubcommandIdx(args);
103-
if (testIdx < 0) return;
104-
if (hasExistingPercyServerFlag(args, testIdx)) return;
105-
const addr = ctx.percy?.address();
106-
if (!addr) {
107-
log?.warn(
108-
'app:exec did not start the Percy CLI server (percy disabled or start ' +
109-
'failed); -e PERCY_SERVER not injected into maestro test. Snapshots will ' +
110-
'NOT be uploaded. Set PERCY_TOKEN and re-run, or check the percy log above.'
111-
);
112-
return;
113-
}
114-
// Inject after `test` so `-e KEY=VAL` is bound to the `test` subcommand
115-
// (the only Maestro subcommand that accepts `-e`).
116-
args.splice(testIdx + 1, 0, '-e', `PERCY_SERVER=${addr}`);
117-
}
118-
119-
// Auto-resolve the Maestro screenshot output directory so customers don't
120-
// have to pair `export PERCY_MAESTRO_SCREENSHOT_DIR=...` with a matching
121-
// `--test-output-dir <same>` in their maestro test command.
122-
//
123-
// Resolution order:
124-
// 1. Customer set BOTH process.env.PERCY_MAESTRO_SCREENSHOT_DIR and
125-
// --test-output-dir in argv → trust them, do nothing.
126-
// 2. Customer set PERCY_MAESTRO_SCREENSHOT_DIR only → use it, inject
127-
// `--test-output-dir <env value>` into argv.
128-
// 3. Customer set --test-output-dir only → use that value, mirror it
129-
// into process.env.PERCY_MAESTRO_SCREENSHOT_DIR (so the SDK +
130-
// CLI relay see the same path).
131-
// 4. Neither set → pick `${process.cwd()}/.percy-out`. On any mkdir
132-
// failure (read-only CWD, EACCES, EEXIST as a file), fall back to
133-
// `${os.tmpdir()}/percy-maestro-<pid>` with a WARN log.
134-
//
135-
// The env-var update and argv splice always keep both sources of truth
136-
// (SDK reads env var; Maestro reads the flag) aligned to the same path.
137-
export function maybeInjectScreenshotDir(ctx, log) {
138-
const args = ctx?.argv;
139-
if (!Array.isArray(args) || args.length < 2) return;
140-
if (path.basename(args[0]) !== 'maestro') return;
141-
const testIdx = findTestSubcommandIdx(args);
142-
if (testIdx < 0) return;
143-
144-
const envSet = !!process.env.PERCY_MAESTRO_SCREENSHOT_DIR;
145-
const existingFlagValue = findTestOutputDirValue(args, testIdx);
146-
const flagSet = existingFlagValue !== null;
147-
148-
// Fully customer-controlled — nothing to do.
149-
if (envSet && flagSet) return;
150-
151-
let resolved;
152-
if (envSet) {
153-
resolved = process.env.PERCY_MAESTRO_SCREENSHOT_DIR;
154-
} else if (flagSet) {
155-
resolved = existingFlagValue;
156-
} else {
157-
const preferred = path.join(process.cwd(), '.percy-out');
158-
try {
159-
fs.mkdirSync(preferred, { recursive: true });
160-
resolved = preferred;
161-
} catch (err) {
162-
const fallback = path.join(os.tmpdir(), `percy-maestro-${process.pid}`);
163-
try {
164-
fs.mkdirSync(fallback, { recursive: true });
165-
} catch (_) {
166-
// tmpdir mkdir failure is exceedingly rare; fall through and let
167-
// downstream code surface a clearer error than this helper can.
168-
}
169-
resolved = fallback;
170-
log?.warn(
171-
`Could not create ${preferred} (${err.code || err.message}); ` +
172-
`falling back to ${fallback}. Set PERCY_MAESTRO_SCREENSHOT_DIR to ` +
173-
'pick a specific location.'
174-
);
175-
}
176-
}
177-
178-
if (!envSet) process.env.PERCY_MAESTRO_SCREENSHOT_DIR = resolved;
179-
// Inject right after `test` (the subcommand that owns `--test-output-dir`).
180-
if (!flagSet) args.splice(testIdx + 1, 0, '--test-output-dir', resolved);
181-
}
182-
18319
export const exec = command('exec', {
18420
description: 'Start and stop Percy around a supplied command for native apps',
18521
usage: '[options] [--] <command>',
@@ -199,16 +35,21 @@ export const exec = command('exec', {
19935
skipDiscovery: true
20036
}
20137
}, async function*(ctx) {
202-
// The two helpers splice their flag groups at argv index 2 (between `test`
203-
// and the flow file) because `-e` and `--test-output-dir` are
204-
// `test`-subcommand options. Resulting argv for `maestro test flow.yaml`:
205-
// maestro test --test-output-dir <dir> -e PERCY_SERVER=<url> flow.yaml
206-
// iOS driver port: not prescribed from this side — the @percy/core relay
207-
// reads `PERCY_IOS_DRIVER_HOST_PORT` (BS-host-injected on production
208-
// hosts) and probes the documented Maestro 2.4.0 single-simulator default
209-
// (`127.0.0.1:7001`) when it isn't set. See `packages/core/src/maestro-hierarchy.js`.
38+
// The helpers splice their flag groups right after `test` because `-e` and
39+
// `--test-output-dir` are `test`-subcommand options; on iOS + Maestro >= 2.6 a
40+
// third adds `--driver-host-port <port>`. Resulting argv for
41+
// `maestro --platform=ios test flow.yaml`:
42+
// maestro --platform=ios test --driver-host-port <port> --test-output-dir <dir> -e PERCY_SERVER=<url> flow.yaml
43+
// iOS driver port: on Maestro >= 2.6 (ephemeral driver port) we prescribe a
44+
// free port via `--driver-host-port` and mirror it to PERCY_IOS_DRIVER_HOST_PORT
45+
// so the @percy/core relay reaches `/viewHierarchy` deterministically for
46+
// element regions and device insets. On Maestro <= 2.4 the helper no-ops and
47+
// the relay's `127.0.0.1:7001` probe (the deterministic 2.4.0 default) serves
48+
// those customers. On BrowserStack the port is host-injected and this path
49+
// never runs. See `packages/core/src/maestro-hierarchy.js`.
21050
maybeInjectMaestroServer(ctx, ctx.log);
21151
maybeInjectScreenshotDir(ctx, ctx.log);
52+
await maybeInjectDriverHostPort(ctx, ctx.log);
21253
yield* ExecPlugin.default.callback(ctx);
21354
});
21455

packages/cli-app/src/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { default, app } from './app.js';
2-
export { exec, start, stop, ping, maybeInjectMaestroServer, maybeInjectScreenshotDir } from './exec.js';
2+
export { exec, start, stop, ping } from './exec.js';
3+
export { maybeInjectMaestroServer, maybeInjectScreenshotDir, maybeInjectDriverHostPort } from './maestro-inject.js';

0 commit comments

Comments
 (0)