Skip to content

[build-tools] Allow running Maestro tests with maestro-runner - #4187

Open
sjchmiela wants to merge 6 commits into
mainfrom
stanley/maestro-runner-tests
Open

[build-tools] Allow running Maestro tests with maestro-runner#4187
sjchmiela wants to merge 6 commits into
mainfrom
stanley/maestro-runner-tests

Conversation

@sjchmiela

@sjchmiela sjchmiela commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Why

maestro-runner promises to be faster, more reliable alternative to the official Maestro CLI.

How

Added support for EAS_MAESTRO_BACKEND and/or input to eas/maestro_tests which opts in to using maestro-runner over maestro command.

maestro-runner doesn't support DADB, but we don't error if both are enabled.

maestro-runner has slightly different screenshots and JUnit format -- both are handled. It also exposes a nicer JSON file that lets us parse failed flows without having to parse JUnit file.

This adds relatively big chunk of code, but it's all guarded by backend === maestro-runner so maybe it's not so bad? I considered splitting logic into MaestroCliUtils and MaestroRunnerUtils and expose similar interfaces from them, but I feel it was too complicated.

Test Plan

@sjchmiela sjchmiela added the no changelog PR that doesn't require a changelog entry label Aug 13, 2026
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.08911% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.63%. Comparing base (6cbddf5) to head (9064bbb).

Files with missing lines Patch % Lines
...ld-tools/src/steps/functions/maestroScreenshots.ts 84.85% 10 Missing ⚠️
...es/build-tools/src/steps/functions/maestroTests.ts 94.69% 5 Missing ⚠️
...d-tools/src/steps/functions/maestroResultParser.ts 92.86% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4187      +/-   ##
==========================================
+ Coverage   63.53%   63.63%   +0.10%     
==========================================
  Files        1028     1028              
  Lines       47033    47197     +164     
  Branches     9884     9934      +50     
==========================================
+ Hits        29879    30029     +150     
- Misses      17053    17067      +14     
  Partials      101      101              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sjchmiela
sjchmiela force-pushed the stanley/maestro-runner-tests branch from 5f3a29b to 6d821dd Compare August 13, 2026 20:38
@sjchmiela
sjchmiela force-pushed the stanley/maestro-runner-tests branch 2 times, most recently from f2d4996 to a594330 Compare August 13, 2026 20:53
@sjchmiela sjchmiela changed the title [build-tools] Run Maestro tests with maestro-runner [build-tools] Allow running Maestro tests with maestro-runner Aug 13, 2026
Base automatically changed from stanley/maestro-runner-install to main August 14, 2026 07:28
@sjchmiela
sjchmiela force-pushed the stanley/maestro-runner-tests branch 2 times, most recently from 61db12a to f2afd26 Compare August 14, 2026 07:59
@sjchmiela
sjchmiela changed the base branch from main to stanley/maestro-runner-xcode-compat August 14, 2026 08:01
@sjchmiela
sjchmiela force-pushed the stanley/maestro-runner-tests branch from f2afd26 to de87b9c Compare August 14, 2026 11:12
@sjchmiela
sjchmiela force-pushed the stanley/maestro-runner-tests branch from de87b9c to 65414f2 Compare August 14, 2026 11:24
@sjchmiela
sjchmiela force-pushed the stanley/maestro-runner-tests branch from 65414f2 to 39e37ff Compare August 14, 2026 11:31
@sjchmiela
sjchmiela force-pushed the stanley/maestro-runner-tests branch from 39e37ff to 40892df Compare August 14, 2026 11:44
@sjchmiela
sjchmiela force-pushed the stanley/maestro-runner-tests branch from 40892df to ff2750d Compare August 14, 2026 11:48
@sjchmiela
sjchmiela force-pushed the stanley/maestro-runner-tests branch from ff2750d to b20c996 Compare August 14, 2026 11:51
@sjchmiela sjchmiela changed the title [build-tools] Allow running Maestro tests with maestro-runner [build-tools] Allow running Maestro tests with maestro-runner Aug 14, 2026
@sjchmiela
sjchmiela force-pushed the stanley/maestro-runner-tests branch 4 times, most recently from 10d0c3f to 069a3c1 Compare August 14, 2026 12:46
@sjchmiela
sjchmiela marked this pull request as ready for review August 14, 2026 12:55
@sjchmiela
sjchmiela requested a review from hSATAC August 14, 2026 12:55
Comment on lines +196 to +199
const backend = resolveMaestroBackend({
input: inputs.backend.value,
env,
});

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.

