diff --git a/packages/core/src/api.js b/packages/core/src/api.js index 42a36385e..3fdfccdc3 100644 --- a/packages/core/src/api.js +++ b/packages/core/src/api.js @@ -372,15 +372,19 @@ export function createPercyServer(percy, port) { let { name, sessionId } = req.body || {}; if (!name) throw new ServerError(400, 'Missing required field: name'); - if (!sessionId) throw new ServerError(400, 'Missing required field: sessionId'); + // `sessionId` is host-injected on BrowserStack; its absence signals + // self-hosted mode (gated separately on PERCY_MAESTRO_SCREENSHOT_DIR + // below). When present, the BS path runs byte-identical. + let selfHosted = !sessionId; // Strict character-class validation — rejects path separators, shell metacharacters, // NUL, newlines, and anything else that could confuse the glob or the filesystem. + // `name` is load-bearing for the recursive glob — must not be loosened. const SAFE_ID = /^[a-zA-Z0-9_-]+$/; if (!SAFE_ID.test(name)) { throw new ServerError(400, 'Invalid screenshot name'); } - if (!SAFE_ID.test(sessionId)) { + if (sessionId && !SAFE_ID.test(sessionId)) { throw new ServerError(400, 'Invalid sessionId'); } @@ -420,6 +424,42 @@ export function createPercyServer(percy, port) { suppliedFilePath = req.body.filePath; } + // Resolve the file-find scope root. On BrowserStack (sessionId present), + // the root is the BS host's /tmp/{sessionId}{_test_suite} convention. + // Self-hosted (sessionId absent) requires PERCY_MAESTRO_SCREENSHOT_DIR + // (read from process.env, never the request body) to be an absolute, + // existing directory — typically the customer's + // `maestro test --test-output-dir ` path. The realpath + prefix + // check below enforces the security invariant at whichever root applies; + // the boundary is relocated, not removed. + let scopeRoot; + if (selfHosted) { + // Reject filePath outright in self-hosted mode. The SDK never emits + // it (it sends a relative SCREENSHOT_NAME); honoring an absolute + // filePath against a caller-influenceable root would re-open + // arbitrary in-root reads. + if (suppliedFilePath) { + throw new ServerError(400, 'filePath is not accepted in self-hosted mode (omit it; PERCY_MAESTRO_SCREENSHOT_DIR + relative SCREENSHOT_NAME is the supported path)'); + } + let dir = process.env.PERCY_MAESTRO_SCREENSHOT_DIR; + if (!dir) { + throw new ServerError(400, 'Missing required env: PERCY_MAESTRO_SCREENSHOT_DIR (set it to your `maestro test --test-output-dir` path)'); + } + if (!path.isAbsolute(dir)) { + throw new ServerError(400, 'PERCY_MAESTRO_SCREENSHOT_DIR must be an absolute path'); + } + let stat; + try { stat = await fs.promises.stat(dir); } catch { stat = null; } + if (!stat || !stat.isDirectory()) { + throw new ServerError(400, `PERCY_MAESTRO_SCREENSHOT_DIR is not an existing directory: ${dir}`); + } + scopeRoot = dir; + } else { + scopeRoot = platform === 'ios' + ? `/tmp/${sessionId}` + : `/tmp/${sessionId}_test_suite`; + } + // Validate regions input shape early (before file I/O and ADB work) so // malformed requests don't consume resolver/relay work. Three parallel // input arrays share the same per-item shape; algorithm semantics differ @@ -476,21 +516,37 @@ export function createPercyServer(percy, port) { // {device}_maestro_debug_ root. The `**` recursive match handles any depth. // Exact {name}.png match at the leaf filters out Maestro's emoji-prefixed // debug frames (e.g., `screenshot-❌--(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');