Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
613d8c9
feat(core): self-hosted Maestro relay — sessionId optional + PERCY_MA…
Sriram567 May 28, 2026
55574ba
fix(core): self-hosted glob — enable `dot: true` for Maestro `.maestr…
Sriram567 May 28, 2026
eeec0a8
feat(core): iOS self-hosted element-region resolver — probe 7001 + ls…
Sriram567 May 28, 2026
d0c61bb
test(core): cross-platform parity — iOS env-missing now returns self-…
Sriram567 May 28, 2026
8aa75c6
feat(cli-exec): export PERCY_CLI_API alongside PERCY_SERVER_ADDRESS
Sriram567 May 29, 2026
690c500
feat(cli-app): auto-inject -e PERCY_SERVER for `maestro test`
Sriram567 May 29, 2026
d9d508d
feat(cli-app): auto-resolve PERCY_MAESTRO_SCREENSHOT_DIR + --test-out…
Sriram567 Jun 2, 2026
ac86447
feat(core): explicit runtime field gates /percy/maestro-screenshot se…
Sriram567 Jun 2, 2026
a3d9673
fix(core): correct self-hosted maestro runtime routing + stabilize re…
Sriram567 Jun 3, 2026
c01a59d
test(core): cover self-hosted maestro coverage gaps (api.js:667, maes…
Sriram567 Jun 4, 2026
816d37a
test(core): cover remaining maestro-hierarchy iOS self-hosted branch …
Sriram567 Jun 4, 2026
05b2295
test(core): fix lsof multi-match test row format to cover the >1 branch
Sriram567 Jun 4, 2026
d6a88d6
test(core): cover the no-port lsof row branch (maestro-hierarchy.js:1…
Sriram567 Jun 4, 2026
67a14bd
Merge branch 'master' of github.com:percy/cli into feat/self-hosted-m…
Sriram567 Jun 4, 2026
1487d9a
refactor(core,cli-exec): address Rishi review — drop runtime field + …
Sriram567 Jun 9, 2026
13616a8
feat(cli-app): prescribe iOS Maestro driver port via --driver-host-po…
Sriram567 Jun 11, 2026
9f40e23
refactor(core): remove iOS port-discovery cascade after prescribe shift
Sriram567 Jun 11, 2026
a2adad6
fix(cli-app): detect --flag=value equals-form for driver-host-port + …
Sriram567 Jun 14, 2026
a7bc61d
Merge remote-tracking branch 'origin/master' into feat/self-hosted-ma…
Sriram567 Jun 15, 2026
841b8ad
test(core): use os.tmpdir() for self-hosted maestro fixtures (fix Win…
Sriram567 Jun 16, 2026
9c8754a
fix(core): make self-hosted maestro relay path-handling Windows-portable
Sriram567 Jun 16, 2026
e22793d
fix(core): forward-slash normalize both sides of self-hosted realpath…
Sriram567 Jun 16, 2026
8b60341
fix(self-hosted maestro): align iOS port discovery with Maestro 2.4.0…
Sriram567 Jun 16, 2026
93fb5c2
fix(cli-app): inject maestro env+output-dir past global parent flags
Sriram567 Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 159 additions & 1 deletion packages/cli-app/src/exec.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import command from '@percy/cli-command';
import * as ExecPlugin from '@percy/cli-exec';

Expand All @@ -15,6 +18,149 @@ export const start = command('start', {
}
}, ExecPlugin.start.callback);

// Locate the `test` subcommand in argv. Maestro accepts global parent
// flags before the subcommand, e.g.:
// maestro test flow.yaml
// maestro --udid <serial> test flow.yaml
// maestro --platform=android test flow.yaml
// maestro --verbose --no-ansi test flow.yaml
// We must find `test` by scanning rather than checking args[1] === 'test',
// or our injects silently no-op when the customer pins a device.
//
// Returns the index of the `test` literal, or -1 if not present. Skips
// over the value of known value-taking parent flags so a literal `test`
// supplied as a flag value (e.g. `--udid test`) isn't mistaken for the
// subcommand. Equals-form (`--flag=value`) doesn't need a skip — the
// value is part of the same argv token.
const MAESTRO_PARENT_VALUE_FLAGS = new Set([
'--udid', '--device', '-p', '--platform',
'--host', '--port', '--driver-host-port'
]);
function findTestSubcommandIdx(args) {
for (let i = 1; i < args.length; i++) {
const tok = args[i];
if (tok === 'test') return i;
if (typeof tok === 'string' && MAESTRO_PARENT_VALUE_FLAGS.has(tok)) {
i++; // skip the value of this flag
}
}
return -1;
}

function hasExistingPercyServerFlag(args, testIdx) {
for (let i = testIdx + 1; i < args.length - 1; i++) {
if (args[i] === '-e' && /^PERCY_SERVER=/.test(args[i + 1])) return true;
}
return false;
}

// Returns the index of the value following `--test-output-dir`, or -1 if absent.
// We return the value-index (not just a boolean) so the screenshot-dir helper
// can align PERCY_MAESTRO_SCREENSHOT_DIR with a customer-supplied flag value.
function findTestOutputDirValueIdx(args, testIdx) {
for (let i = testIdx + 1; i < args.length - 1; i++) {
if (args[i] === '--test-output-dir') return i + 1;
}
return -1;
}

// Maestro's GraalJS sandbox does NOT inherit the parent process's env,
// so `PERCY_SERVER_ADDRESS` exported by app:exec is invisible to the
// SDK. When wrapping `maestro test`, surface the CLI address through
// Maestro's only env channel — `-e KEY=VALUE` flags — so the SDK
// healthcheck can find the local CLI without the customer having to
// pair ports manually. No-op when the customer already supplied their
// own `-e PERCY_SERVER=...`.
//
// When percy?.address() is falsy (percy disabled, start failed), emit a
// WARN so the customer is not surprised by a silent zero-snapshot build.
// The customer-override skip case (their own `-e PERCY_SERVER=...` is in
// argv) does NOT warn — that's intentional flow control, not a problem.
export function maybeInjectMaestroServer(ctx, log) {
const args = ctx?.argv;
if (!Array.isArray(args) || args.length < 2) return;
if (path.basename(args[0]) !== 'maestro') return;
const testIdx = findTestSubcommandIdx(args);
if (testIdx < 0) return;
if (hasExistingPercyServerFlag(args, testIdx)) return;
const addr = ctx.percy?.address();
if (!addr) {
log?.warn(
'app:exec did not start the Percy CLI server (percy disabled or start ' +
'failed); -e PERCY_SERVER not injected into maestro test. Snapshots will ' +
'NOT be uploaded. Set PERCY_TOKEN and re-run, or check the percy log above.'
Comment on lines +79 to +91

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all this check SDK should do using existing healthcheck endpoint?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The healthcheck would work IF the SDK could read PERCY_SERVER_ADDRESS from its process env — but it can't. Maestro runs JS scripts inside a GraalJS sandbox that doesn't inherit the parent process's environment. The only channel into the sandbox is Maestro's -e KEY=VALUE CLI flag.

So when a customer runs percy app:exec -- maestro test ..., the env var we set on the wrapping process never reaches the SDK; the SDK has no address to healthcheck against, falls back to localhost:5338, and if the CLI is on a non-default port the snapshot silently fails.

maybeInjectMaestroServer exists specifically to bridge that gap — it splices -e PERCY_SERVER=<addr> into the maestro test argv so the value crosses the GraalJS boundary. It's not a check; it's the only mechanism that gets the address through. The WARN is just a safety net for when percy itself didn't start (e.g. missing token) so the customer gets an actionable message instead of silent zero-snapshot builds.

Happy to add a comment block on top of the helper calling out the GraalJS-sandbox rationale so it's not a mystery to future readers.

);
return;
}
// Inject after `test` so `-e KEY=VAL` is bound to the `test` subcommand
// (the only Maestro subcommand that accepts `-e`).
args.splice(testIdx + 1, 0, '-e', `PERCY_SERVER=${addr}`);
}

// Auto-resolve the Maestro screenshot output directory so customers don't
// have to pair `export PERCY_MAESTRO_SCREENSHOT_DIR=...` with a matching
// `--test-output-dir <same>` in their maestro test command.
//
// Resolution order:
// 1. Customer set BOTH process.env.PERCY_MAESTRO_SCREENSHOT_DIR and
// --test-output-dir in argv → trust them, do nothing.
// 2. Customer set PERCY_MAESTRO_SCREENSHOT_DIR only → use it, inject
// `--test-output-dir <env value>` into argv.
// 3. Customer set --test-output-dir only → use that value, mirror it
// into process.env.PERCY_MAESTRO_SCREENSHOT_DIR (so the SDK +
// CLI relay see the same path).
// 4. Neither set → pick `${process.cwd()}/.percy-out`. On any mkdir
// failure (read-only CWD, EACCES, EEXIST as a file), fall back to
// `${os.tmpdir()}/percy-maestro-<pid>` with a WARN log.
//
// The env-var update and argv splice always keep both sources of truth
// (SDK reads env var; Maestro reads the flag) aligned to the same path.
export function maybeInjectScreenshotDir(ctx, log) {
const args = ctx?.argv;
if (!Array.isArray(args) || args.length < 2) return;
if (path.basename(args[0]) !== 'maestro') return;
const testIdx = findTestSubcommandIdx(args);
if (testIdx < 0) return;

const envSet = !!process.env.PERCY_MAESTRO_SCREENSHOT_DIR;
const flagValueIdx = findTestOutputDirValueIdx(args, testIdx);
const flagSet = flagValueIdx > 0;

// Fully customer-controlled — nothing to do.
if (envSet && flagSet) return;

let resolved;
if (envSet) {
resolved = process.env.PERCY_MAESTRO_SCREENSHOT_DIR;
} else if (flagSet) {
resolved = args[flagValueIdx];
} else {
const preferred = path.join(process.cwd(), '.percy-out');
try {
fs.mkdirSync(preferred, { recursive: true });
resolved = preferred;
} catch (err) {
const fallback = path.join(os.tmpdir(), `percy-maestro-${process.pid}`);
try {
fs.mkdirSync(fallback, { recursive: true });
} catch (_) {
// tmpdir mkdir failure is exceedingly rare; fall through and let
// downstream code surface a clearer error than this helper can.
}
resolved = fallback;
log?.warn(
`Could not create ${preferred} (${err.code || err.message}); ` +
`falling back to ${fallback}. Set PERCY_MAESTRO_SCREENSHOT_DIR to ` +
'pick a specific location.'
);
}
}

if (!envSet) process.env.PERCY_MAESTRO_SCREENSHOT_DIR = resolved;
// Inject right after `test` (the subcommand that owns `--test-output-dir`).
if (!flagSet) args.splice(testIdx + 1, 0, '--test-output-dir', resolved);
}

export const exec = command('exec', {
description: 'Start and stop Percy around a supplied command for native apps',
usage: '[options] -- <command>',
Expand All @@ -29,6 +175,18 @@ export const exec = command('exec', {
projectType: 'app',
skipDiscovery: true
}
}, ExecPlugin.default.callback);
}, async function*(ctx) {
// The two helpers splice their flag groups at argv index 2 (between `test`
// and the flow file) because `-e` and `--test-output-dir` are
// `test`-subcommand options. Resulting argv for `maestro test flow.yaml`:
// maestro test --test-output-dir <dir> -e PERCY_SERVER=<url> flow.yaml
// iOS driver port: not prescribed from this side — the @percy/core relay
// reads `PERCY_IOS_DRIVER_HOST_PORT` (BS-host-injected on production
// hosts) and probes the documented Maestro 2.4.0 single-simulator default
// (`127.0.0.1:7001`) when it isn't set. See `packages/core/src/maestro-hierarchy.js`.
maybeInjectMaestroServer(ctx, ctx.log);
maybeInjectScreenshotDir(ctx, ctx.log);
yield* ExecPlugin.default.callback(ctx);
});

export default exec;
2 changes: 1 addition & 1 deletion packages/cli-app/src/index.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { default, app } from './app.js';
export { exec, start, stop, ping } from './exec.js';
export { exec, start, stop, ping, maybeInjectMaestroServer, maybeInjectScreenshotDir } from './exec.js';
Loading
Loading