resolveMaestroBackend can throw before the required outputs are published. If the input or EAS_MAESTRO_BACKEND is invalid, the downstream if: always() upload step may fail to interpolate those outputs and obscure the original error.

This seems to be exactly what the “Outputs are published BEFORE any throw below” comment is meant to prevent. Nothing before the output assignments uses backend, so could we move this call below them?

Non-blocking—just moving the call should be enough.

Comment on lines +575 to +587
case 'maestro-runner': {
const results = await Promise.all(
reportDirectories.map(directory => parseMaestroRunnerReport(directory))
);
flowResults = results.some(result => result === null)
? null
: results.flatMap(result => result?.flows ?? []);
break;
}
}
if (flowResults === null) {
logger.warn('Failed to classify failure screenshots; skipping upload.');
return;

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.

Runner screenshot classification is currently all-or-nothing: if any attempt’s report.json cannot be parsed, all previously harvested screenshots are skipped.

For example, an earlier attempt could fail normally and collect screenshots, then the final retry crashes before writing report.json, causing all of them to be discarded. The Maestro path degrades and still uploads, so the comment above about fully failed runs still uploading screenshots does not hold for the runner path.

Could we use the reports that can be parsed, or fall back to uploading everything? The 30-screenshot cap should keep that bounded.

Non-blocking, but I think it’s worth addressing in this PR.

Comment on lines +154 to +171
let report: {
flows?: { name?: string; status?: string; dataFile?: string }[];
};
try {
report = JSON.parse(await fs.readFile(path.join(args.reportDirectory, 'report.json'), 'utf8'));
} catch (err: any) {
args.logger.info(
{ err },
`Skipping maestro-runner screenshot harvest: cannot read ${args.reportDirectory}.`
);
return [];
}

const shots: HarvestedScreenshot[] = [];
for (const flow of report.flows ?? []) {
if (flow.status !== 'failed' || !flow.name || !flow.dataFile) {
continue;
}

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.

I don’t think the “Never throws” guarantee currently holds because report.flows is accessed outside the try.

Valid JSON with an unexpected shape, such as null, { "flows": 5 }, or { "flows": [null] }, can throw here. Since the caller does not catch it, a screenshot issue could fail the step and obscure the test result.

Could we move the loop into the try or validate the shape before iterating?

Non-blocking, small fix.

hSATAC commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

On the “maybe it’s not so bad?” point: I think the current structure is fine for now. maestro-runner is still being validated, so adding it behind backend guards while keeping the existing Maestro path unchanged seems like the lowest-cost approach. I wouldn’t ask to restructure this PR.

If we decide to support both backends long-term, I’d prefer to keep the overall flow shared and resolve the backend-specific runtime once before the loop, instead of adding backend checks throughout. The divergence is still small, but there are already some signs of it: the runner path drops all screenshots if any report is unusable while the Maestro path degrades, and the retry logic has an unreachable branch just for TypeScript narrowing.

Not something we need to do in this PR, but I’d prefer to refactor before the two paths diverge further.

@hSATAC hSATAC left a comment

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.

One more non-blocking comment.

@@ -82,6 +88,17 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
: (tc.error?.['#text'] ?? null)
: null;
const errorMessage: string | null = failureText ?? errorText ?? null;

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.

From junit.go’s failure element construction, the <failure> message attribute contains the actual error from entry.Error, while resolveFailure puts the command label/type in the body.

This currently reads #text, so the runner’s errorMessage may be something like tapOn instead of the actual element not found: ... error, which will also be exposed through the API and website.

Could we prefer @_message and fall back to #text? Official Maestro only uses the body, so its behavior should remain unchanged.

Non-blocking.

Base automatically changed from stanley/maestro-runner-xcode-compat to main August 18, 2026 09:31
sjchmiela and others added 3 commits August 18, 2026 11:41
- Resolve the maestro backend after publishing outputs so an invalid backend
  input can't throw before `if: always()` upload steps can interpolate them.
- Keep runner failure screenshots from attempts that reported when a later
  attempt's report.json is unparseable, instead of discarding all of them.
- Guard the runner screenshot report shape so a malformed report.json can't
  throw out of the harvest that promises never to throw.
- Prefer the JUnit `<failure>`/`<error>` message attribute over the body so the
  runner's real error surfaces instead of the command label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sjchmiela
sjchmiela force-pushed the stanley/maestro-runner-tests branch from 3a0293f to f48b2eb Compare August 18, 2026 09:42
sjchmiela and others added 2 commits August 18, 2026 11:47
Replace the hand-rolled report shape check and the flow-detail `as` cast with
zod schemas: a recursive command schema (zod v4 getter) drives the flow-detail
parse, and a tolerant report schema yields no flows on an unexpected shape
instead of throwing. Add regression cases for malformed report.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…es to Sentry

The zod schemas are expected to always match maestro-runner's output, so a
validation failure is an anomaly worth surfacing: capture it in Sentry (still
without throwing) for both report.json and the per-flow detail file. Drop the
verbose schema comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

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.

Pull request overview

This PR extends the eas/maestro_tests build-step function in @expo/build-tools to support running Maestro tests via an alternate backend (maestro-runner) selected by EAS_MAESTRO_BACKEND and/or a step input. It updates the execution, retry subsetting, and artifact/screenshot parsing paths to accommodate maestro-runner’s output formats while keeping existing Maestro behavior intact.

Changes:

  • Added backend selection (maestro vs maestro-runner) and wired it into spawning, retry logic, and logs.
  • Added maestro-runner-specific parsing for report.json (failed flows + flow results) and screenshot harvesting.
  • Expanded Jest coverage for backend selection, retry behavior, and the new parsers/harvesters.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/build-tools/src/steps/functions/maestroTests.ts Adds backend selection, runner-specific execution/output handling, and integrates runner parsing/harvesting.
packages/build-tools/src/steps/functions/maestroScreenshots.ts Implements maestro-runner screenshot harvesting from report.json bundles.
packages/build-tools/src/steps/functions/maestroResultParser.ts Extends JUnit parsing for runner differences and adds report.json parsing helpers.
packages/build-tools/src/steps/functions/tests/maestroTests.test.ts Adds tests covering backend selection, runner invocation, retry subsetting, and runner artifact handling.
packages/build-tools/src/steps/functions/tests/maestroScreenshots.test.ts Adds tests for harvesting runner failure screenshots from report bundles.
packages/build-tools/src/steps/functions/tests/maestroResultParser.test.ts Adds tests for report.json parsing + runner JUnit file-property handling.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +281 to +285
function resolvePathInsideDirectory(directory: string, relativePath: string): string | null {
const candidate = path.resolve(directory, relativePath);
const relative = path.relative(directory, candidate);
return relative === '' || (!relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
? candidate

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.

Good catch — fixed in 9064bbb. resolvePathInsideDirectory now also rejects an exact .. (relative !== '..'), so a report-provided path that resolves to the parent directory is treated as outside. Added a regression test feeding a screenshot artifact of ...

Comment on lines +337 to +341
// maestro-runner writes its JUnit report and screenshot metadata to this directory.
const runnerOutputDirectory = path.join(
testsDirectory,
`${platform}-maestro-runner-attempt-${attempt}`
);

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.

Fixed in 9064bbb — the runner output directory is now cleared best-effort (fs.rm(dir, { recursive: true, force: true })) before each attempt's spawn, so a crash before it writes fresh output can't resurrect stale results. Added a test asserting the clear happens before the spawn.

Comment on lines 93 to +98
const errorMessage: string | null = failureText ?? errorText ?? null;
// Official Maestro uses status="SUCCESS". maestro-runner uses standard JUnit semantics:
// a testcase passes when it has no failure or error child.
const statusAttribute = tc['@_status'];
const status: 'passed' | 'failed' =
typeof statusAttribute === 'string'

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.

Fixed in 9064bbbparseJUnitContent now excludes <skipped/> testcases instead of counting them as passed, matching the report.json path which already drops skipped flows. Added a test.

Note this mainly hardens the official-Maestro JUnit path; the maestro-runner path classifies off report.json, whose schema already filters status: 'skipped'. Excluding skipped is the correct, consistent behavior either way.

- Reject an exact `..` in resolvePathInsideDirectory, which previously resolved
  to the parent directory and slipped past the containment guard.
- Clear the deterministic maestro-runner output directory before each attempt
  so a crash before it writes fresh output can't resurrect stale results.
- Exclude skipped JUnit testcases (<skipped/>) instead of counting them as
  passed, matching the report.json path which already drops skipped flows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

⏩ The changelog entry check has been skipped since the "no changelog" label is present.

Copilot AI left a comment

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.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (1)

packages/build-tools/src/steps/functions/maestroTests.ts:238

  • backend resolution currently allows an explicit empty string (e.g. a workflow interpolation that resolves to "") to be treated as “not provided”, which can silently fall back to EAS_MAESTRO_BACKEND/default instead of failing validation. This makes misconfiguration harder to detect and can unexpectedly change which binary is invoked.
      const backend = resolveMaestroBackend({
        input: inputs.backend.value,
        env,
      });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no changelog PR that doesn't require a changelog entry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants