Compare commits

...
Author SHA1 Message Date
AbbanMustafa 82c650d313 [build] - add preflight check for Compose Compiler and Kotlin version mismatch 2026-05-08 19:04:59 -04:00
Juwan Wheatley 4eb0753a06 [eas-cli] recommend that users read the docs after connecting Convex (#3683)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->

<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

We should direct users to places to learn more about using Convex after connecting the integration.   
  
Fixes ENG-21071

# How

I added some extra output that links to both our docs quickstart and the Convex docs.

# Test Plan

`yarn build` and using the local `easd` alias, run `EXPO_STAGING=true easd integrations:convex:connect`  
  
You should see the new text content at the end:

![Screenshot 2026-05-07 at 21.40.41.png](https://app.graphite.com/user-attachments/assets/132a9115-c0d5-4f2b-aaa2-c08354ac3a6d.png)
2026-05-09 05:32:53 +09:00
Ash Wu bfaff08894 [build-tools] Fix ai-*.json is not guarenteed in Maestro 2.5 (#3639)
* [build-tools] Fix ai-*.json is not guarenteed in Maestro 2.5

* fix param name & use zod according to previous comments

Signed-off-by: Ash Wu <[email protected]>

* address comments

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2026-05-07 14:51:23 +08:00
Mustafa Abban 64874f7dfe [build-tools] route to api v2 endpoint (#3680) 2026-05-06 14:48:19 -04:00
Mustafa Abban daa4c60e9b [build-tools] Log Gradle cache restore events to Datadog (#3674)
* [build-tools] Log Gradle cache restore events to Datadog

* route

* update routes

* remove key tags

* remove key tags

* tags
2026-05-06 13:10:39 -04:00
Stanisław Chmiela ba4e4eddbc [build-tools] Update minimum precompiled modules Expo version (#3677)
<img width="501" height="112" alt="Zrzut ekranu 2026-05-5 o 17 02 16" src="https://github.com/user-attachments/assets/7ee40f33-af83-4504-b1d9-b088674d7e81" />
2026-05-06 10:26:43 +02:00
Expo CI 6bda9c7fa0 update CHANGELOG.md 2026-05-05 16:42:27 +00:00
18 changed files with 811 additions and 608 deletions

No files matched your search

+11 -2
View File
@@ -8,6 +8,17 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
- [build-tools] Update the minimum Expo version required for iOS precompiled modules. ([#3677](https://github.com/expo/eas-cli/pull/3677) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] recommend that users read the docs after connecting Convex ([#3683](https://github.com/expo/eas-cli/pull/3683) by [@fiberjw](https://github.com/fiberjw))
### 🧹 Chores
## [18.11.0](https://github.com/expo/eas-cli/releases/tag/v18.11.0) - 2026-05-05
### 🎉 New features
- [eas-cli] Add `--simulator` to `eas build:dev` and `eas build:run` to select which iOS simulator to install and run builds on. ([#3637](https://github.com/expo/eas-cli/pull/3637) by [@mmichels-brex](https://github.com/mmichels-brex))
### 🐛 Bug fixes
@@ -15,8 +26,6 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Fix Convex team invite output after skipped or unnecessary invitations. ([#3672](https://github.com/expo/eas-cli/pull/3672) by [@fiberjw](https://github.com/fiberjw))
- Make directories marked as read-only writable when creating a tar, to prevent the cross-filesystem issues when extracting the tar. ([#3489](https://github.com/expo/eas-cli/pull/3489) by [@sswrk](https://github.com/sswrk))
### 🧹 Chores
## [18.10.0](https://github.com/expo/eas-cli/releases/tag/v18.10.0) - 2026-05-04
### 🎉 New features
@@ -1,10 +1,7 @@
import { fs } from 'memfs';
const fsRealpath = fs.realpath;
(fsRealpath as any).native = fsRealpath;
const fsRm = (path: string, options: object): Promise<void> => {
return fs.promises.rm(path, options);
};
module.exports = { ...fs.promises, realpath: fsRealpath, rm: fsRm };
module.exports = { ...fs.promises, rm: fsRm };
@@ -66,8 +66,8 @@ describe(installPods, () => {
});
});
it('adds precompiled modules env var for Expo versions that support them', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.18' });
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
const ctx = makeIosBuildContext({ expoVersion: '999.0.0' });
await installPods(ctx, {});
@@ -96,7 +96,7 @@ describe(installPods, () => {
(spawn as jest.Mock).mock.calls[1][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=55.0.18; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nPrecompiled modules pod install environment is configured.'
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nPrecompiled modules pod install environment is configured.'
);
});
@@ -107,7 +107,9 @@ describe(installPods, () => {
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=55.0.17; not enabling precompiled modules use because precompiled modules require expo>=55.0.18.'
expect.stringMatching(
/^Detected expo=55\.0\.17; not enabling precompiled modules use because precompiled modules require expo>=/
)
);
});
@@ -136,7 +138,7 @@ describe(installPods, () => {
it('does not add precompiled modules env vars without the builder flag', async () => {
const ctx = makeIosBuildContext({
expoVersion: '55.0.18',
expoVersion: '999.0.0',
usePrecompiledModules: false,
});
+1 -1
View File
@@ -6,7 +6,7 @@ import semver from 'semver';
import { BuildContext } from '../context';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.18';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.21';
// const PRECOMPILED_MODULES_BASE_URL = 'https://storage.googleapis.com/eas-build-precompiled-modules/';
export async function installPods<TJob extends Ios.Job>(
@@ -0,0 +1,302 @@
import { vol } from 'memfs';
import { buildFlowNameToPathMap, readFlowName, walkDir } from '../maestroFlowDiscovery';
import { createMockLogger } from '../../../__tests__/utils/logger';
describe(readFlowName, () => {
beforeEach(() => vol.reset());
it('returns top-level `name` when present', async () => {
vol.fromJSON({
'/p/login.yml': `appId: com.example\nname: Login Flow\n---\n- launchApp\n`,
});
expect(await readFlowName('/p/login.yml')).toBe('Login Flow');
});
it('falls back to basename without extension when name absent', async () => {
vol.fromJSON({ '/p/login.yml': `appId: com.example\n---\n- launchApp\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when name is empty string', async () => {
vol.fromJSON({ '/p/login.yml': `name: ""\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when name is non-string', async () => {
vol.fromJSON({ '/p/login.yml': `name: 42\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when YAML is malformed', async () => {
vol.fromJSON({ '/p/login.yml': `appId: [unclosed\n` });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when file is empty', async () => {
vol.fromJSON({ '/p/login.yml': '' });
expect(await readFlowName('/p/login.yml')).toBe('login');
});
it('falls back when file does not exist', async () => {
expect(await readFlowName('/p/missing.yml')).toBe('missing');
});
it('only parses the first YAML document (commands document errors are ignored)', async () => {
vol.fromJSON({
'/p/login.yml': `name: Login\n---\n- this is: { malformed yaml\n`,
});
expect(await readFlowName('/p/login.yml')).toBe('Login');
});
it('handles .yaml extension', async () => {
vol.fromJSON({ '/p/checkout.yaml': `name: Checkout\n` });
expect(await readFlowName('/p/checkout.yaml')).toBe('Checkout');
});
it('handles a leading `---` document marker', async () => {
vol.fromJSON({
'/p/login.yml': `---\nappId: com.example\nname: Login\n---\n- launchApp\n`,
});
expect(await readFlowName('/p/login.yml')).toBe('Login');
});
});
describe(walkDir, () => {
beforeEach(() => vol.reset());
it('collects all .yml/.yaml files recursively, sorted', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/p/flows/a.yml': '',
'/p/flows/b.yaml': '',
'/p/flows/sub/c.yml': '',
'/p/flows/sub/d.YAML': '',
'/p/flows/readme.md': '',
});
const out: string[] = [];
await walkDir('/p/flows', new Set(), out, logger);
expect(out.sort()).toEqual([
'/p/flows/a.yml',
'/p/flows/b.yaml',
'/p/flows/sub/c.yml',
'/p/flows/sub/d.YAML',
]);
});
it('skips files named config.yaml (case-insensitive)', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/p/flows/config.yaml': '',
'/p/flows/CONFIG.yaml': '',
'/p/flows/login.yml': '',
});
const out: string[] = [];
await walkDir('/p/flows', new Set(), out, logger);
expect(out.sort()).toEqual(['/p/flows/login.yml']);
});
it('returns silently when realpath fails (e.g. missing dir)', async () => {
const logger = createMockLogger();
const out: string[] = [];
await walkDir('/p/missing', new Set(), out, logger);
expect(out).toEqual([]);
});
});
describe(buildFlowNameToPathMap, () => {
beforeEach(() => vol.reset());
it('builds a map from a single file input (basename name)', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/flows/login.yml': 'appId: x\n' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['flows/login.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['login', 'flows/login.yml']]));
});
it('uses top-level name when present', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/flows/a.yml': 'name: Login Flow\n' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['flows/a.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['Login Flow', 'flows/a.yml']]));
});
it('walks a directory input and adds each flow', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/login.yml': 'name: Login\n',
'/proj/.maestro/checkout.yml': '',
'/proj/.maestro/sub/profile.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(
new Map([
['Login', '.maestro/login.yml'],
['checkout', '.maestro/checkout.yml'],
['profile', '.maestro/sub/profile.yml'],
])
);
});
it('merges file + directory inputs', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/a.yml': '',
'/proj/extra/b.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro', 'extra/b.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(
new Map([
['a', '.maestro/a.yml'],
['b', 'extra/b.yml'],
])
);
});
it('skips non-existent inputs with warn', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/flows/a.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['flows/missing.yml', 'flows/a.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['a', 'flows/a.yml']]));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('flows/missing.yml'));
});
it('skips workspace config.yaml inside directory inputs', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/config.yaml': 'flows:\n - "*.yml"\n',
'/proj/.maestro/login.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
});
it('does NOT skip config.yaml when passed as an explicit file input', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/.maestro/config.yaml': 'name: Workspace Defaults\nflows:\n - "*.yml"\n',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro/config.yaml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['Workspace Defaults', '.maestro/config.yaml']]));
});
it('returns null when two files share top-level name', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/a.yml': 'name: Login\n',
'/proj/b.yml': 'name: Login\n',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['a.yml', 'b.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('"Login"'));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('a.yml'));
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('b.yml'));
});
it('returns null when two files share basename across directories', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/login/index.yml': '',
'/proj/checkout/index.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['login', 'checkout'],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
});
it('returns null when name collides with another file basename', async () => {
const logger = createMockLogger();
vol.fromJSON({
'/proj/login.yml': 'name: Other\n',
'/proj/Other.yml': '',
});
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['login.yml', 'Other.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
});
it('dedupes the same physical file passed via overlapping inputs', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/.maestro/login.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['.maestro', '.maestro/login.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
expect(logger.warn).not.toHaveBeenCalled();
});
it('uses absolute fallback when input file lives outside projectRoot', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/elsewhere/a.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['/elsewhere/a.yml'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['a', '/elsewhere/a.yml']]));
});
it('absolute directory input under projectRoot yields project-relative children', async () => {
const logger = createMockLogger();
vol.fromJSON({ '/proj/.maestro/a.yml': '' });
const map = await buildFlowNameToPathMap({
inputFlowPaths: ['/proj/.maestro'],
projectRoot: '/proj',
logger,
});
expect(map).toEqual(new Map([['a', '.maestro/a.yml']]));
});
it('returns null and warns on unexpected helper failure', async () => {
const logger = createMockLogger();
const map = await buildFlowNameToPathMap({
inputFlowPaths: [Symbol('bad') as unknown as string],
projectRoot: '/proj',
logger,
});
expect(map).toBeNull();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('buildFlowNameToPathMap failed unexpectedly')
);
});
});
@@ -6,63 +6,13 @@ import {
copyLatestAttemptXml,
mergeJUnitReports,
parseFailedFlowsFromJUnit,
parseFlowMetadata,
parseJUnitTestCases,
parseMaestroResults,
} from '../maestroResultParser';
describe(parseFlowMetadata, () => {
it('parses valid ai-*.json', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toEqual({
flow_name: 'home',
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
});
});
it('returns null when flow_name is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null when flow_file_path is missing', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
}),
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
it('returns null for invalid JSON', async () => {
vol.fromJSON({
'/tests/2026-01-28_055409/ai-home.json': 'not json',
});
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
expect(result).toBeNull();
});
});
describe(parseMaestroResults, () => {
it('parses JUnit results and enriches with ai-*.json metadata', async () => {
it('parses JUnit results and enriches with name→path map', async () => {
vol.fromJSON({
// JUnit XML (primary data)
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -74,18 +24,15 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output (for flow_file_path)
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults(
'/junit',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
@@ -113,7 +60,7 @@ describe(parseMaestroResults, () => {
);
});
it('calculates retryCount from timestamp directory occurrences', async () => {
it('uses flow name as fallback path when nameToPath is null', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
@@ -123,49 +70,33 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Two timestamp dirs = 1 retry
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].retryCount).toBe(1);
});
it('uses flow name as fallback path when ai-*.json not found', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// No ai-*.json files
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults('/junit', null);
expect(results[0].path).toBe('home');
expect(results[0].retryCount).toBe(0);
});
it('returns empty array when no JUnit files found', async () => {
it('uses flow name as fallback path when name not in map', async () => {
vol.fromJSON({
'/junit/.gitkeep': '',
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults('/junit', new Map());
expect(results[0].path).toBe('home');
});
it('returns empty array when no JUnit files found', async () => {
vol.fromJSON({ '/junit/.gitkeep': '' });
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
expect(results).toEqual([]);
});
@@ -184,44 +115,13 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
expect(results[0].tags).toEqual(['e2e', 'smoke']);
expect(results[0].properties).toEqual({ env: 'staging' });
});
it('handles reuse_devices=true (junit_report_directory == tests_directory)', async () => {
vol.fromJSON({
// Same directory for both JUnit and debug output
'/maestro-tests/android-maestro-junit.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
expect(results).toEqual([
expect.objectContaining({
name: 'home',
path: '.maestro/home.yml',
status: 'passed',
}),
]);
});
it('returns per-attempt results when multiple JUnit files exist for the same flow', async () => {
vol.fromJSON({
// Attempt 0: login FAILED
@@ -244,20 +144,11 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// ai-*.json metadata (2 timestamp dirs = 2 attempts)
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
const results = await parseMaestroResults('/junit', new Map([['login', '.maestro/login.yml']]));
// Should return 2 results — one per attempt
// Should return 2 results — one per attempt; retryCount from filename
expect(results).toHaveLength(2);
expect(results).toEqual([
expect.objectContaining({
@@ -303,25 +194,15 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
const results = await parseMaestroResults(
'/junit-reports',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
// 4 results: 2 flows × 2 attempts
expect(results).toHaveLength(4);
@@ -335,7 +216,6 @@ describe(parseMaestroResults, () => {
it('returns per-attempt results with sharding (multiple testsuites per attempt file)', async () => {
vol.fromJSON({
// Attempt 0: shard 1 has home (FAILED), shard 2 has login (PASSED)
'/junit-reports/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -349,7 +229,6 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Attempt 1: all passed across shards
'/junit-reports/android-maestro-junit-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -361,25 +240,15 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/junit-reports', '/tests', '/root/project');
const results = await parseMaestroResults(
'/junit-reports',
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
expect(results).toHaveLength(4);
expect(results).toEqual([
@@ -390,52 +259,8 @@ describe(parseMaestroResults, () => {
]);
});
it('backward compat: reuse_devices=true with retries but old single JUnit file', async () => {
it('handles reuse_devices=false (separate junit_report_directory, per-flow files)', async () => {
vol.fromJSON({
// Single overwritten JUnit (only has final attempt's results)
'/maestro-tests/android-maestro-junit.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="2" failures="0">',
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
' <testcase id="login" name="login" classname="login" time="2.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
// 2 timestamp dirs — both flows appear in both (entire suite retried)
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/maestro-tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
'/maestro-tests/2026-01-28_055420/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/maestro-tests/2026-01-28_055420/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
// Both flows have 2 occurrences → retryCount = 1 for both
expect(results).toHaveLength(2);
expect(results).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
])
);
});
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
vol.fromJSON({
// JUnit in temp dir (per-flow files)
'/tmp/maestro-reports-abc123/junit-report-flow-1.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
@@ -454,21 +279,14 @@ describe(parseMaestroResults, () => {
' </testsuite>',
'</testsuites>',
].join('\n'),
// Debug output in default location
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
flow_name: 'login',
flow_file_path: '/root/project/.maestro/login.yml',
}),
});
const results = await parseMaestroResults(
'/tmp/maestro-reports-abc123',
'/tests',
'/root/project'
new Map([
['home', '.maestro/home.yml'],
['login', '.maestro/login.yml'],
])
);
expect(results).toHaveLength(2);
expect(results).toEqual(
@@ -478,51 +296,6 @@ describe(parseMaestroResults, () => {
])
);
});
it('uses raw path when flow_file_path is outside project_root', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/somewhere/else/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
expect(results[0].path).toBe('/somewhere/else/.maestro/home.yml');
});
it('filters out non-timestamp directories when scanning debug output', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites>',
' <testsuite name="Test Suite" tests="1" failures="0">',
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
' </testsuite>',
'</testsuites>',
].join('\n'),
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
'/tests/not-a-timestamp/ai-home.json': JSON.stringify({
flow_name: 'home',
flow_file_path: '/root/project/.maestro/home.yml',
}),
});
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
// Only 1 occurrence (not-a-timestamp dir should be ignored)
expect(results[0].retryCount).toBe(0);
});
});
describe(parseJUnitTestCases, () => {
@@ -811,11 +584,7 @@ describe(parseJUnitTestCases, () => {
describe('parseFailedFlowsFromJUnit', () => {
it('returns the subset of input flow paths whose testcases failed', async () => {
// memfs setup: 3 input flows, 1 junit file with 1 failure, 1 timestamp dir with 3 ai-*.json
vol.fromJSON({
'/project/flows/login.yaml': '',
'/project/flows/search.yaml': '',
'/project/flows/checkout.yaml': '',
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites>
<testsuite>
@@ -824,82 +593,49 @@ describe('parseFailedFlowsFromJUnit', () => {
<testcase name="Checkout" time="1.0"><failure>something</failure></testcase>
</testsuite>
</testsuites>`,
'/tmp/tests/2026-04-23_120000/ai-Login.json': JSON.stringify({
flow_name: 'Login',
flow_file_path: '/project/flows/login.yaml',
}),
'/tmp/tests/2026-04-23_120000/ai-Search.json': JSON.stringify({
flow_name: 'Search',
flow_file_path: '/project/flows/search.yaml',
}),
'/tmp/tests/2026-04-23_120000/ai-Checkout.json': JSON.stringify({
flow_name: 'Checkout',
flow_file_path: '/project/flows/checkout.yaml',
}),
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
testsDirectory: '/tmp/tests',
inputFlowPaths: ['flows/login.yaml', 'flows/search.yaml', 'flows/checkout.yaml'],
projectRoot: '/project',
nameToPath: new Map([
['Login', 'flows/login.yaml'],
['Search', 'flows/search.yaml'],
['Checkout', 'flows/checkout.yaml'],
]),
});
expect(result).toEqual(['flows/checkout.yaml']);
});
it('accepts flow files discovered under a directory input (documented usage)', async () => {
// Users may set `flow_path: ./maestro/flows` (a directory). Maestro then
// discovers the YAMLs inside; smart retry must still subset to the failing
// child file, not fall back to dumb retry.
it('returns null when a failing testcase has no entry in nameToPath', async () => {
vol.fromJSON({
'/project/flows/login.yaml': '',
'/project/flows/checkout.yaml': '',
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Login" status="SUCCESS" time="1.0" />
<testcase name="Checkout" time="1.0"><failure>x</failure></testcase>
<testcase name="Unknown" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/tmp/tests/2026-04-23_120000/ai-Login.json': JSON.stringify({
flow_name: 'Login',
flow_file_path: '/project/flows/login.yaml',
}),
'/tmp/tests/2026-04-23_120000/ai-Checkout.json': JSON.stringify({
flow_name: 'Checkout',
flow_file_path: '/project/flows/checkout.yaml',
}),
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/attempt-0.xml',
testsDirectory: '/tmp/tests',
inputFlowPaths: ['flows'],
projectRoot: '/project',
nameToPath: new Map([['Login', 'flows/login.yaml']]),
});
expect(result).toEqual(['flows/checkout.yaml']);
expect(result).toBeNull();
});
it('returns null when two failing testcases share the same name (collision)', async () => {
vol.fromJSON({
'/project/flows/a.yaml': '',
'/project/flows/b.yaml': '',
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Duplicate" time="1.0"><failure>x</failure></testcase>
<testcase name="Duplicate" time="1.0"><failure>y</failure></testcase>
</testsuite></testsuites>`,
'/tmp/tests/2026-04-23_120000/ai-Duplicate.json': JSON.stringify({
flow_name: 'Duplicate',
flow_file_path: '/project/flows/a.yaml',
}),
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
testsDirectory: '/tmp/tests',
inputFlowPaths: ['flows/a.yaml', 'flows/b.yaml'],
projectRoot: '/project',
nameToPath: new Map([['Duplicate', 'flows/a.yaml']]),
});
expect(result).toBeNull();
@@ -907,38 +643,25 @@ describe('parseFailedFlowsFromJUnit', () => {
it('returns null when a failing testcase shares a name with any other testcase (pass or fail)', async () => {
vol.fromJSON({
'/project/flows/a.yaml': '',
'/project/flows/b.yaml': '',
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="Shared" status="SUCCESS" time="1.0" />
<testcase name="Shared" time="2.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/tmp/tests/2026-04-23_120000/ai-Shared.json': JSON.stringify({
flow_name: 'Shared',
flow_file_path: '/project/flows/a.yaml',
}),
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
testsDirectory: '/tmp/tests',
inputFlowPaths: ['flows/a.yaml', 'flows/b.yaml'],
projectRoot: '/project',
nameToPath: new Map([['Shared', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
it('returns null when junit file does not exist', async () => {
vol.fromJSON({
'/project/flows/a.yaml': '',
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/missing.xml',
testsDirectory: '/tmp/tests',
inputFlowPaths: ['flows/a.yaml'],
projectRoot: '/project',
nameToPath: new Map([['A', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
@@ -949,9 +672,7 @@ describe('parseFailedFlowsFromJUnit', () => {
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/bad.xml',
testsDirectory: '/tmp/tests',
inputFlowPaths: ['flows/a.yaml'],
projectRoot: '/project',
nameToPath: new Map([['A', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
@@ -959,50 +680,33 @@ describe('parseFailedFlowsFromJUnit', () => {
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
// fast-xml-parser can produce a partial parse from truncated XML — without
// strict validation, smart retry would only retry the visible failures and
// silently skip flows that were cut off, masking real failures when the
// subset retry passes. Validation must reject the file → dumb retry.
// silently skip flows that were cut off.
vol.fromJSON({
'/project/flows/a.yaml': '',
'/project/flows/b.yaml': '',
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" time="1.0"><failure>x</failure></testcase>
<testcase name="B"`, // intentionally truncated mid-tag
'/tmp/tests/2026-04-23_120000/ai-A.json': JSON.stringify({
flow_name: 'A',
flow_file_path: '/project/flows/a.yaml',
}),
'/tmp/tests/2026-04-23_120000/ai-B.json': JSON.stringify({
flow_name: 'B',
flow_file_path: '/project/flows/b.yaml',
}),
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/attempt-0.xml',
testsDirectory: '/tmp/tests',
inputFlowPaths: ['flows/a.yaml', 'flows/b.yaml'],
projectRoot: '/project',
nameToPath: new Map([
['A', 'flows/a.yaml'],
['B', 'flows/b.yaml'],
]),
});
expect(result).toBeNull();
});
it('returns null when junit XML has unclosed tags (partial parse risk)', async () => {
vol.fromJSON({
'/project/flows/a.yaml': '',
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" time="1.0"><failure>x</failure></testcase>
</testsuite>`, // missing </testsuites>
'/tmp/tests/2026-04-23_120000/ai-A.json': JSON.stringify({
flow_name: 'A',
flow_file_path: '/project/flows/a.yaml',
}),
});
const result = await parseFailedFlowsFromJUnit({
junitFile: '/tmp/junit-reports/attempt-0.xml',
testsDirectory: '/tmp/tests',
inputFlowPaths: ['flows/a.yaml'],
projectRoot: '/project',
nameToPath: new Map([['A', 'flows/a.yaml']]),
});
expect(result).toBeNull();
});
@@ -72,7 +72,7 @@ describe(createReportMaestroTestResultsFunction, () => {
it('parses JUnit results and calls GraphQL mutation', async () => {
vol.fromJSON({
'/junit/report.xml': JUNIT_PASS,
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
'/root/project/.maestro/home.yml': '',
});
mockMutationFn.mockResolvedValue({
@@ -83,7 +83,11 @@ describe(createReportMaestroTestResultsFunction, () => {
},
});
await createStep().executeAsync();
await createStep({
callInputs: {
flow_path: ['.maestro/home.yml'],
},
}).executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
@@ -0,0 +1,190 @@
import type { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fs from 'fs/promises';
import path from 'path';
import yaml from 'yaml';
const YAML_EXT = /\.ya?ml$/i;
const WORKSPACE_CONFIG_BASENAME = 'config.yaml';
export interface BuildFlowNameToPathMapArgs {
inputFlowPaths: string[];
projectRoot: string;
logger: bunyan;
}
interface FlowEntry {
name: string;
path: string;
realpath: string;
}
export async function buildFlowNameToPathMap(
args: BuildFlowNameToPathMapArgs
): Promise<Map<string, string> | null> {
try {
const flows = await discoverFlows(args);
return dedupAndDetectDuplicates(flows, args.logger);
} catch (err: any) {
args.logger.warn(`buildFlowNameToPathMap failed unexpectedly: ${err?.message ?? String(err)}`);
return null;
}
}
async function discoverFlows({
inputFlowPaths,
projectRoot,
logger,
}: BuildFlowNameToPathMapArgs): Promise<FlowEntry[]> {
const realRootResult = await asyncResult(fs.realpath(projectRoot));
const realRoot = realRootResult.ok ? realRootResult.value : projectRoot;
const fileLists = await Promise.all(
inputFlowPaths.map(async input => {
const abs = path.resolve(projectRoot, input);
const statResult = await asyncResult(fs.stat(abs));
if (!statResult.ok) {
logger.warn(`flow_path entry "${input}" not found, skipping`);
return [];
}
const stat = statResult.value;
if (stat.isFile() && YAML_EXT.test(abs)) {
return [abs];
}
if (stat.isDirectory()) {
const out: string[] = [];
await walkDir(abs, new Set(), out, logger);
return out;
}
return [];
})
);
return Promise.all(fileLists.flat().map(absFile => makeEntry(absFile, realRoot)));
}
async function makeEntry(absFile: string, realRoot: string): Promise<FlowEntry> {
const [name, realFileResult] = await Promise.all([
readFlowName(absFile),
asyncResult(fs.realpath(absFile)),
]);
const realFile = realFileResult.ok ? realFileResult.value : absFile;
const rel = path.relative(realRoot, realFile);
const value = rel.startsWith('..') || path.isAbsolute(rel) ? absFile : rel;
return { name, path: value, realpath: realFile };
}
function dedupAndDetectDuplicates(flows: FlowEntry[], logger: bunyan): Map<string, string> | null {
const byKey = new Map<string, FlowEntry>();
for (const f of flows) {
if (!byKey.has(f.realpath)) {
byKey.set(f.realpath, f);
}
}
const unique = [...byKey.values()];
const nameToPath = new Map<string, string>();
const duplicates = new Map<string, string[]>();
for (const f of unique) {
if (nameToPath.has(f.name)) {
const existing = nameToPath.get(f.name)!;
const list = duplicates.get(f.name) ?? [existing];
list.push(f.path);
duplicates.set(f.name, list);
}
nameToPath.set(f.name, f.path);
}
if (duplicates.size > 0) {
for (const [name, paths] of duplicates) {
logger.warn(`Duplicate Maestro flow name "${name}" across paths: ${paths.join(', ')}.`);
}
logger.warn(
'Smart retry disabled for this run; will retry all flows on failure. ' +
'Give each Maestro flow a unique name (file basename or top-level name) to enable smart retry.'
);
return null;
}
return nameToPath;
}
export async function walkDir(
dir: string,
visited: Set<string>,
out: string[],
logger: bunyan
): Promise<void> {
// Cycle protection: dedup on realpath (fall back to the original path when
// realpath fails so we still make progress on missing or restricted dirs).
const realResult = await asyncResult(fs.realpath(dir));
const real = realResult.ok ? realResult.value : dir;
if (visited.has(real)) {
return;
}
visited.add(real);
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err: any) {
logger.warn(`readdir failed for ${dir}: ${err?.message ?? String(err)}`);
return;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isSymbolicLink()) {
const tgtResult = await asyncResult(fs.stat(full));
if (!tgtResult.ok) {
continue;
}
const tgt = tgtResult.value;
if (tgt.isDirectory()) {
await walkDir(full, visited, out, logger);
} else if (tgt.isFile() && isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
out.push(full);
}
} else if (entry.isDirectory()) {
await walkDir(full, visited, out, logger);
} else if (entry.isFile()) {
if (isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
out.push(full);
}
}
}
}
export async function readFlowName(absFile: string): Promise<string> {
const ext = path.extname(absFile);
const fallback = path.basename(absFile, ext);
let content: string;
try {
content = await fs.readFile(absFile, 'utf-8');
} catch {
return fallback;
}
let firstDoc;
try {
const docs = yaml.parseAllDocuments(content);
firstDoc = docs[0];
} catch {
return fallback;
}
if (!firstDoc || firstDoc.errors.length > 0) {
return fallback;
}
const parsed = firstDoc.toJS();
const name = parsed?.name;
if (typeof name === 'string' && name.length > 0) {
return name;
}
return fallback;
}
function isYamlFile(name: string): boolean {
return YAML_EXT.test(name);
}
function isWorkspaceConfig(name: string): boolean {
return name.toLowerCase() === WORKSPACE_CONFIG_BASENAME;
}
@@ -1,7 +1,6 @@
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
export interface MaestroFlowResult {
name: string;
@@ -14,17 +13,9 @@ export interface MaestroFlowResult {
properties: Record<string, string>;
}
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
const ATTEMPT_PATTERN = /attempt-(\d+)/;
export function extractFlowKey(filename: string, prefix: string): string | null {
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
return match?.[1] ?? null;
}
export interface JUnitTestCaseResult {
name: string;
status: 'passed' | 'failed';
@@ -140,42 +131,10 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
return perFile.flat();
}
const FlowMetadataFileSchema = z.object({
flow_name: z.string(),
flow_file_path: z.string(),
});
type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
/**
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
*
* The file contains:
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
* the flow filename without extension.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
* - `flow_file_path`: absolute path to the original flow YAML file.
* - `outputs`: screenshot defect data (unused here).
*
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
*/
export async function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null> {
try {
const content = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(content);
return FlowMetadataFileSchema.parse(data);
} catch {
return null;
}
}
export async function parseMaestroResults(
junitDirectory: string,
testsDirectory: string,
projectRoot: string
nameToPath: Map<string, string> | null
): Promise<MaestroFlowResult[]> {
// 1. Parse JUnit XML files, tracking which file each result came from
let junitEntries: string[];
try {
junitEntries = await fs.readdir(junitDirectory);
@@ -204,51 +163,8 @@ export async function parseMaestroResults(
return [];
}
// 2. Parse ai-*.json from debug output for flow_file_path + retryCount
const flowPathMap = new Map<string, string>(); // flowName → flowFilePath
const flowOccurrences = new Map<string, number>(); // flowName → count
let entries: string[];
try {
entries = await fs.readdir(testsDirectory);
} catch {
entries = [];
}
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
for (const dir of timestampDirs) {
const dirPath = path.join(testsDirectory, dir);
let files: string[];
try {
files = await fs.readdir(dirPath);
} catch {
continue;
}
for (const file of files) {
const flowKey = extractFlowKey(file, 'ai');
if (!flowKey) {
continue;
}
const metadata = await parseFlowMetadata(path.join(dirPath, file));
if (!metadata) {
continue;
}
// Track latest path (last timestamp dir wins)
flowPathMap.set(metadata.flow_name, metadata.flow_file_path);
// Count occurrences for retryCount
flowOccurrences.set(metadata.flow_name, (flowOccurrences.get(metadata.flow_name) ?? 0) + 1);
}
}
// 3. Merge: JUnit results + ai-*.json metadata
const results: MaestroFlowResult[] = [];
// Group results by flow name
const resultsByName = new Map<string, JUnitResultWithSource[]>();
for (const entry of junitResultsWithSource) {
const group = resultsByName.get(entry.result.name) ?? [];
@@ -257,50 +173,28 @@ export async function parseMaestroResults(
}
for (const [flowName, flowEntries] of resultsByName) {
const flowFilePath = flowPathMap.get(flowName);
const relativePath = flowFilePath
? await relativizePathAsync(flowFilePath, projectRoot)
: flowName;
if (flowEntries.length === 1) {
// Single result for this flow — use ai-*.json occurrence count for retryCount
// (backward compat with old-style single JUnit file that gets overwritten)
const { result } = flowEntries[0];
const occurrences = flowOccurrences.get(flowName) ?? 0;
const retryCount = Math.max(0, occurrences - 1);
const flowPath = nameToPath?.get(flowName) ?? flowName;
// retryCount is derived from each entry's source filename (`attempt-N`);
// a single entry from `report.xml` (no marker) collapses to attempt 0.
const sorted = flowEntries
.map(entry => {
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
return { ...entry, attemptIndex };
})
.sort((a, b) => a.attemptIndex - b.attemptIndex);
for (const { result, attemptIndex } of sorted) {
results.push({
name: flowName,
path: relativePath,
path: flowPath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount,
retryCount: attemptIndex,
tags: result.tags,
properties: result.properties,
});
} else {
// Multiple results — per-attempt JUnit files. Sort by attempt index from filename.
const sorted = flowEntries
.map(entry => {
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
return { ...entry, attemptIndex };
})
.sort((a, b) => a.attemptIndex - b.attemptIndex);
for (const { result, attemptIndex } of sorted) {
results.push({
name: flowName,
path: relativePath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount: attemptIndex,
tags: result.tags,
properties: result.properties,
});
}
}
}
@@ -312,15 +206,13 @@ export async function parseMaestroResults(
* attempt's JUnit file, or `null` when the result cannot be trusted (caller
* then falls back to dumb retry — re-run everything).
*
* Mapping: <testcase> only carries `name`, so we recover `flow_file_path`
* from `ai-${flow_name}.json` under testsDirectory and match it back to
* inputFlowPaths.
* Caller passes a precomputed `nameToPath` map built from the user's
* `inputFlowPaths`. We translate failed testcase names to flow paths via
* that map; unknown names → null.
*/
export async function parseFailedFlowsFromJUnit(args: {
junitFile: string;
testsDirectory: string;
inputFlowPaths: string[];
projectRoot: string;
nameToPath: Map<string, string>;
}): Promise<string[] | null> {
// fast-xml-parser is lenient — truncated XML can produce a partial parse
// with some testcases dropped. Trusting that subset for smart retry would
@@ -347,8 +239,9 @@ export async function parseFailedFlowsFromJUnit(args: {
}
// Two testcases with the same name (pass+fail or fail+fail) make it
// impossible to map back to a single input flow_path, since the
// ai-*.json keyed map collapses duplicates. Signal "unknown" → dumb retry.
// impossible to map back to a single input flow_path. Signal "unknown".
// Complementary to the map-build duplicate check (which catches the case
// where tag filter hides duplication from JUnit).
const allNameCounts = new Map<string, number>();
for (const tc of testcases) {
allNameCounts.set(tc.name, (allNameCounts.get(tc.name) ?? 0) + 1);
@@ -359,62 +252,17 @@ export async function parseFailedFlowsFromJUnit(args: {
}
}
// Build flow_name → flow_file_path map from ai-*.json across timestamped
// subdirectories (same traversal as parseMaestroResults).
const nameToPath = new Map<string, string>();
let entries: string[];
try {
entries = await fs.readdir(args.testsDirectory);
} catch {
entries = [];
}
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
for (const dir of timestampDirs) {
const dirPath = path.join(args.testsDirectory, dir);
let files: string[];
try {
files = await fs.readdir(dirPath);
} catch {
continue;
}
for (const file of files) {
const flowKey = extractFlowKey(file, 'ai');
if (!flowKey) {
continue;
}
const metadata = await parseFlowMetadata(path.join(dirPath, file));
if (!metadata) {
continue;
}
// Latest timestamp dir wins if the same flow appears in multiple attempts.
nameToPath.set(metadata.flow_name, metadata.flow_file_path);
}
}
const matched: string[] = [];
for (const tc of failing) {
const abs = nameToPath.get(tc.name);
if (!abs) {
return null; // unknown mapping; safer to fall back
}
const relative = await relativizePathAsync(abs, args.projectRoot);
// Accept exact matches and flow files discovered under an input directory
// (documented usage: `flow_path: ./maestro/flows` discovers nested .yml).
// Anything outside every input is treated as out-of-scope → dumb retry.
if (!args.inputFlowPaths.some(input => isPathWithinOrEqual(relative, input))) {
const p = args.nameToPath.get(tc.name);
if (p === undefined) {
return null;
}
matched.push(relative);
matched.push(p);
}
return matched;
}
function isPathWithinOrEqual(child: string, parent: string): boolean {
const rel = path.relative(parent, child);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
}
/**
* Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
* attempt's <testcase> per unique flow name (latest determined from the
@@ -581,25 +429,3 @@ export async function copyLatestAttemptXml(args: {
await fs.copyFile(path.join(args.sourceDir, winner), args.outputPath);
}
async function relativizePathAsync(flowFilePath: string, projectRoot: string): Promise<string> {
if (!path.isAbsolute(flowFilePath)) {
return flowFilePath;
}
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
let resolvedRoot = projectRoot;
let resolvedFlow = flowFilePath;
try {
resolvedRoot = await fs.realpath(projectRoot);
} catch {}
try {
resolvedFlow = await fs.realpath(flowFilePath);
} catch {}
const relative = path.relative(resolvedRoot, resolvedFlow);
if (relative.startsWith('..')) {
return flowFilePath;
}
return relative;
}
@@ -11,6 +11,7 @@ import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
mergeJUnitReports,
@@ -192,6 +193,14 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
throw new SystemError('Failed to create JUnit report directory', { cause: err });
}
// null → duplicate flow names; smart retry disabled, fall through to
// dumb retry (re-run everything) on failure.
const nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: flowPaths,
projectRoot: stepCtx.workingDirectory,
logger,
});
// Retry loop. spawn-async error shapes:
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
// numeric err.status → maestro exited non-zero → retry.
@@ -203,6 +212,7 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
let flowsToRun: string[] = flowPaths;
let lastAttemptExitCode: number | null = null;
const totalAttempts = retries + 1;
for (let attempt = 0; attempt <= retries; attempt++) {
const outputPath =
outputFormat === 'junit'
@@ -211,19 +221,25 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
const maestroArgs = buildMaestroArgs({
flow_path: flowsToRun,
outputPath,
output_format: outputFormat,
shards,
include_tags: includeTags,
exclude_tags: excludeTags,
});
logger.info(
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
);
try {
await spawn(
'maestro',
buildMaestroArgs({
flow_path: flowsToRun,
outputPath,
output_format: outputFormat,
shards,
include_tags: includeTags,
exclude_tags: excludeTags,
}),
{ cwd: stepCtx.workingDirectory, env: spawnEnv, logger, signal }
);
await spawn('maestro', maestroArgs, {
cwd: stepCtx.workingDirectory,
env: spawnEnv,
logger,
signal,
});
lastAttemptExitCode = 0;
} catch (err: any) {
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
@@ -240,19 +256,25 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
break;
}
if (outputFormat === 'junit' && outputPath) {
if (outputFormat === 'junit' && outputPath && nameToPath) {
const failed = await parseFailedFlowsFromJUnit({
junitFile: outputPath,
testsDirectory,
inputFlowPaths: flowsToRun,
projectRoot: stepCtx.workingDirectory,
nameToPath,
});
if (failed !== null && failed.length > 0) {
flowsToRun = failed;
logger.info(
`Test failed; retrying ${failed.length} failed flow(s): ${failed.join(', ')}`
);
} else {
flowsToRun = flowPaths;
logger.info('Test failed; could not determine failed subset, retrying all flows');
}
} else {
flowsToRun = flowPaths;
logger.info('Test failed, retrying all flows');
}
logger.info('Test failed, retrying...');
await sleepAsync(2000);
}
@@ -291,7 +313,6 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
// or throw (infra). A non-null non-zero status means the user's tests
// failed every attempt.
if (lastAttemptExitCode !== 0) {
const totalAttempts = retries + 1;
throw new UserError(
'ERR_MAESTRO_TESTS_FAILED',
`Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`
@@ -1,9 +1,13 @@
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
const CREATE_MUTATION = graphql(`
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
workflowDeviceTestCaseResult {
@@ -38,6 +42,11 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
defaultValue: '${{ env.HOME }}/.maestro/tests',
}),
BuildStepInput.createProvider({
id: 'flow_path',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
],
fn: async (stepsCtx, { inputs }) => {
const { logger } = stepsCtx;
@@ -51,14 +60,26 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
logger.info('No JUnit directory provided, skipping test results report');
return;
}
const testsDirectory = inputs.tests_directory.value as string;
const flowPathRaw = inputs.flow_path.value;
let nameToPath: Map<string, string> | null = null;
if (flowPathRaw !== undefined) {
const parsed = FlowPathSchema.safeParse(flowPathRaw);
if (parsed.success) {
nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: parsed.data,
projectRoot: stepsCtx.workingDirectory,
logger,
});
} else {
logger.warn(
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
);
}
}
try {
const flowResults = await parseMaestroResults(
junitDirectory,
testsDirectory,
stepsCtx.workingDirectory
);
const flowResults = await parseMaestroResults(junitDirectory, nameToPath);
if (flowResults.length === 0) {
logger.info('No maestro test results found, skipping report');
return;
@@ -20,7 +20,7 @@ import {
getCcachePath,
} from '../../utils/cacheKey';
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
import { TurtleFetchError } from '../../utils/turtleFetch';
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
export function createRestoreBuildCacheFunction(): BuildFunction {
return new BuildFunction({
@@ -277,9 +277,27 @@ export async function restoreGradleCacheAsync({
logger,
});
const hitType = matchedKey === cacheKey ? 'direct_hit' : 'prefix_match';
logger.info(
`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
`Gradle cache restored to ${gradleCachesPath} (${hitType === 'direct_hit' ? 'direct hit' : 'prefix match'})`
);
try {
await turtleFetch(new URL('v2/turtle-builds/logs', expoApiServerURL).toString(), 'POST', {
json: {
buildId: jobId,
message: `Gradle cache restored (${hitType})`,
tags: {
event: 'gradle_cache_restored',
cache_hit_type: hitType,
},
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
});
} catch {}
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Gradle cache found for this key');
@@ -29,6 +29,7 @@ import {
import { AndroidBuildContext, BuildContext, CommonContext } from '../context';
import { logCredentialsSource } from '../utils/credentials';
import {
checkComposeKotlinCompatibilityAsync,
checkGoogleServicesFileAsync,
checkNodeEnvVariable,
validatePNGsForManagedProjectAsync,
@@ -58,6 +59,7 @@ This means that it will most likely produce an AAB and you will not be able to i
checkNodeEnvVariable(ctx);
await checkGoogleServicesFileAsync(ctx);
await validatePNGsForManagedProjectAsync(ctx);
await checkComposeKotlinCompatibilityAsync(ctx);
const gradleContext = await resolveGradleBuildContextAsync(
ctx.projectDir,
+97
View File
@@ -2,6 +2,7 @@ import { Platform, Workflow } from '@expo/eas-build-job';
import { Errors } from '@oclif/core';
import fs from 'fs-extra';
import path from 'path';
import resolveFrom from 'resolve-from';
import semver from 'semver';
import { CommonContext } from './context';
@@ -190,3 +191,99 @@ async function validatePNGAsync({ configPath, pngPath }: ConfigPng): Promise<voi
throw new Error(`"${configPath}" is not valid PNG`);
}
}
/**
* Compose Compiler versions bundled by expo-modules-core and their required Kotlin versions.
*/
const COMPOSE_KOTLIN_COMPAT: Record<string, string> = {
'1.5.15': '1.9.25',
'1.5.14': '1.9.24',
};
/**
* Maps expo-modules-core version ranges to the Compose Compiler version they bundle.
*/
function getComposeCompilerVersion(expoModulesCoreVersion: string): string | null {
if (semver.satisfies(expoModulesCoreVersion, '~1.12.0')) {
return '1.5.15';
}
return null;
}
/**
* Attempts to read the project Kotlin version from gradle.properties or build.gradle.
*/
async function resolveProjectKotlinVersionAsync(projectDir: string): Promise<string | null> {
// Try android/gradle.properties first
const gradlePropertiesPath = path.join(projectDir, 'android', 'gradle.properties');
if (await fs.pathExists(gradlePropertiesPath)) {
const content = await fs.readFile(gradlePropertiesPath, 'utf8');
const match = content.match(/kotlinVersion\s*=\s*(\S+)/);
if (match) {
return match[1];
}
}
// Fall back to android/build.gradle
const buildGradlePath = path.join(projectDir, 'android', 'build.gradle');
if (await fs.pathExists(buildGradlePath)) {
const content = await fs.readFile(buildGradlePath, 'utf8');
const pluginMatch = content.match(
/org\.jetbrains\.kotlin:kotlin-gradle-plugin:(\d+\.\d+\.\d+)/
);
if (pluginMatch) {
return pluginMatch[1];
}
const extMatch = content.match(/ext\.kotlinVersion\s*=\s*['"](\d+\.\d+\.\d+)['"]/);
if (extMatch) {
return extMatch[1];
}
}
return null;
}
export async function checkComposeKotlinCompatibilityAsync(
ctx: CommonContext<Platform.ANDROID>
): Promise<void> {
if (ctx.workflow !== Workflow.GENERIC) {
return;
}
const maybePackageJson = resolveFrom.silent(ctx.projectDir, 'expo-modules-core/package.json');
if (!maybePackageJson) {
return;
}
const { version: expoModulesCoreVersion } = await fs.readJson(maybePackageJson);
if (!expoModulesCoreVersion) {
return;
}
const composeVersion = getComposeCompilerVersion(expoModulesCoreVersion);
if (!composeVersion) {
return;
}
const requiredKotlin = COMPOSE_KOTLIN_COMPAT[composeVersion];
if (!requiredKotlin) {
return;
}
const projectKotlin = await resolveProjectKotlinVersionAsync(ctx.projectDir);
if (!projectKotlin) {
return;
}
if (projectKotlin !== requiredKotlin) {
Log.warn(
`Kotlin version mismatch: your project uses Kotlin ${projectKotlin}, but expo-modules-core requires Kotlin ${requiredKotlin} (for Compose Compiler ${composeVersion}).`
);
Log.warn('This will cause "compileReleaseKotlin" build failures.');
Log.warn(
`Update the Kotlin version in android/gradle.properties to ${requiredKotlin},`
);
Log.warn('or run "npx expo prebuild --clean" to regenerate the native project.');
Log.newLine();
}
}
@@ -157,6 +157,11 @@ describe(IntegrationsConvexConnect, () => {
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('Check your email for an invitation')
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('npx convex dev'));
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('https://docs.expo.dev/guides/using-convex')
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('https://docs.convex.dev/'));
expect(spawnAsync).toHaveBeenCalledWith('npx', ['expo', 'install', 'convex'], {
cwd: testProjectDir,
stdio: 'inherit',
@@ -151,6 +151,10 @@ export default class IntegrationsConvexConnect extends EasCommand {
Log.newLine();
Log.log('Next steps:');
Log.log(` 1. Start the Convex dev server: ${chalk.cyan('npx convex dev')}`);
Log.log(
` 2. Learn how to connect to your new Convex database by following our quickstart guide: ${chalk.cyan('https://docs.expo.dev/guides/using-convex')}`
);
Log.log(` 3. Read more about Convex: ${chalk.cyan('https://docs.convex.dev/')}`);
Log.newLine();
if (teamInviteResult === 'sent') {
Log.log(
+3 -3
View File
@@ -183,7 +183,7 @@ describe(BuildService, () => {
});
expect(turtleFetch).toHaveBeenCalledWith(
'http://api.expo.test/v2/turtle-builds/error-logs',
'http://api.expo.test/v2/turtle-builds/logs',
'POST',
expect.objectContaining({
json: expect.objectContaining({
@@ -211,7 +211,7 @@ describe(BuildService, () => {
});
expect(turtleFetch).toHaveBeenCalledWith(
'http://api.expo.test/v2/turtle-builds/error-logs',
'http://api.expo.test/v2/turtle-builds/logs',
'POST',
expect.objectContaining({
json: expect.objectContaining({
@@ -242,7 +242,7 @@ describe(BuildService, () => {
expect(spawn).not.toHaveBeenCalled();
expect(turtleFetch).toHaveBeenCalledWith(
'http://api.expo.test/v2/turtle-builds/error-logs',
'http://api.expo.test/v2/turtle-builds/logs',
'POST',
expect.objectContaining({
json: expect.objectContaining({
+4 -3
View File
@@ -354,15 +354,16 @@ export default class BuildService {
try {
await turtleFetch(
new URL('turtle-builds/error-logs', config.wwwApiV2BaseUrl).toString(),
new URL('turtle-builds/logs', config.wwwApiV2BaseUrl).toString(),
'POST',
{
json: {
buildId: this.buildId,
message: rawErrorMessage,
buildPhase: err.buildPhase ?? null,
errorCode: err.errorCode,
level: 'error',
tags: {
build_phase: err.buildPhase ?? null,
error_code: err.errorCode,
platform: job.platform,
workflow: job.type,
sdk_version: metadata?.sdkVersion ?? null,