-(flow).png`).
- let searchPattern = platform === 'ios'
- ? `/tmp/${sessionId}/*_maestro_debug_*/**/${name}.png`
- : `/tmp/${sessionId}_test_suite/logs/*/screenshots/${name}.png`;
+ let searchPattern;
+ if (selfHosted) {
+ // Self-hosted: recursive glob under the customer's --test-output-dir
+ // (PERCY_MAESTRO_SCREENSHOT_DIR). Recursive depth handles arbitrary
+ // Maestro layouts; `name` is SAFE_ID-validated above so it cannot
+ // contain separators or traversal characters.
+ searchPattern = `${scopeRoot}/**/${name}.png`;
+ } else {
+ searchPattern = platform === 'ios'
+ ? `/tmp/${sessionId}/*_maestro_debug_*/**/${name}.png`
+ : `/tmp/${sessionId}_test_suite/logs/*/screenshots/${name}.png`;
+ }
let files;
try {
let { default: glob } = await import('fast-glob');
- files = await glob(searchPattern);
+ // Self-hosted needs `dot: true` because Maestro's default output
+ // directory is `.maestro/` — a dot-prefixed entry that fast-glob
+ // hides by default. BS layouts have no dot-prefixed segments, so
+ // omitting the option there keeps the byte-identical behavior.
+ files = await glob(searchPattern, selfHosted ? { dot: true } : undefined);
} catch {
// Fast-glob import / glob call failed — fall back to manual walker.
// See manualScreenshotWalk() at file top for the rationale + the
// file-level .semgrepignore covering path-traversal sinks inside.
+ // Self-hosted has no walker fallback (no fixed-layout convention) —
+ // empty files → 404 with the actionable PERCY_MAESTRO_SCREENSHOT_DIR
+ // guidance above.
/* istanbul ignore next — only fires when fast-glob import throws
(broken install / FS corruption); integration-test territory. */
- files = await manualScreenshotWalk(platform, sessionId, name);
+ files = selfHosted ? [] : await manualScreenshotWalk(platform, sessionId, name);
}
if (!files || files.length === 0) {
@@ -515,22 +571,20 @@ export function createPercyServer(percy, port) {
}
}
- // Canonicalize and confirm the resolved path still lives under the sessionId-owned dir.
- // Defeats symlink swaps where a sessionId-named dir points elsewhere.
- // We resolve both the file and the expected prefix because /tmp is a symlink on macOS
- // (iOS hosts run macOS, where /tmp → /private/tmp).
- let expectedSessionRoot = platform === 'ios'
- ? `/tmp/${sessionId}`
- : `/tmp/${sessionId}_test_suite`;
+ // Canonicalize and confirm the resolved path still lives under scopeRoot.
+ // Defeats symlink swaps where the root points elsewhere. Both ends are
+ // realpath'd because /tmp is a symlink on macOS (where iOS hosts run).
+ // The trailing `/` on the prefix is load-bearing — it prevents
+ // sibling-prefix bypass (e.g. /x/.maestro vs /x/.maestro-secrets).
let realPath, realPrefix;
try {
realPath = await fs.promises.realpath(chosenFile);
- realPrefix = await fs.promises.realpath(expectedSessionRoot);
+ realPrefix = await fs.promises.realpath(scopeRoot);
} catch {
throw new ServerError(404, `Screenshot not found: ${name}.png (path resolution failed)`);
}
if (!realPath.startsWith(`${realPrefix}/`)) {
- throw new ServerError(404, `Screenshot not found: ${name}.png (resolved outside session dir)`);
+ throw new ServerError(404, `Screenshot not found: ${name}.png (resolved outside ${selfHosted ? 'PERCY_MAESTRO_SCREENSHOT_DIR' : 'session dir'})`);
}
// Read and base64-encode the screenshot
@@ -663,10 +717,14 @@ export function createPercyServer(percy, port) {
// 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.
+ // Also thread iosPortCache so the self-hosted iOS port cascade
+ // (probe 7001 + lsof) resolves once per session and reuses the
+ // port for subsequent snapshots — same per-Percy scope.
cachedDump = await maestroDump({
platform,
sessionId,
- grpcClientCache: percy.grpcClientCache
+ grpcClientCache: percy.grpcClientCache,
+ iosPortCache: percy.iosPortCache
});
}
/* istanbul ignore else — branch where dump resolves to hierarchy is
diff --git a/packages/core/src/maestro-hierarchy.js b/packages/core/src/maestro-hierarchy.js
index b7a8ae35a..3cda11fc9 100644
--- a/packages/core/src/maestro-hierarchy.js
+++ b/packages/core/src/maestro-hierarchy.js
@@ -101,10 +101,16 @@ const SELECTOR_KEYS_UNION = ['resource-id', 'text', 'content-desc', 'class', 'id
// past the socket timeout.
const IOS_HTTP_HEALTHY_DEADLINE_MS = 1500;
const IOS_HTTP_CIRCUIT_BREAKER_MS = 5000;
-// Maestro iOS driver-host-port is realmobile-derived as wda_port + 2700.
-// WDA ports are 8400-8410 → driver host ports are 11100-11110.
-const IOS_DRIVER_HOST_PORT_MIN = 11100;
-const IOS_DRIVER_HOST_PORT_MAX = 11110;
+// BS realmobile derives the iOS driver-host-port as wda_port + 2700 (WDA
+// ports 8400-8410 → driver host ports 11100-11110). The validator
+// `parseIosDriverHostPort` accepts any valid TCP port (1-65535); the BS
+// range is a strict subset. Self-hosted iOS port resolution probes
+// `127.0.0.1:7001` first — source-verified on Maestro 1.40-2.4.0 (cli-2.4.0
+// `TestCommand.kt#selectPort`) as the deterministic single-simulator port;
+// sharded runs use the fixed range 7001-7128. Ephemeral-port Maestro
+// (`main`/2.6.0+ uses `ServerSocket(0)`) is handled by `lsof`
+// self-discovery rather than range-probing.
+const IOS_SELF_HOSTED_PROBE_PORT = 7001;
// HTTP response cap before parse — sized for WebView-heavy iOS apps.
const IOS_HTTP_RESPONSE_MAX_BYTES = 20 * 1024 * 1024;
@@ -902,17 +908,23 @@ function defaultHttpRequest({ host, port, method, path: requestPath, headers, bo
});
}
-// Validate PERCY_IOS_DRIVER_HOST_PORT env value as integer in the realmobile
-// range (wda_port + 2700 = 11100-11110). Out-of-range values return null.
+// Validate PERCY_IOS_DRIVER_HOST_PORT env value as an integer in the valid
+// TCP range (1-65535). Was previously clamped to the BS-specific realmobile
+// range (wda_port+2700 = 11100-11110); the relaxed range is a superset so BS
+// continues to accept its canonical port unchanged. Self-hosted callers can
+// pass any port they pinned via `maestro test --driver-host-port ` (e.g.
+// the deterministic `7001` on a simulator, or a forwarded port like ~6001 on
+// a real device).
function parseIosDriverHostPort(raw) {
/* istanbul ignore if — undefined/null/empty raw value branch; iOS dispatch
pre-checks PERCY_IOS_DRIVER_HOST_PORT before calling, so these never fire. */
if (raw === undefined || raw === null || raw === '') return null;
const n = Number(raw);
/* istanbul ignore if — non-integer port (e.g. NaN from non-numeric env);
- env var is set by realmobile as the canonical wda_port+2700 integer. */
+ env var is set by realmobile as the canonical wda_port+2700 integer or by
+ a self-hosted customer as their explicit `--driver-host-port` value. */
if (!Number.isInteger(n)) return null;
- if (n < IOS_DRIVER_HOST_PORT_MIN || n > IOS_DRIVER_HOST_PORT_MAX) return null;
+ if (n < 1 || n > 65535) return null;
return n;
}
@@ -1238,11 +1250,111 @@ async function runAdbFallback(serial, execAdb) {
return result;
}
+// ===== Self-hosted iOS port resolution helpers =====
+// Used only on the implicit branch of the iOS dispatch — when
+// PERCY_IOS_DRIVER_HOST_PORT is absent, the resolver auto-discovers the
+// running Maestro iOS driver's host port via deterministic probe + lsof.
+// The BS path (explicit env vars present) does not invoke these.
+
+// Production default for `execLsof`. Tests inject a fake. The 5s timeout
+// is generous — lsof is local and listing TCP LISTEN sockets is fast; the
+// budget is mostly defense against a hung lsof. Returns the spawn result
+// in the shape spawnWithTimeout produces.
+/* istanbul ignore next — child-process spawn wrapper; tests inject a fake. */
+async function execLsofDefault({ timeoutMs } = {}) {
+ return spawnWithTimeout('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN'], { timeoutMs: timeoutMs ?? 5000 });
+}
+
+// Parse `lsof -nP -iTCP -sTCP:LISTEN` output and return the LISTEN port of
+// the Maestro iOS XCTest runner — ONLY when exactly one matching listener
+// is found. Zero matches, multiple matches, or any spawn/parse failure
+// returns null so the caller falls through to warn-skip rather than
+// guessing. Match criterion: process name (column 1, case-insensitive
+// substring) contains `maestro-driver-ios` (covers names like
+// `dev.mobile.maestro-driver-iosUITests.xctrunner` plus future variants).
+async function lsofXctrunnerPort(execLsof) {
+ let result;
+ try { result = await execLsof({ timeoutMs: 5000 }); } catch { return null; }
+ if (!result || result.spawnError || result.timedOut || (result.exitCode ?? 1) !== 0) return null;
+ const lines = (result.stdout || '').split('\n');
+ const matches = new Set();
+ for (const line of lines) {
+ if (!line) continue;
+ const cols = line.split(/\s+/);
+ // lsof default columns: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME...
+ if (cols.length < 9) continue;
+ if (!/maestro-driver-ios/i.test(cols[0])) continue;
+ // NAME column for LISTEN sockets looks like `*:7001` or `127.0.0.1:7001`,
+ // sometimes suffixed with `(LISTEN)`. Pull the last `:` group.
+ const name = cols.slice(8).join(' ');
+ const m = name.match(/:(\d+)(?=\D|$)/g);
+ if (!m || m.length === 0) continue;
+ const last = m[m.length - 1];
+ const port = Number(last.slice(1));
+ if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
+ matches.add(port);
+ if (matches.size > 1) return null; // multi-match → don't guess
+ }
+ return matches.size === 1 ? [...matches][0] : null;
+}
+
+// Self-hosted iOS port resolution cascade. Returns either
+// `{ port, result }` where `result` is a `runIosHttpDump` outcome
+// (caller decides what to do with it), or `null` (caller emits
+// warn-skip). Order:
+// 1. Cache hit → re-probe the cached port (subsequent snapshots).
+// 2. Probe `127.0.0.1:7001` (deterministic single-simulator port on
+// Maestro ≤2.4.0 per the source-verified spike).
+// 3. `lsof` self-discovery for ephemeral-port Maestro (2.6+ uses
+// `ServerSocket(0)`); exactly-one-match guard.
+// 4. null → warn-skip.
+//
+// "Probe" reuses runIosHttpDump as both the liveness check AND the dump.
+// A `hierarchy` or `no-aut-tree` result confirms the port is a valid
+// Maestro driver (`no-aut-tree` means the driver is alive but the AUT
+// isn't foregrounded — still a Maestro listener worth caching).
+// `dump-error` and `connection-fail` are NOT cached and move to the
+// next candidate; in the cascade we don't propagate schema-drift since
+// the wrong service answering on `7001` would also produce `dump-error`.
+async function resolveSelfHostedIosPort({ httpRequest, execLsof, sessionId, iosPortCache }) {
+ const probe = async port => {
+ const result = await runIosHttpDump({ port, sessionId, httpRequest });
+ if (result.kind === 'hierarchy' || result.kind === 'no-aut-tree') {
+ if (iosPortCache) iosPortCache.port = port;
+ return { port, result };
+ }
+ return null;
+ };
+
+ // 1. Cache hit.
+ if (iosPortCache && Number.isInteger(iosPortCache.port)) {
+ const cached = iosPortCache.port;
+ const result = await runIosHttpDump({ port: cached, sessionId, httpRequest });
+ return { port: cached, result };
+ }
+
+ // 2. Deterministic primary.
+ const primary = await probe(IOS_SELF_HOSTED_PROBE_PORT);
+ if (primary) return primary;
+
+ // 3. lsof self-discovery — only try its port if it's distinct from the
+ // primary we already probed (otherwise the earlier probe failure
+ // already settled it).
+ const lsofPort = await lsofXctrunnerPort(execLsof);
+ if (lsofPort !== null && lsofPort !== IOS_SELF_HOSTED_PROBE_PORT) {
+ const hit = await probe(lsofPort);
+ if (hit) return hit;
+ }
+
+ // 4. Nothing resolved.
+ return null;
+}
+
export async function dump(options) {
/* istanbul ignore next — options-omitted default; callers always pass an
object (tests inject every dependency; production code binds them). */
options = options || {};
- let { platform, sessionId, execAdb, execMaestro, httpRequest, grpcClient, grpcClientCache, getEnv } = options;
+ let { platform, sessionId, execAdb, execMaestro, httpRequest, grpcClient, grpcClientCache, getEnv, execLsof, iosPortCache } = options;
/* istanbul ignore next — defaults applied only when caller omits the
corresponding key; tests inject every dependency, production callers
bind these from defaults at runtime. */
@@ -1257,18 +1369,52 @@ export async function dump(options) {
grpcClient = grpcClient || defaultGrpcClientFactory;
/* istanbul ignore next */
getEnv = getEnv || defaultGetEnv;
+ /* istanbul ignore next — execLsof default for the self-hosted iOS
+ cascade; tests inject a fake. iosPortCache has no default — undefined
+ is the "no cache" sentinel and the cascade handles it. */
+ execLsof = execLsof || execLsofDefault;
const started = Date.now();
if (platform === 'ios') {
- // iOS dispatch: read realmobile-injected env vars; warn-skip if absent.
const udid = getEnv('PERCY_IOS_DEVICE_UDID');
const driverHostPortRaw = getEnv('PERCY_IOS_DRIVER_HOST_PORT');
- if (!udid || !driverHostPortRaw) {
- log.warn(`iOS resolver env-missing: udid=${udid ? 'set' : 'unset'} driver_port=${driverHostPortRaw ? 'set' : 'unset'}`);
+
+ // ─── Self-hosted (implicit) — PERCY_IOS_DRIVER_HOST_PORT absent ────────
+ // Cascade-discover the running Maestro iOS driver's host port: cache →
+ // probe 127.0.0.1:7001 → lsof self-discovery → warn-skip. No CLI cold-
+ // start tier (that was Tier B from the original plan, cut after the
+ // spike confirmed `7001` is deterministic on Maestro ≤2.4.0 + lsof
+ // covers the ephemeral-port case). UDID is not required here — the
+ // HTTP `/viewHierarchy` POST doesn't take a udid; the driver host is
+ // already bound to one device.
+ if (!driverHostPortRaw) {
+ const cascade = await resolveSelfHostedIosPort({ httpRequest, execLsof, sessionId, iosPortCache });
+ if (cascade) {
+ const { port, result } = cascade;
+ if (result.kind === 'hierarchy') {
+ log.debug(`dump took ${Date.now() - started}ms via maestro-http (self-hosted, port=${port}, ${result.nodes.length} nodes)`);
+ recordResolverSuccess({ platform: 'ios', via: 'maestro-http' });
+ } else {
+ // `no-aut-tree` from a resolved port — Maestro driver alive (port
+ // cached) but the AUT isn't foregrounded for THIS snapshot.
+ // Subsequent snapshots in the session re-probe the cached port
+ // and may succeed.
+ log.debug(`dump took ${Date.now() - started}ms via maestro-http (self-hosted, port=${port}, ${result.kind}/${result.reason})`);
+ recordResolverFinalFailure({ platform: 'ios', failureClass: failureClassFromReason(result.reason) });
+ }
+ return result;
+ }
+ log.warn('[percy] iOS element regions: no Maestro driver found on :7001 or via lsof. Set PERCY_IOS_DRIVER_HOST_PORT (real devices use a forwarded port; sharded/newer Maestro may use a different port).');
recordResolverFinalFailure({ platform: 'ios', failureClass: 'other' });
- return { kind: 'unavailable', reason: 'env-missing' };
+ return { kind: 'unavailable', reason: 'self-hosted-no-driver' };
}
+ // ─── Explicit — PERCY_IOS_DRIVER_HOST_PORT present ─────────────────────
+ // BrowserStack (host-injected) and self-hosted-with-explicit-port both
+ // take this path. The HTTP primary runs against the supplied port; the
+ // CLI fallback runs only when UDID is also present (without UDID
+ // there's no way to target a specific device via `maestro --udid`).
+
// D3 kill switch (PERCY_MAESTRO_GRPC=0): same env name gates BOTH Maestro
// primaries. On iOS this skips runIosHttpDump and routes straight to the
// maestro-CLI fallback below. Read every call so toggling at runtime is
@@ -1278,8 +1424,10 @@ export async function dump(options) {
log.warn('PERCY_MAESTRO_GRPC=0 kill switch active; skipping iOS HTTP primary');
}
- // Validate driver-host-port range before attempting HTTP. Out-of-range
- // values skip the HTTP path entirely and fall through to maestro-CLI.
+ // Validate driver-host-port — integer 1-65535 (relaxed from the BS
+ // 11100-11110 range; BS values stay valid as a subset). Out-of-range
+ // values skip the HTTP path entirely and fall through to maestro-CLI
+ // (when UDID is present) or warn-skip (when not).
const driverHostPort = parseIosDriverHostPort(driverHostPortRaw);
let httpResult = null;
if (!iosKillSwitch && driverHostPort !== null) {
@@ -1308,6 +1456,15 @@ export async function dump(options) {
log.info(`[percy] hierarchy: maestro-http failed (other: ${oorReason}) → falling back to maestro-cli-fallback`);
}
+ // CLI fallback requires UDID. Without it (port-only self-hosted), the
+ // HTTP attempt above was the only available path — emit warn-skip with
+ // an actionable message rather than running maestro without a target.
+ if (!udid) {
+ log.warn('[percy] iOS resolver: PERCY_IOS_DEVICE_UDID absent — no CLI fallback. HTTP primary either succeeded above and returned, or failed and there is no further path. Set UDID to enable the CLI fallback.');
+ recordResolverFinalFailure({ platform: 'ios', failureClass: 'other' });
+ return { kind: 'unavailable', reason: 'self-hosted-no-udid' };
+ }
+
const cliResult = await runMaestroIosDump(udid, driverHostPort ?? driverHostPortRaw, execMaestro, getEnv);
const httpReason = httpResult ? `${httpResult.kind}/${httpResult.reason}` : 'out-of-range-port';
log.debug(`dump took ${Date.now() - started}ms via maestro-cli-fallback (${httpReason}) kind=${cliResult.kind}`);
diff --git a/packages/core/src/percy.js b/packages/core/src/percy.js
index 2878299c5..29c90d9b5 100644
--- a/packages/core/src/percy.js
+++ b/packages/core/src/percy.js
@@ -145,6 +145,12 @@ export class Percy {
this.grpcClientCache = new Map();
this.grpcClientCache.shutdownInProgress = false;
+ // Self-hosted iOS port cache — mirrors the per-`Percy`-instance scope of
+ // grpcClientCache (D9 of the maestro 4-PR plan). Set on the first
+ // successful self-hosted iOS port resolution and reused for the rest of
+ // the session so subsequent snapshots skip the probe/lsof cascade.
+ this.iosPortCache = { port: null };
+
// Domain validation state for auto domain allow-listing
this.domainValidation = {
autoConfiguredHosts: new Set(), // Domains from project config
diff --git a/packages/core/test/api.test.js b/packages/core/test/api.test.js
index ad6d67962..b10f649a7 100644
--- a/packages/core/test/api.test.js
+++ b/packages/core/test/api.test.js
@@ -1110,9 +1110,21 @@ describe('API Server', () => {
await expectAsync(postMaestro({ sessionId: SID })).toBeRejectedWithError(/Missing required field: name/);
});
- it('rejects missing sessionId with 400', async () => {
- await percy.start();
- await expectAsync(postMaestro({ name: SS_NAME })).toBeRejectedWithError(/Missing required field: sessionId/);
+ it('400s missing sessionId + missing PERCY_MAESTRO_SCREENSHOT_DIR (self-hosted mode requires the env var)', async () => {
+ // `sessionId` absent is the self-hosted detection signal. Without
+ // PERCY_MAESTRO_SCREENSHOT_DIR set, the relay 400s with actionable
+ // guidance rather than 404'ing on a glob it cannot scope. The
+ // self-hosted happy path is covered in the dedicated describe below.
+ let prior = process.env.PERCY_MAESTRO_SCREENSHOT_DIR;
+ delete process.env.PERCY_MAESTRO_SCREENSHOT_DIR;
+ try {
+ await percy.start();
+ await expectAsync(postMaestro({ name: SS_NAME }))
+ .toBeRejectedWithError(/Missing required env: PERCY_MAESTRO_SCREENSHOT_DIR/);
+ } finally {
+ if (prior === undefined) delete process.env.PERCY_MAESTRO_SCREENSHOT_DIR;
+ else process.env.PERCY_MAESTRO_SCREENSHOT_DIR = prior;
+ }
});
it('rejects invalid platform with 400', async () => {
@@ -1747,5 +1759,112 @@ describe('API Server', () => {
expect(payload.tag.width).toBe(1179);
expect(payload.tag.height).toBe(2556);
});
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Self-hosted mode: `sessionId` absent triggers PERCY_MAESTRO_SCREENSHOT_DIR
+ // resolution. The BS path (sessionId present) is byte-identical and
+ // covered above; these tests lock the new branches.
+ // ─────────────────────────────────────────────────────────────────────
+ describe('self-hosted (sessionId absent)', () => {
+ const SELF_HOSTED_ROOT = '/tmp/percy-self-hosted-root';
+ const NESTED_SUBDIR = `${SELF_HOSTED_ROOT}/.maestro/run-x/screenshots`;
+ const SELF_HOSTED_NAME = 'SelfHostedScreen';
+ let priorEnv;
+
+ beforeEach(() => {
+ priorEnv = process.env.PERCY_MAESTRO_SCREENSHOT_DIR;
+ process.env.PERCY_MAESTRO_SCREENSHOT_DIR = SELF_HOSTED_ROOT;
+ fs.mkdirSync(NESTED_SUBDIR, { recursive: true });
+ // Valid 24-byte PNG header (1080 x 2400) exercises PNG-fill on the
+ // self-hosted path too.
+ const pngHeader = Buffer.alloc(24);
+ Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]).copy(pngHeader, 0);
+ pngHeader.writeUInt32BE(13, 8);
+ Buffer.from('IHDR', 'ascii').copy(pngHeader, 12);
+ pngHeader.writeUInt32BE(1080, 16);
+ pngHeader.writeUInt32BE(2400, 20);
+ fs.writeFileSync(`${NESTED_SUBDIR}/${SELF_HOSTED_NAME}.png`, pngHeader);
+ });
+
+ afterEach(() => {
+ if (priorEnv === undefined) delete process.env.PERCY_MAESTRO_SCREENSHOT_DIR;
+ else process.env.PERCY_MAESTRO_SCREENSHOT_DIR = priorEnv;
+ });
+
+ it('finds screenshot via recursive glob under PERCY_MAESTRO_SCREENSHOT_DIR and uploads without sessionId', async () => {
+ await percy.start();
+ spyOn(percy, 'upload').and.resolveTo();
+ let res = await postMaestro({ name: SELF_HOSTED_NAME, platform: 'android' });
+ expect(res.success).toBe(true);
+ let [payload] = percy.upload.calls.mostRecent().args;
+ expect(payload.name).toBe(SELF_HOSTED_NAME);
+ expect(payload.tag.width).toBe(1080);
+ expect(payload.tag.height).toBe(2400);
+ expect(payload.tiles[0].content).toBeDefined();
+ // sessionId is never forwarded into the upload payload (relay only
+ // used it for scoping; self-hosted has no equivalent).
+ expect(payload.sessionId).toBeUndefined();
+ });
+
+ it('400s when PERCY_MAESTRO_SCREENSHOT_DIR is not absolute', async () => {
+ process.env.PERCY_MAESTRO_SCREENSHOT_DIR = 'relative/path';
+ await percy.start();
+ await expectAsync(postMaestro({ name: SELF_HOSTED_NAME }))
+ .toBeRejectedWithError(/PERCY_MAESTRO_SCREENSHOT_DIR must be an absolute path/);
+ });
+
+ it('400s when PERCY_MAESTRO_SCREENSHOT_DIR does not exist', async () => {
+ process.env.PERCY_MAESTRO_SCREENSHOT_DIR = '/tmp/this-path-does-not-exist-percy-self-hosted';
+ await percy.start();
+ await expectAsync(postMaestro({ name: SELF_HOSTED_NAME }))
+ .toBeRejectedWithError(/PERCY_MAESTRO_SCREENSHOT_DIR is not an existing directory/);
+ });
+
+ it('400s when PERCY_MAESTRO_SCREENSHOT_DIR points to a file, not a directory', async () => {
+ fs.writeFileSync('/tmp/percy-self-hosted-not-a-dir', 'plain-file');
+ process.env.PERCY_MAESTRO_SCREENSHOT_DIR = '/tmp/percy-self-hosted-not-a-dir';
+ await percy.start();
+ await expectAsync(postMaestro({ name: SELF_HOSTED_NAME }))
+ .toBeRejectedWithError(/PERCY_MAESTRO_SCREENSHOT_DIR is not an existing directory/);
+ });
+
+ it('rejects a supplied filePath in self-hosted mode (security invariant)', async () => {
+ // The SDK never emits filePath self-hosted; honoring it against a
+ // caller-influenceable root would re-open arbitrary in-root reads.
+ await percy.start();
+ await expectAsync(postMaestro({
+ name: SELF_HOSTED_NAME,
+ filePath: `${NESTED_SUBDIR}/${SELF_HOSTED_NAME}.png`
+ })).toBeRejectedWithError(/filePath is not accepted in self-hosted mode/);
+ });
+
+ it('404s when the screenshot is missing under the configured root', async () => {
+ await percy.start();
+ await expectAsync(postMaestro({ name: 'NoSuchScreenshot' }))
+ .toBeRejectedWithError(/Screenshot not found/);
+ });
+
+ it('rejects name with traversal characters (SAFE_ID is load-bearing for the recursive glob)', async () => {
+ await percy.start();
+ await expectAsync(postMaestro({ name: '../etc/passwd' }))
+ .toBeRejectedWithError(/Invalid screenshot name/);
+ });
+
+ it('coordinate regions still pass through on the self-hosted path', async () => {
+ await percy.start();
+ spyOn(percy, 'upload').and.resolveTo();
+ await postMaestro({
+ name: SELF_HOSTED_NAME,
+ platform: 'android',
+ regions: [{ top: 10, bottom: 50, left: 0, right: 100, algorithm: 'ignore' }]
+ });
+ let [payload] = percy.upload.calls.mostRecent().args;
+ expect(payload.regions).toBeDefined();
+ expect(payload.regions.length).toBe(1);
+ expect(payload.regions[0].elementSelector.boundingBox)
+ .toEqual({ x: 0, y: 10, width: 100, height: 40 });
+ expect(payload.regions[0].algorithm).toBe('ignore');
+ });
+ });
});
});
diff --git a/packages/core/test/unit/maestro-hierarchy.parity.test.js b/packages/core/test/unit/maestro-hierarchy.parity.test.js
index 6ab5ceb86..b7eee3e0b 100644
--- a/packages/core/test/unit/maestro-hierarchy.parity.test.js
+++ b/packages/core/test/unit/maestro-hierarchy.parity.test.js
@@ -89,11 +89,19 @@ describe('Unit / maestro-hierarchy / cross-platform parity', () => {
expect(res.nodes.length).toBeGreaterThan(0);
});
- it('iOS env-missing returns { kind: "unavailable", reason: "env-missing" }', async () => {
- // Same envelope shape as Android-failure paths, just a different reason tag.
- const res = await dump({ platform: 'ios', getEnv: () => undefined });
+ it('iOS env-missing returns { kind: "unavailable", reason: "self-hosted-no-driver" }', async () => {
+ // Same envelope shape as Android-failure paths, just a different reason
+ // tag. Post-Unit-2: with both PERCY_IOS_* env vars absent, the
+ // dispatch enters the self-hosted IMPLICIT branch and runs the port
+ // cascade (probe 7001 → lsof). With injected fakes that fail at every
+ // tier, the cascade returns null and the dispatch emits the new
+ // `self-hosted-no-driver` warn-skip reason. The envelope shape (kind
+ // = 'unavailable') matches Android-failure paths unchanged.
+ const httpRequest = async () => { throw Object.assign(new Error('econnrefused'), { code: 'ECONNREFUSED' }); };
+ const execLsof = async () => ({ stdout: '', stderr: '', exitCode: 0 });
+ const res = await dump({ platform: 'ios', getEnv: () => undefined, httpRequest, execLsof });
expect(res.kind).toBe('unavailable');
- expect(res.reason).toBe('env-missing');
+ expect(res.reason).toBe('self-hosted-no-driver');
});
it('iOS env-set with no http/maestro reachable returns same envelope kinds as Android failure paths', async () => {
diff --git a/packages/core/test/unit/maestro-hierarchy.test.js b/packages/core/test/unit/maestro-hierarchy.test.js
index 6597bfce9..28099947a 100644
--- a/packages/core/test/unit/maestro-hierarchy.test.js
+++ b/packages/core/test/unit/maestro-hierarchy.test.js
@@ -476,27 +476,42 @@ describe('Unit / maestro-hierarchy', () => {
});
describe('iOS dispatch — env handling', () => {
- it('returns env-missing when PERCY_IOS_DEVICE_UDID is unset', async () => {
+ // Self-hosted-with-explicit-port-but-no-UDID: enters the EXPLICIT branch
+ // (port present), runs the HTTP primary. With UDID absent, the CLI
+ // fallback is unavailable — on HTTP success the dump returns; on HTTP
+ // failure (connection-fail here), warn-skip with the new
+ // `self-hosted-no-udid` reason.
+ it('warn-skips with self-hosted-no-udid when port is set, udid is unset, and HTTP primary fails', async () => {
const getEnv = key => {
if (key === 'PERCY_IOS_DRIVER_HOST_PORT') return '11100';
return undefined;
};
- const res = await dump({ platform: 'ios', getEnv });
- expect(res).toEqual({ kind: 'unavailable', reason: 'env-missing' });
+ const httpRequest = async () => { throw Object.assign(new Error('econnrefused'), { code: 'ECONNREFUSED' }); };
+ const res = await dump({ platform: 'ios', getEnv, httpRequest });
+ expect(res).toEqual({ kind: 'unavailable', reason: 'self-hosted-no-udid' });
});
- it('returns env-missing when PERCY_IOS_DRIVER_HOST_PORT is unset', async () => {
+ // Self-hosted (UDID-set, PORT-unset): enters the IMPLICIT branch and
+ // runs the discovery cascade (probe 7001 → lsof). With both injected
+ // fakes failing, cascade returns null → warn-skip with the new
+ // `self-hosted-no-driver` reason. UDID being set is irrelevant on the
+ // implicit path — HTTP `/viewHierarchy` doesn't take a udid.
+ it('warn-skips with self-hosted-no-driver when port is unset and the discovery cascade finds nothing', async () => {
const getEnv = key => {
if (key === 'PERCY_IOS_DEVICE_UDID') return '00008110-000065081404401E';
return undefined;
};
- const res = await dump({ platform: 'ios', getEnv });
- expect(res).toEqual({ kind: 'unavailable', reason: 'env-missing' });
+ const httpRequest = async () => { throw Object.assign(new Error('econnrefused'), { code: 'ECONNREFUSED' }); };
+ const execLsof = async () => ({ stdout: '', stderr: '', exitCode: 0 });
+ const res = await dump({ platform: 'ios', getEnv, httpRequest, execLsof });
+ expect(res).toEqual({ kind: 'unavailable', reason: 'self-hosted-no-driver' });
});
- it('returns env-missing when both env vars are unset', async () => {
- const res = await dump({ platform: 'ios', getEnv: () => undefined });
- expect(res).toEqual({ kind: 'unavailable', reason: 'env-missing' });
+ it('warn-skips with self-hosted-no-driver when both env vars are unset and the cascade finds nothing', async () => {
+ const httpRequest = async () => { throw Object.assign(new Error('econnrefused'), { code: 'ECONNREFUSED' }); };
+ const execLsof = async () => ({ stdout: '', stderr: '', exitCode: 0 });
+ const res = await dump({ platform: 'ios', getEnv: () => undefined, httpRequest, execLsof });
+ expect(res).toEqual({ kind: 'unavailable', reason: 'self-hosted-no-driver' });
});
it('does not invoke adb on iOS dispatch', async () => {
@@ -546,6 +561,191 @@ describe('Unit / maestro-hierarchy', () => {
});
});
+ // Self-hosted iOS path: triggered when PERCY_IOS_DRIVER_HOST_PORT is
+ // absent. The resolver auto-discovers the running Maestro driver port
+ // via probe 127.0.0.1:7001 → lsof → warn-skip. The BS path (explicit
+ // env vars) does not exercise this code at all.
+ describe('iOS self-hosted port cascade', () => {
+ // Minimal axElement response matching the existing iOS HTTP fixture
+ // shape — single AUT root, one button child with a frame. Enough for
+ // runIosHttpDump to return { kind: 'hierarchy' }.
+ const minimalAxElementJson = JSON.stringify({
+ axElement: {
+ elementType: 1,
+ identifier: 'com.example.app',
+ frame: { X: 0, Y: 0, Width: 100, Height: 100 },
+ children: [
+ { elementType: 9, identifier: 'btn', label: 'OK', frame: { X: 10, Y: 10, Width: 50, Height: 30 } }
+ ]
+ }
+ });
+
+ const successfulHttpResponse = {
+ statusCode: 200,
+ headers: { 'content-type': 'application/json' },
+ body: minimalAxElementJson
+ };
+
+ const connectionRefused = () => Object.assign(new Error('econnrefused'), { code: 'ECONNREFUSED' });
+
+ it('probe-7001 hit: cascade returns hierarchy and caches port 7001', async () => {
+ const httpRequest = jasmine.createSpy('httpRequest').and.resolveTo(successfulHttpResponse);
+ const execLsof = jasmine.createSpy('execLsof');
+ const iosPortCache = { port: null };
+
+ const res = await dump({
+ platform: 'ios',
+ getEnv: () => undefined,
+ httpRequest,
+ execLsof,
+ iosPortCache
+ });
+
+ expect(res.kind).toBe('hierarchy');
+ expect(res.nodes.length).toBeGreaterThan(0);
+ // Probed exactly :7001; never invoked lsof (probe succeeded first).
+ expect(httpRequest.calls.count()).toBe(1);
+ expect(httpRequest.calls.first().args[0].port).toBe(7001);
+ expect(execLsof).not.toHaveBeenCalled();
+ // Cache populated.
+ expect(iosPortCache.port).toBe(7001);
+ });
+
+ it('lsof discovery: 7001 fails, lsof returns exactly one xctrunner listener, cascade probes it', async () => {
+ const ephemeralPort = 51234;
+ const httpRequest = jasmine.createSpy('httpRequest').and.callFake(async ({ port }) => {
+ if (port === 7001) throw connectionRefused();
+ if (port === ephemeralPort) return successfulHttpResponse;
+ throw connectionRefused();
+ });
+ const lsofStdout = `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\ndev.mobile.maestro-driver-iosUITests.xctrunner 12345 user 8u IPv4 0x1 0t0 TCP *:${ephemeralPort} (LISTEN)\n`;
+ const execLsof = jasmine.createSpy('execLsof').and.resolveTo({ stdout: lsofStdout, stderr: '', exitCode: 0 });
+ const iosPortCache = { port: null };
+
+ const res = await dump({
+ platform: 'ios',
+ getEnv: () => undefined,
+ httpRequest,
+ execLsof,
+ iosPortCache
+ });
+
+ expect(res.kind).toBe('hierarchy');
+ expect(execLsof).toHaveBeenCalled();
+ // Probed 7001 first, then lsof-discovered port.
+ const probedPorts = httpRequest.calls.allArgs().map(args => args[0].port);
+ expect(probedPorts).toEqual([7001, ephemeralPort]);
+ expect(iosPortCache.port).toBe(ephemeralPort);
+ });
+
+ it('lsof zero matches: cascade warn-skips without guessing', async () => {
+ const httpRequest = jasmine.createSpy('httpRequest').and.callFake(async () => { throw connectionRefused(); });
+ // No xctrunner row in lsof output.
+ const lsofStdout = 'COMMAND PID USER FD TYPE DEVICE NAME\nnode 999 user 8u IPv4 0t0 TCP *:3000 (LISTEN)\n';
+ const execLsof = async () => ({ stdout: lsofStdout, stderr: '', exitCode: 0 });
+ const iosPortCache = { port: null };
+
+ const res = await dump({
+ platform: 'ios',
+ getEnv: () => undefined,
+ httpRequest,
+ execLsof,
+ iosPortCache
+ });
+
+ expect(res).toEqual({ kind: 'unavailable', reason: 'self-hosted-no-driver' });
+ expect(iosPortCache.port).toBeNull();
+ });
+
+ it('lsof multi-match (two xctrunner listeners): cascade refuses to guess and warn-skips', async () => {
+ const httpRequest = jasmine.createSpy('httpRequest').and.callFake(async () => { throw connectionRefused(); });
+ const lsofStdout = [
+ 'COMMAND PID USER FD TYPE DEVICE NAME',
+ 'dev.mobile.maestro-driver-iosUITests.xctrunner 100 user 8u IPv4 0t0 TCP *:51234 (LISTEN)',
+ 'dev.mobile.maestro-driver-iosUITests.xctrunner 101 user 8u IPv4 0t0 TCP *:51235 (LISTEN)',
+ ''
+ ].join('\n');
+ const execLsof = async () => ({ stdout: lsofStdout, stderr: '', exitCode: 0 });
+ const iosPortCache = { port: null };
+
+ const res = await dump({
+ platform: 'ios',
+ getEnv: () => undefined,
+ httpRequest,
+ execLsof,
+ iosPortCache
+ });
+
+ expect(res).toEqual({ kind: 'unavailable', reason: 'self-hosted-no-driver' });
+ expect(iosPortCache.port).toBeNull();
+ });
+
+ it('explicit PERCY_IOS_DRIVER_HOST_PORT (out-of-legacy-range) bypasses cascade and runs HTTP primary', async () => {
+ // Customer pinned --driver-host-port 6001 (e.g., real-device-forwarded
+ // port). UDID absent — common single-device self-hosted case. The
+ // EXPLICIT branch runs runIosHttpDump on 6001 (relaxed range admits
+ // it); cascade/lsof are NOT invoked.
+ const httpRequest = jasmine.createSpy('httpRequest').and.resolveTo(successfulHttpResponse);
+ const execLsof = jasmine.createSpy('execLsof');
+ const getEnv = key => (key === 'PERCY_IOS_DRIVER_HOST_PORT' ? '6001' : undefined);
+
+ const res = await dump({ platform: 'ios', getEnv, httpRequest, execLsof });
+
+ expect(res.kind).toBe('hierarchy');
+ expect(httpRequest.calls.count()).toBe(1);
+ expect(httpRequest.calls.first().args[0].port).toBe(6001);
+ expect(execLsof).not.toHaveBeenCalled();
+ });
+
+ it('probe returns dump-error (wrong service): cascade does not cache and falls through', async () => {
+ // The probed port answers 200 but with a body missing axElement —
+ // runIosHttpDump returns { kind: 'dump-error', reason: 'http-missing-root' }.
+ // This is NOT a Maestro driver; cascade must not cache, must move on
+ // to lsof (which here returns no matches), and end in warn-skip with
+ // an empty cache.
+ const wrongServiceResponse = {
+ statusCode: 200,
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ unrelated: 'shape' })
+ };
+ const httpRequest = jasmine.createSpy('httpRequest').and.resolveTo(wrongServiceResponse);
+ const execLsof = async () => ({ stdout: '', stderr: '', exitCode: 0 });
+ const iosPortCache = { port: null };
+
+ const res = await dump({
+ platform: 'ios',
+ getEnv: () => undefined,
+ httpRequest,
+ execLsof,
+ iosPortCache
+ });
+
+ expect(res).toEqual({ kind: 'unavailable', reason: 'self-hosted-no-driver' });
+ expect(iosPortCache.port).toBeNull();
+ });
+
+ it('cache hit: a second invocation with the same iosPortCache reuses the resolved port without re-probing/re-lsof', async () => {
+ const httpRequest = jasmine.createSpy('httpRequest').and.resolveTo(successfulHttpResponse);
+ const execLsof = jasmine.createSpy('execLsof');
+ const iosPortCache = { port: null };
+
+ // First call: cascade resolves to 7001 and caches it.
+ await dump({ platform: 'ios', getEnv: () => undefined, httpRequest, execLsof, iosPortCache });
+ expect(iosPortCache.port).toBe(7001);
+ const firstCallCount = httpRequest.calls.count();
+
+ // Second call: cache hit → single HTTP to the cached port. lsof never
+ // invoked on either call.
+ httpRequest.calls.reset();
+ await dump({ platform: 'ios', getEnv: () => undefined, httpRequest, execLsof, iosPortCache });
+ expect(httpRequest.calls.count()).toBe(1);
+ expect(httpRequest.calls.first().args[0].port).toBe(7001);
+ expect(execLsof).not.toHaveBeenCalled();
+ // Sanity: first call probed exactly once (7001 hit) — no range/lsof.
+ expect(firstCallCount).toBe(1);
+ });
+ });
+
describe('iOS HTTP dump (runIosHttpDump primary path)', () => {
const iosFixtureDir = path.resolve(url.fileURLToPath(import.meta.url), '../../fixtures/maestro-ios-hierarchy');
const loadIosFixture = name => fs.readFileSync(path.join(iosFixtureDir, name), 'utf8');