mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e2dddb59a | ||
|
|
b7df3ff4f8 | ||
|
|
8a74758d56 | ||
|
|
26fdf8fb75 | ||
|
|
9328063547 | ||
|
|
fc66b8251e | ||
|
|
394576368a | ||
|
|
f80e34cffe | ||
|
|
59ad2c2ca0 | ||
|
|
4e95a29797 | ||
|
|
66d8e3d6b8 | ||
|
|
5e4a88094f | ||
|
|
7a4c81f4e3 | ||
|
|
c9cdfd1eb5 | ||
|
|
8115dc8766 | ||
|
|
113467c91d | ||
|
|
be967f4b9a | ||
|
|
660303b0c3 | ||
|
|
5514289648 | ||
|
|
7924e33e96 | ||
|
|
e699a06c07 | ||
|
|
ce819c1a14 | ||
|
|
92ad6b5ba3 | ||
|
|
d63d51f548 | ||
|
|
bfb12f0317 | ||
|
|
b162162007 | ||
|
|
6ed7e14a69 | ||
|
|
37910b17e1 | ||
|
|
0d3c56f4d6 | ||
|
|
bc8e60d385 | ||
|
|
99b8d67d3f | ||
|
|
4e202db843 | ||
|
|
ab209463ab | ||
|
|
99e9d367a1 | ||
|
|
a6f68462c9 | ||
|
|
1c58823991 | ||
|
|
6a804ab8d7 | ||
|
|
74eff9bf67 | ||
|
|
d20c465ddf | ||
|
|
0c17846e67 | ||
|
|
d5a3fc06f9 | ||
|
|
c51ac27e25 | ||
|
|
88506d01a6 | ||
|
|
77e36e0743 | ||
|
|
ce8f438610 | ||
|
|
f3fd05d2dc | ||
|
|
1ca478cb5e | ||
|
|
efbb61b088 | ||
|
|
3984c9a938 | ||
|
|
a3c9e6727e | ||
|
|
e06a5babcf | ||
|
|
5f400d7766 | ||
|
|
4338aea702 | ||
|
|
ecf42e68f3 | ||
|
|
f23579fa32 | ||
|
|
afdc626fcb | ||
|
|
f9f4583a39 | ||
|
|
2d955440ca | ||
|
|
642bb6fa4d | ||
|
|
fb1f2d2add | ||
|
|
dbcca53f1d | ||
|
|
4a0dc9f426 | ||
|
|
146656c7f7 | ||
|
|
e37411040f | ||
|
|
b71226d90f | ||
|
|
9a9fe11f73 | ||
|
|
1b62b6feae | ||
|
|
ebb215db1a | ||
|
|
cdf49377cd | ||
|
|
8fb84e86bb | ||
|
|
d660e3602d | ||
|
|
5b9d47c02b | ||
|
|
61b601de88 | ||
|
|
c5867c8c07 | ||
|
|
f2d5c210a7 | ||
|
|
6e70f7ee4b | ||
|
|
1d36f4f636 | ||
|
|
f8deba78a5 | ||
|
|
df8ebc8f84 | ||
|
|
62ab329f39 | ||
|
|
198b796804 | ||
|
|
110055d943 | ||
|
|
06e2ba5bdb | ||
|
|
15690e7f6b | ||
|
|
134e74f50b | ||
|
|
4b3dcda99d |
No files matched your search
@@ -0,0 +1,11 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "eas-cli"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
mise trust
|
||||
mise install
|
||||
yarn
|
||||
yarn typecheck
|
||||
'''
|
||||
@@ -12,6 +12,8 @@ jobs:
|
||||
profile: system-test
|
||||
custom_job:
|
||||
name: Custom Job
|
||||
runs_on: linux-medium-nested-virtualization
|
||||
steps:
|
||||
- name: Echo
|
||||
run: echo "This is a custom job step"
|
||||
- uses: eas/start_android_emulator
|
||||
@@ -1,6 +1,7 @@
|
||||
name: CHANGELOG.md PR check
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
@@ -29,7 +30,7 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
core.setFailed('Please add a changelog entry!')
|
||||
- uses: mshick/add-pr-comment@v2.1.0
|
||||
- uses: mshick/add-pr-comment@v2.8.2
|
||||
if: always()
|
||||
with:
|
||||
message-id: changelog-entry-check
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"packages/eas-cli/README.md",
|
||||
"packages/eas-cli/schema/**",
|
||||
"packages/eas-cli/graphql.schema.json",
|
||||
"packages/eas-cli/src/graphql/generated.ts",
|
||||
"packages/build-tools/schema.graphql"
|
||||
]
|
||||
}
|
||||
@@ -3,3 +3,4 @@
|
||||
When working with this repository follow instructions from CLAUDE.md.
|
||||
|
||||
- [./CLAUDE.md](./CLAUDE.md)
|
||||
- Before committing, run the formatter on modified files.
|
||||
+61
-3
@@ -6,14 +6,73 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Require --environment flag for update commands when SDK >= 55. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add screenshots and previews support to `metadata:push` and `metadata:pull`. ([#3301](https://github.com/expo/eas-cli/pull/3301) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add `--non-interactive` flag to `metadata:push` and `metadata:pull` commands with ASC API Key auth support. ([#3548](https://github.com/expo/eas-cli/pull/3548) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix workflow:logs for builds using built-in EAS build steps. ([#3523](https://github.com/expo/eas-cli/pull/3523) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools][worker] Read Expo app config with `expo config` CLI invocation before falling back to `@expo/config` ([#3536](https://github.com/expo/eas-cli/pull/3536) by [@kitten](https://github.com/kitten))
|
||||
- Fix `hasIgnoredIosProjectAsync()` always returning `false` for ignored iOS projects. ([#3562](https://github.com/expo/eas-cli/pull/3562) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Bump apple-utils. ([#3545](https://github.com/expo/eas-cli/pull/3545) by [@quinlanj](https://github.com/quinlanj))
|
||||
- [eas-cli][build-tools][worker] Bump `@expo/config` and `@expo/config-plugins`. ([#3527](https://github.com/expo/eas-cli/pull/3527) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
## [18.4.0](https://github.com/expo/eas-cli/releases/tag/v18.4.0) - 2026-03-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Auto-detect non-interactive mode in CI and non-TTY environments. ([#3486](https://github.com/expo/eas-cli/pull/3486) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- Add `skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
## [18.3.0](https://github.com/expo/eas-cli/releases/tag/v18.3.0) - 2026-03-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Show user email in `eas account:view` output. ([#3485](https://github.com/expo/eas-cli/pull/3485) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [18.2.0](https://github.com/expo/eas-cli/releases/tag/v18.2.0) - 2026-03-09
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Auto-enable `--non-interactive` when `--json` is passed, so users no longer need to specify both flags. ([#3476](https://github.com/expo/eas-cli/pull/3476) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- Auto-generate Android keystore in non-interactive mode instead of throwing an error. ([#3477](https://github.com/expo/eas-cli/pull/3477) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- Add `--source-maps` flag to prevent uploading server sourcemaps ([#3478](https://github.com/expo/eas-cli/pull/3478) by [@hassankhan](https://github.com/hassankhan))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Check all certificates in provisioning profile during verification instead of only the first one. ([#3484](https://github.com/expo/eas-cli/pull/3484) by [@qwertey6](https://github.com/qwertey6))
|
||||
- Provide an override for the new --environment flag requirement in the update command. ([#3442](https://github.com/expo/eas-cli/pull/3442) by [@douglowder](https://github.com/douglowder))
|
||||
- Add missing `--include=dev` for `npm install` commands ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
|
||||
- Add missing `--production false` for `yarn install` commands for Yarn Classic ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- Upgrade `oclif` to v4. ([#3472](https://github.com/expo/eas-cli/pull/3472) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [18.1.0](https://github.com/expo/eas-cli/releases/tag/v18.1.0) - 2026-03-04
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Support YAML anchors and aliases when parsing workflow files. ([#3435](https://github.com/expo/eas-cli/pull/3435) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Remove --environment flag requirement for update:configure. ([#3440](https://github.com/expo/eas-cli/pull/3440) by [@douglowder](https://github.com/douglowder))
|
||||
- Fix login spinner interfering with prompts in `eas go` command when not logged in. ([#3451](https://github.com/expo/eas-cli/pull/3451) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
|
||||
## [18.0.6](https://github.com/expo/eas-cli/releases/tag/v18.0.6) - 2026-02-27
|
||||
|
||||
## [18.0.5](https://github.com/expo/eas-cli/releases/tag/v18.0.5) - 2026-02-25
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Require --environment flag for update commands when SDK >= 55. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [18.0.4](https://github.com/expo/eas-cli/releases/tag/v18.0.4) - 2026-02-23
|
||||
|
||||
### 🐛 Bug fixes
|
||||
@@ -1631,7 +1690,6 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- Add rollback disambiguation command. ([#2004](https://github.com/expo/eas-cli/pull/2004) by [@wschurman](https://github.com/wschurman))
|
||||
- Detect devices that fail to be provisioned, list them to the user and show the explanation message with the link to the devices page to check actual status. ([#2011](https://github.com/expo/eas-cli/pull/2011) by [@radoslawkrzemien](https://github.com/radoslawkrzemien))
|
||||
- Add info to EAS Update asset upload process about asset counts and limits. ([#2013](https://github.com/expo/eas-cli/pull/2013) by [@wschurman](https://github.com/wschurman))
|
||||
- .nvmrc support for setting node version. ([#1954](https://github.com/expo/eas-cli/pull/1954) by [@khamilowicz](https://github.com/khamilowicz))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
|
||||
@@ -296,9 +296,11 @@ export EXPO_DEBUG=1 # Enable debug logging
|
||||
|
||||
### Validation
|
||||
|
||||
All changes should be validated with TypeScript and the linter before committing:
|
||||
All changes should be validated before committing. At minimum run package-level tests (e.g. `jest-unit`)
|
||||
for touched code, then run type checks/linting and format the repo:
|
||||
|
||||
```bash
|
||||
yarn fmt # Apply Oxfmt formatting
|
||||
yarn typecheck # Validate TypeScript types
|
||||
yarn lint # Run Oxlint
|
||||
yarn fmt:check # Run Oxfmt format check
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "18.0.4",
|
||||
"version": "18.5.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+3
-2
@@ -17,7 +17,7 @@
|
||||
"lint-changelog": "./scripts/bin/run lint-changelog",
|
||||
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
|
||||
"test": "lerna run test",
|
||||
"clean": "lerna run clean && rm -rf node_modules coverage"
|
||||
"clean": "lerna run clean && rimraf node_modules coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
@@ -26,10 +26,11 @@
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
"lerna": "9.0.3",
|
||||
"oclif": "^3.9.0",
|
||||
"oclif": "^4.22.85",
|
||||
"oxfmt": "0.28.0",
|
||||
"oxlint": "1.43.0",
|
||||
"oxlint-tsgolint": "0.11.1",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.0.2",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -24,35 +24,36 @@
|
||||
"prebuild": "yarn gql",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && yarn gql && tsc -p tsconfig.build.json",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
|
||||
"jest-integration-watch": "jest --config jest/integration-config.ts --watch",
|
||||
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo",
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
|
||||
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
|
||||
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
|
||||
"test": "yarn jest-unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/downloader": "18.0.1",
|
||||
"@expo/eas-build-job": "18.0.2",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.5.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.0.2",
|
||||
"@expo/template-file": "18.0.1",
|
||||
"@expo/turtle-spawn": "18.0.1",
|
||||
"@expo/steps": "18.5.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@google-cloud/storage": "^7.11.2",
|
||||
"@urql/core": "^6.0.1",
|
||||
"bplist-parser": "0.3.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fast-xml-parser": "^4.4.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"gql.tada": "^1.8.13",
|
||||
"joi": "^17.13.1",
|
||||
@@ -74,7 +75,7 @@
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/repack-app": "~0.2.5",
|
||||
"@expo/repack-app": "~0.4.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
@@ -90,6 +91,7 @@
|
||||
"jest": "^29.7.0",
|
||||
"memfs": "^4.17.1",
|
||||
"nock": "13.4.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"tslib": "^2.6.3",
|
||||
|
||||
@@ -19,3 +19,7 @@ export enum LoggerLevel {
|
||||
ERROR = 'error',
|
||||
FATAL = 'fatal',
|
||||
}
|
||||
|
||||
export enum PipeMode {
|
||||
COMBINED = 'combined',
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { BuildTrigger, Ios, Metadata } from '@expo/eas-build-job';
|
||||
import { StepMetric } from '@expo/steps';
|
||||
|
||||
import { createTestIosJob } from './utils/job';
|
||||
import { createMockLogger } from './utils/logger';
|
||||
import { BuildContext } from '../context';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { uploadStepMetricsToWwwAsync } from '../utils/stepMetrics';
|
||||
|
||||
jest.mock('../utils/stepMetrics');
|
||||
const mockUploadStepMetricsToWwwAsync = jest.mocked(uploadStepMetricsToWwwAsync);
|
||||
|
||||
describe(CustomBuildContext, () => {
|
||||
it('should not lose workflowInterpolationContext', () => {
|
||||
@@ -34,4 +39,122 @@ describe(CustomBuildContext, () => {
|
||||
foo: 'bar',
|
||||
});
|
||||
});
|
||||
|
||||
describe('reportStepMetric', () => {
|
||||
const expoApiV2BaseUrl = 'http://exp.test/--/api/v2/';
|
||||
const workflowJobId = 'test-workflow-job-id';
|
||||
const robotAccessToken = 'test-robot-token';
|
||||
|
||||
const sampleMetric: StepMetric = {
|
||||
metricsId: 'eas/checkout',
|
||||
result: 'success',
|
||||
durationMs: 1000,
|
||||
platform: 'linux',
|
||||
};
|
||||
|
||||
function createCustomBuildContext(
|
||||
overrides: {
|
||||
expoApiV2BaseUrl?: string | undefined;
|
||||
includeRobotAccessToken?: boolean;
|
||||
includeWorkflowJobId?: boolean;
|
||||
} = {}
|
||||
): CustomBuildContext {
|
||||
const job = createTestIosJob();
|
||||
if (overrides.includeRobotAccessToken !== false) {
|
||||
(job.secrets as any).robotAccessToken = robotAccessToken;
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
};
|
||||
if (overrides.includeWorkflowJobId !== false) {
|
||||
env.__WORKFLOW_JOB_ID = workflowJobId;
|
||||
}
|
||||
|
||||
const ctx = new BuildContext(job, {
|
||||
env,
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
uploadArtifact: jest.fn(),
|
||||
workingdir: '/workingdir',
|
||||
expoApiV2BaseUrl:
|
||||
'expoApiV2BaseUrl' in overrides ? overrides.expoApiV2BaseUrl : expoApiV2BaseUrl,
|
||||
});
|
||||
|
||||
return new CustomBuildContext(ctx);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUploadStepMetricsToWwwAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('calls uploadStepMetricsToWwwAsync when all required fields are present', () => {
|
||||
const customCtx = createCustomBuildContext();
|
||||
customCtx.reportStepMetric(sampleMetric);
|
||||
|
||||
expect(mockUploadStepMetricsToWwwAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadStepMetricsToWwwAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workflowJobId,
|
||||
robotAccessToken,
|
||||
expoApiV2BaseUrl,
|
||||
stepMetrics: [sampleMetric],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not call upload when workflowJobId is missing', () => {
|
||||
const customCtx = createCustomBuildContext({ includeWorkflowJobId: false });
|
||||
customCtx.reportStepMetric(sampleMetric);
|
||||
|
||||
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call upload when robotAccessToken is missing', () => {
|
||||
const customCtx = createCustomBuildContext({ includeRobotAccessToken: false });
|
||||
customCtx.reportStepMetric(sampleMetric);
|
||||
|
||||
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call upload when expoApiV2BaseUrl is missing', () => {
|
||||
const customCtx = createCustomBuildContext({ expoApiV2BaseUrl: undefined });
|
||||
customCtx.reportStepMetric(sampleMetric);
|
||||
|
||||
expect(mockUploadStepMetricsToWwwAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('drainPendingMetricUploads awaits all pending uploads', async () => {
|
||||
let resolveUpload!: () => void;
|
||||
const uploadPromise = new Promise<void>(resolve => {
|
||||
resolveUpload = resolve;
|
||||
});
|
||||
mockUploadStepMetricsToWwwAsync.mockReturnValue(uploadPromise);
|
||||
|
||||
const customCtx = createCustomBuildContext();
|
||||
customCtx.reportStepMetric(sampleMetric);
|
||||
|
||||
let drained = false;
|
||||
const drainPromise = customCtx.drainPendingMetricUploads().then(() => {
|
||||
drained = true;
|
||||
});
|
||||
|
||||
await new Promise(r => setImmediate(r));
|
||||
expect(drained).toBe(false);
|
||||
|
||||
resolveUpload();
|
||||
await drainPromise;
|
||||
expect(drained).toBe(true);
|
||||
});
|
||||
|
||||
it('drainPendingMetricUploads settles even if an upload rejects', async () => {
|
||||
mockUploadStepMetricsToWwwAsync.mockRejectedValue(new Error('upload failed'));
|
||||
|
||||
const customCtx = createCustomBuildContext();
|
||||
customCtx.reportStepMetric(sampleMetric);
|
||||
|
||||
await expect(customCtx.drainPendingMetricUploads()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { BuildPhase } from '@expo/eas-build-job';
|
||||
import { BuildStepGlobalContext, StepsConfigParser } from '@expo/steps';
|
||||
|
||||
import { runGenericJobAsync } from '../generic';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { uploadJobOutputsToWwwAsync } from '../utils/outputs';
|
||||
|
||||
jest.mock('../customBuildContext');
|
||||
jest.mock('../utils/outputs');
|
||||
jest.mock('../common/projectSources');
|
||||
jest.mock('../steps/easFunctions', () => ({ getEasFunctions: jest.fn().mockReturnValue([]) }));
|
||||
jest.mock('../steps/easFunctionGroups', () => ({
|
||||
getEasFunctionGroups: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
jest.mock('@expo/steps');
|
||||
jest.mock('fs/promises');
|
||||
|
||||
const mockUploadJobOutputsToWwwAsync = jest.mocked(uploadJobOutputsToWwwAsync);
|
||||
|
||||
describe(runGenericJobAsync, () => {
|
||||
const expoApiV2BaseUrl = 'http://exp.test/--/api/v2/';
|
||||
|
||||
let mockDrainPendingMetricUploads: jest.Mock;
|
||||
let mockCtx: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockDrainPendingMetricUploads = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
(CustomBuildContext as unknown as jest.Mock).mockImplementation(() => ({
|
||||
projectSourceDirectory: '/tmp/src',
|
||||
env: { __WORKFLOW_JOB_ID: 'test-workflow-job-id' },
|
||||
drainPendingMetricUploads: mockDrainPendingMetricUploads,
|
||||
}));
|
||||
|
||||
(BuildStepGlobalContext as unknown as jest.Mock).mockImplementation(() => ({
|
||||
env: { __WORKFLOW_JOB_ID: 'test-workflow-job-id' },
|
||||
}));
|
||||
|
||||
mockCtx = {
|
||||
expoApiV2BaseUrl,
|
||||
job: {
|
||||
secrets: { robotAccessToken: 'test-token' },
|
||||
steps: [],
|
||||
},
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
},
|
||||
runBuildPhase: jest.fn(async (_phase: BuildPhase, fn: () => Promise<any>) => fn()),
|
||||
};
|
||||
|
||||
mockUploadJobOutputsToWwwAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('awaits drainPendingMetricUploads in COMPLETE_JOB phase', async () => {
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
|
||||
}));
|
||||
|
||||
let resolveDrain!: () => void;
|
||||
mockDrainPendingMetricUploads.mockReturnValue(
|
||||
new Promise<void>(resolve => {
|
||||
resolveDrain = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
let resolved = false;
|
||||
const resultPromise = runGenericJobAsync(mockCtx).then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
await new Promise(r => setImmediate(r));
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
resolveDrain();
|
||||
await resultPromise;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it('awaits drainPendingMetricUploads even when outputs upload fails', async () => {
|
||||
const mockWorkflow = { executeAsync: jest.fn().mockResolvedValue(undefined) };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
|
||||
}));
|
||||
mockUploadJobOutputsToWwwAsync.mockRejectedValue(new Error('outputs upload failed'));
|
||||
|
||||
let resolveDrain!: () => void;
|
||||
mockDrainPendingMetricUploads.mockReturnValue(
|
||||
new Promise<void>(resolve => {
|
||||
resolveDrain = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
let rejected = false;
|
||||
const resultPromise = runGenericJobAsync(mockCtx).catch(err => {
|
||||
rejected = true;
|
||||
throw err;
|
||||
});
|
||||
|
||||
await new Promise(r => setImmediate(r));
|
||||
expect(rejected).toBe(false);
|
||||
|
||||
resolveDrain();
|
||||
await expect(resultPromise).rejects.toThrow('outputs upload failed');
|
||||
expect(rejected).toBe(true);
|
||||
});
|
||||
|
||||
it('throws before workflow execution when expoApiV2BaseUrl is missing', async () => {
|
||||
mockCtx.expoApiV2BaseUrl = undefined;
|
||||
|
||||
const mockWorkflow = { executeAsync: jest.fn() };
|
||||
(StepsConfigParser as unknown as jest.Mock).mockImplementation(() => ({
|
||||
parseAsync: jest.fn().mockResolvedValue(mockWorkflow),
|
||||
}));
|
||||
|
||||
await expect(runGenericJobAsync(mockCtx)).rejects.toThrow(
|
||||
'expoApiV2BaseUrl is required for generic jobs'
|
||||
);
|
||||
|
||||
expect(CustomBuildContext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,7 @@ export async function runGradleCommand(
|
||||
return line;
|
||||
}
|
||||
},
|
||||
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx) },
|
||||
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx), LC_ALL: 'C.UTF-8' },
|
||||
});
|
||||
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
|
||||
adjustOOMScore(spawnPromise, logger);
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { EasBuildGradle } from '../templates/EasBuildGradle';
|
||||
const APPLY_EAS_BUILD_GRADLE_LINE = 'apply from: "./eas-build.gradle"';
|
||||
|
||||
export async function configureBuildGradle(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
ctx.logger.info('Injecting signing config into build.gradle');
|
||||
if (await fs.pathExists(getEasBuildGradlePath(ctx.getReactNativeProjectDirectory()))) {
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
ctx.logger.warn('eas-build.gradle script is deprecated, please remove it from your project.');
|
||||
}
|
||||
await deleteEasBuildGradle(ctx.getReactNativeProjectDirectory());
|
||||
await createEasBuildGradle(ctx.getReactNativeProjectDirectory());
|
||||
await addApplyToBuildGradle(ctx.getReactNativeProjectDirectory());
|
||||
}
|
||||
|
||||
async function deleteEasBuildGradle(projectRoot: string): Promise<void> {
|
||||
const easBuildGradlePath = getEasBuildGradlePath(projectRoot);
|
||||
await fs.remove(easBuildGradlePath);
|
||||
}
|
||||
|
||||
function getEasBuildGradlePath(projectRoot: string): string {
|
||||
return path.join(projectRoot, 'android/app/eas-build.gradle');
|
||||
}
|
||||
|
||||
async function createEasBuildGradle(projectRoot: string): Promise<void> {
|
||||
const easBuildGradlePath = getEasBuildGradlePath(projectRoot);
|
||||
await fs.writeFile(easBuildGradlePath, EasBuildGradle);
|
||||
}
|
||||
|
||||
async function addApplyToBuildGradle(projectRoot: string): Promise<void> {
|
||||
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectRoot);
|
||||
const buildGradleContents = await fs.readFile(path.join(buildGradlePath), 'utf8');
|
||||
|
||||
if (hasLine(buildGradleContents, APPLY_EAS_BUILD_GRADLE_LINE)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
buildGradlePath,
|
||||
`${buildGradleContents.trim()}\n${APPLY_EAS_BUILD_GRADLE_LINE}\n`
|
||||
);
|
||||
}
|
||||
|
||||
function hasLine(haystack: string, needle: string): boolean {
|
||||
return (
|
||||
haystack
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
// Check for both single and double quotes
|
||||
.some(line => line === needle || line === needle.replace(/"/g, "'"))
|
||||
);
|
||||
}
|
||||
@@ -26,8 +26,14 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
},
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('NPM_CORRUPTED_PACKAGE');
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.message).toBe(
|
||||
'Unknown error. See logs of the Install dependencies build phase for more information.'
|
||||
);
|
||||
expect(err.trackingCode).toBe('NPM_CORRUPTED_PACKAGE');
|
||||
expect(err.metadata).toEqual({
|
||||
packageName: '@typescript-eslint/[email protected]',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects log for invalid bundler and reports it to user', async () => {
|
||||
@@ -45,7 +51,11 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
|
||||
expect(err.message).toBe(
|
||||
'Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.'
|
||||
);
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
expect(err.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not detect errors if they show up in different build phase', async () => {
|
||||
@@ -63,7 +73,10 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.message).toBe(
|
||||
'Unknown error. See logs of the Install dependencies build phase for more information.'
|
||||
);
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects npm cache error if cache is enabled', async () => {
|
||||
@@ -82,8 +95,11 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
},
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('NPM_CACHE_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.message).toBe(
|
||||
'Unknown error. See logs of the Install dependencies build phase for more information.'
|
||||
);
|
||||
expect(err.trackingCode).toBe('NPM_CACHE_ERROR');
|
||||
});
|
||||
|
||||
it('does not detect npm cache error if cache is disabled', async () => {
|
||||
@@ -103,7 +119,10 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.message).toBe(
|
||||
'Unknown error. See logs of the Install dependencies build phase for more information.'
|
||||
);
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects xcode line error', async () => {
|
||||
@@ -126,7 +145,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/path/to/'
|
||||
);
|
||||
expect(err.errorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects minimum deployment target error correctly', async () => {
|
||||
@@ -144,7 +163,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects provisioning profile mismatch error correctly', async () => {
|
||||
@@ -162,7 +181,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects generic "Run Fastlane" error for resign correctly', async () => {
|
||||
@@ -178,7 +197,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects build error in "Run Fastlane" phase correctly', async () => {
|
||||
@@ -194,7 +213,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects provisioning profile mismatch error correctly', async () => {
|
||||
@@ -240,8 +259,8 @@ note`;
|
||||
'/path/to'
|
||||
);
|
||||
expect(err.errorCode).toBe('XCODE_BUILD_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('XCODE_BUILD_ERROR');
|
||||
expect(err.userFacingMessage)
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
expect(err.message)
|
||||
.toBe(`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:
|
||||
- The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified.
|
||||
- Some other error
|
||||
@@ -263,9 +282,9 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
|
||||
'/fake/path'
|
||||
);
|
||||
|
||||
expect(err.errorCode).toBe('MAVEN_CACHE_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
|
||||
expect(err.userFacingMessage).toBe(
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
|
||||
expect(err.trackingCode).toBe('MAVEN_CACHE_ERROR');
|
||||
expect(err.message).toBe(
|
||||
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
|
||||
);
|
||||
});
|
||||
@@ -285,8 +304,8 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
|
||||
);
|
||||
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
|
||||
expect(err.userFacingMessage).toBe(
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
expect(err.message).toBe(
|
||||
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ import { ErrorContext, ErrorHandler } from './errors.types';
|
||||
export class TrackedBuildError extends Error {
|
||||
constructor(
|
||||
public errorCode: string,
|
||||
public message: string
|
||||
public message: string,
|
||||
public metadata?: Record<string, unknown>
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
@@ -154,7 +155,9 @@ export const buildErrorHandlers: ErrorHandler<TrackedBuildError>[] = [
|
||||
// [stderr] WARN tarball tarball data for @typescript-eslint/[email protected] (sha512-cozo/GbwixVR0sgfHItz3t1yXu521yn71Wj6PlYCFA3WPhy51CUPkifFKfBis91bDclGmAY45hhaAXVjdn4new==) seems to be corrupted. Trying again.
|
||||
regexp: /tarball tarball data for ([^ ]*) .* seems to be corrupted. Trying again/,
|
||||
createError: (match: RegExpMatchArray) =>
|
||||
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`),
|
||||
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`, {
|
||||
packageName: match[1],
|
||||
}),
|
||||
})),
|
||||
...[BuildPhase.INSTALL_DEPENDENCIES, BuildPhase.PREBUILD].map(phase => ({
|
||||
phase,
|
||||
|
||||
@@ -67,40 +67,26 @@ export async function resolveBuildPhaseErrorAsync(
|
||||
logLines: string[],
|
||||
errorContext: ErrorContext,
|
||||
buildLogsDirectory: string
|
||||
): Promise<errors.BuildError> {
|
||||
): Promise<errors.ExpoError> {
|
||||
const { phase } = errorContext;
|
||||
if (error instanceof errors.BuildError) {
|
||||
if (error instanceof errors.ExpoError) {
|
||||
error.buildPhase ??= phase;
|
||||
return error;
|
||||
}
|
||||
const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory);
|
||||
const userFacingError =
|
||||
error instanceof errors.UserFacingError
|
||||
? error
|
||||
: (resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
|
||||
new errors.UnknownError(errorContext.phase));
|
||||
const userError =
|
||||
resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
|
||||
new errors.UnknownError(errorContext.phase);
|
||||
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
|
||||
|
||||
const isUnknownUserError =
|
||||
!userFacingError ||
|
||||
(
|
||||
[
|
||||
errors.ErrorCode.UNKNOWN_ERROR,
|
||||
errors.ErrorCode.UNKNOWN_GRADLE_ERROR,
|
||||
errors.ErrorCode.UNKNOWN_FASTLANE_ERROR,
|
||||
] as string[]
|
||||
).includes(userFacingError.errorCode);
|
||||
const message =
|
||||
(isUnknownUserError ? buildError?.message : userFacingError.message) ?? userFacingError.message;
|
||||
const errorCode =
|
||||
(isUnknownUserError ? buildError?.errorCode : userFacingError.errorCode) ??
|
||||
userFacingError.errorCode;
|
||||
const trackingCode =
|
||||
buildError && buildError.errorCode !== userError.errorCode ? buildError.errorCode : undefined;
|
||||
|
||||
return new errors.BuildError(message, {
|
||||
errorCode,
|
||||
userFacingErrorCode: userFacingError.errorCode,
|
||||
userFacingMessage: userFacingError.message,
|
||||
docsUrl: userFacingError.docsUrl,
|
||||
innerError: error,
|
||||
return new errors.UserError(userError.errorCode, userError.message, {
|
||||
trackingCode,
|
||||
docsUrl: userError.docsUrl,
|
||||
buildPhase: phase,
|
||||
metadata: buildError?.metadata,
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-bui
|
||||
|
||||
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
|
||||
|
||||
import UserFacingError = errors.UserFacingError;
|
||||
import UserError = errors.UserError;
|
||||
|
||||
export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
export const userErrorHandlers: ErrorHandler<UserError>[] = [
|
||||
{
|
||||
platform: Platform.IOS,
|
||||
phase: BuildPhase.INSTALL_PODS,
|
||||
@@ -12,7 +12,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
// example log:
|
||||
// [!] `React` requires CocoaPods version `>= 1.10.1`, which is not satisfied by your current version, `1.10.0`.
|
||||
createError: () =>
|
||||
new UserFacingError(
|
||||
new UserError(
|
||||
'EAS_BUILD_UNSUPPORTED_COCOAPODS_VERSION_ERROR',
|
||||
`Your project requires a newer version of CocoaPods. You can update it in the build profile in eas.json by either:
|
||||
- changing the current version under key "cocoapods"
|
||||
@@ -27,7 +27,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
// example log:
|
||||
// /System/Library/Frameworks/Ruby.framework/Versions/2.6/usr/lib/ruby/2.6.0/rubygems/dependency.rb:313:in `to_specs': Could not find 'bundler' (2.2.3) required by your /Users/expo/project/build/ios/Gemfile.lock. (Gem::MissingSpecVersionError)
|
||||
createError: () =>
|
||||
new UserFacingError(
|
||||
new UserError(
|
||||
'EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR',
|
||||
`Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.`,
|
||||
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
|
||||
@@ -40,7 +40,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
// > Failed to read key keyalias from store "/build/workingdir/build/generic/keystore-5787e6af-3002-4cb7-8a57-3e73d13313c2.jks": Invalid keystore format
|
||||
regexp: /Invalid keystore format/,
|
||||
createError: () =>
|
||||
new UserFacingError(
|
||||
new UserError(
|
||||
'EAS_BUILD_INVALID_KEYSTORE_FORMAT_ERROR',
|
||||
'The keystore used in this build is malformed or it has an unsupported type. Make sure you provided the correct file.'
|
||||
),
|
||||
@@ -52,7 +52,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
// > Failed to read key keyalias from store "/build/workingdir/build/generic/keystore-286069a8-4bb9-48a6-add9-acf6b58ea06d.jks": null
|
||||
regexp: /Failed to read key[^\n]+from store/,
|
||||
createError: () =>
|
||||
new UserFacingError(
|
||||
new UserError(
|
||||
'EAS_BUILD_INVALID_KEYSTORE_ALIAS_ERROR',
|
||||
'The alias specified for this keystore does not exist. Make sure you specified the correct value.'
|
||||
),
|
||||
@@ -67,7 +67,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
// [11:17:29] Error: [android.dangerous]: withAndroidDangerousBaseMod: Cannot copy google-services.json from /home/expo/workingdir/build/test/test-google-services.json to /home/expo/workingdir/build/android/app/google-services.json. Please make sure the source and destination paths exist.
|
||||
regexp: /Cannot copy google-services\.json/,
|
||||
createError: () =>
|
||||
new UserFacingError(
|
||||
new UserError(
|
||||
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
|
||||
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
|
||||
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
|
||||
@@ -82,7 +82,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
regexp:
|
||||
/File google-services\.json is missing\. The Google Services Plugin cannot function without it/,
|
||||
createError: () =>
|
||||
new UserFacingError(
|
||||
new UserError(
|
||||
'EAS_BUILD_MISSING_GOOGLE_SERVICES_JSON_ERROR',
|
||||
'"google-services.json" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
|
||||
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
|
||||
@@ -95,7 +95,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
// [08:44:18] ENOENT: no such file or directory, copyfile '/Users/expo/workingdir/build/managed/abc' -> '/Users/expo/workingdir/build/managed/ios/testapp/GoogleService-Info.plist'
|
||||
regexp: /ENOENT: no such file or directory, copyfile .*GoogleService-Info.plist/,
|
||||
createError: () =>
|
||||
new UserFacingError(
|
||||
new UserError(
|
||||
'EAS_BUILD_MISSING_GOOGLE_SERVICES_PLIST_ERROR',
|
||||
'"GoogleService-Info.plist" is missing, make sure that the file exists. Remember that EAS Build only uploads the files tracked by git. Use EAS environment variables to provide EAS Build with the file.',
|
||||
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
|
||||
@@ -113,7 +113,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
regexp:
|
||||
/Specs satisfying the `(.*)` dependency were found, but they required a higher minimum deployment target/,
|
||||
createError: (_, { job }) => {
|
||||
return new UserFacingError(
|
||||
return new UserError(
|
||||
'EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR',
|
||||
`Some pods require a higher minimum deployment target.
|
||||
${
|
||||
@@ -141,7 +141,7 @@ ${
|
||||
// You should run `pod update Firebase/Core` to apply changes you've made.
|
||||
regexp: /CocoaPods could not find compatible versions for pod /,
|
||||
createError: () => {
|
||||
return new UserFacingError(
|
||||
return new UserError(
|
||||
'EAS_BUILD_INCOMPATIBLE_PODS_ERROR',
|
||||
`Compatible versions of some pods could not be resolved.
|
||||
You are seeing this error because either:
|
||||
@@ -161,7 +161,7 @@ You are seeing this error because either:
|
||||
/Fix the upstream dependency conflict, or retry.*\s.*this command with --force, or --legacy-peer-deps/,
|
||||
createError: (matchResult: RegExpMatchArray) => {
|
||||
if (matchResult.length >= 2) {
|
||||
return new UserFacingError(
|
||||
return new UserError(
|
||||
'EAS_BUILD_NPM_CONFLICTING_PEER_DEPENDENCIES',
|
||||
`Some of your peer dependencies are not compatible. The recommended approach is to fix your dependencies by resolving any conflicts listed by "npm install". As a temporary workaround you can:
|
||||
- Add ".npmrc" file with "legacy-peer-deps=true" and commit that to your repo.
|
||||
@@ -179,7 +179,7 @@ You are seeing this error because either:
|
||||
regexp: /Integrity check failed for "(.*)" \(computed integrity doesn't match our records, got/,
|
||||
createError: (matchResult: RegExpMatchArray) => {
|
||||
if (matchResult.length >= 2) {
|
||||
return new UserFacingError(
|
||||
return new UserError(
|
||||
'EAS_BUILD_YARN_LOCK_CHECKSUM_ERROR',
|
||||
`Checksum for package "${matchResult[1]}" does not match value in registry. To fix that:
|
||||
- run "yarn cache clean"
|
||||
@@ -203,7 +203,7 @@ You are seeing this error because either:
|
||||
/\[1\/4\] Resolving packages...\s*\[2\/4\] Fetching packages...\s*\[1\/4\] Resolving packages...\s*\[2\/4\] Fetching packages.../,
|
||||
createError: (matchResult: RegExpMatchArray) => {
|
||||
if (matchResult) {
|
||||
return new UserFacingError(
|
||||
return new UserError(
|
||||
'EAS_BUILD_YARN_MULTIPLE_INSTANCES_ERROR',
|
||||
`One of project dependencies is starting new install process while the main one is still in progress, which might result in corrupted packages. Most likely the reason for error is "prepare" script in git-referenced dependency of your project. Learn more: https://github.com/yarnpkg/yarn/issues/7212#issuecomment-493720324`
|
||||
);
|
||||
@@ -230,7 +230,7 @@ You are seeing this error because either:
|
||||
regexp: /error: Signing for "[a-zA-Z-0-9_]+" requires a development team/,
|
||||
createError: (_, { job }) =>
|
||||
'type' in job && job.type === Workflow.MANAGED
|
||||
? new UserFacingError(
|
||||
? new UserError(
|
||||
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
|
||||
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
|
||||
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or upgrade to SDK 46 or higher.`,
|
||||
@@ -239,7 +239,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
|
||||
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
|
||||
}
|
||||
)
|
||||
: new UserFacingError(
|
||||
: new UserError(
|
||||
'XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR',
|
||||
`Starting from Xcode 14, resource bundles are signed by default, which requires setting the development team for each resource bundle target.
|
||||
To resolve this issue, downgrade to an older Xcode version using the "image" field in eas.json, or turn off signing resource bundles in your Podfile: https://expo.fyi/r/disable-bundle-resource-signing`,
|
||||
@@ -254,7 +254,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
|
||||
phase: BuildPhase.RUN_GRADLEW,
|
||||
regexp: /.*/,
|
||||
createError: () =>
|
||||
new UserFacingError(
|
||||
new UserError(
|
||||
errors.ErrorCode.UNKNOWN_GRADLE_ERROR,
|
||||
'Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.'
|
||||
),
|
||||
@@ -265,7 +265,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
|
||||
mode: BuildMode.RESIGN,
|
||||
regexp: /No provisioning profile for application: '(.+)' with bundle identifier '(.+)'/,
|
||||
createError: () =>
|
||||
new UserFacingError(
|
||||
new UserError(
|
||||
'EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR',
|
||||
`The bundle identifier in provisioning profile used to resign the app does not match the bundle identifier of the app selected to be resigned. See logs above for more information.`
|
||||
),
|
||||
@@ -276,7 +276,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
|
||||
mode: BuildMode.RESIGN,
|
||||
regexp: /.*/,
|
||||
createError: () =>
|
||||
new UserFacingError(
|
||||
new UserError(
|
||||
errors.ErrorCode.UNKNOWN_FASTLANE_RESIGN_ERROR,
|
||||
`The "Run fastlane" step failed with an unknown error.`
|
||||
),
|
||||
@@ -297,7 +297,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
|
||||
// file modified: /Users/expo/workingdir/build/packages/video/ios/Vendor/dependency/ProgrammaticAccessLibrary.xcframework/ios-arm64/ProgrammaticAccessLibrary.framework/ProgrammaticAccessLibrary
|
||||
regexp: /error: .+/g,
|
||||
createError: matchResult =>
|
||||
new UserFacingError(
|
||||
new UserError(
|
||||
'XCODE_BUILD_ERROR',
|
||||
`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:\n${matchResult
|
||||
.map(match => `- ${match.replace('error: ', '')}`)
|
||||
@@ -309,7 +309,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
|
||||
phase: BuildPhase.RUN_FASTLANE,
|
||||
regexp: /.*/,
|
||||
createError: () =>
|
||||
new UserFacingError(
|
||||
new UserError(
|
||||
errors.ErrorCode.UNKNOWN_FASTLANE_ERROR,
|
||||
`The "Run fastlane" step failed with an unknown error. Refer to "Xcode Logs" below for additional, more detailed logs.`
|
||||
),
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Android, BuildPhase, BuildPhaseResult } from '@expo/eas-build-job';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createTestAndroidJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { restoreCredentials } from '../../android/credentials';
|
||||
import androidBuilder from '../android';
|
||||
import { runBuilderWithHooksAsync } from '../common';
|
||||
import {
|
||||
injectConfigureVersionGradleConfig,
|
||||
injectCredentialsGradleConfig,
|
||||
} from '../../steps/utils/android/gradleConfig';
|
||||
|
||||
jest.mock('../common', () => ({
|
||||
runBuilderWithHooksAsync: jest.fn(async (ctx, buildFn) => {
|
||||
await buildFn(ctx);
|
||||
return ctx.artifacts;
|
||||
}),
|
||||
}));
|
||||
jest.mock('../custom', () => ({
|
||||
runCustomBuildAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../android/credentials');
|
||||
jest.mock('../../android/gradle', () => ({
|
||||
ensureLFLineEndingsInGradlewScript: jest.fn(),
|
||||
resolveGradleCommand: jest.fn(() => ':app:bundleRelease'),
|
||||
runGradleCommand: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../common/eagerBundle', () => ({
|
||||
eagerBundleAsync: jest.fn(),
|
||||
shouldUseEagerBundle: jest.fn(() => false),
|
||||
}));
|
||||
jest.mock('../../common/prebuild', () => ({
|
||||
prebuildAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../common/setup', () => ({
|
||||
setupAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../steps/functions/restoreBuildCache', () => ({
|
||||
cacheStatsAsync: jest.fn(),
|
||||
restoreCcacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../steps/functions/saveBuildCache', () => ({
|
||||
saveCcacheAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../steps/utils/android/gradleConfig', () => ({
|
||||
...jest.requireActual('../../steps/utils/android/gradleConfig'),
|
||||
injectConfigureVersionGradleConfig: jest.fn(),
|
||||
injectCredentialsGradleConfig: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/artifacts', () => ({
|
||||
uploadApplicationArchive: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdates', () => ({
|
||||
configureExpoUpdatesIfInstalledAsync: jest.fn(),
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
|
||||
}));
|
||||
jest.mock('../../utils/hooks', () => ({
|
||||
Hook: {
|
||||
POST_INSTALL: 'POST_INSTALL',
|
||||
PRE_UPLOAD_ARTIFACTS: 'PRE_UPLOAD_ARTIFACTS',
|
||||
},
|
||||
runHookIfPresent: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/prepareBuildExecutable', () => ({
|
||||
prepareExecutableAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe(androidBuilder, () => {
|
||||
beforeEach(() => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'/workingdir/env/.gitkeep': '',
|
||||
'/workingdir/build/package.json': JSON.stringify({
|
||||
name: 'test-app',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
},
|
||||
'/'
|
||||
);
|
||||
});
|
||||
|
||||
it('injects Android version config without build credentials', async () => {
|
||||
const job: Android.Job = {
|
||||
...createTestAndroidJob(),
|
||||
secrets: {
|
||||
buildCredentials: undefined,
|
||||
},
|
||||
version: {
|
||||
versionCode: '42',
|
||||
versionName: '1.2.3',
|
||||
},
|
||||
};
|
||||
const ctx = new BuildContext(job, {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/workingdir/build',
|
||||
{
|
||||
versionCode: '42',
|
||||
versionName: '1.2.3',
|
||||
}
|
||||
);
|
||||
expect(injectCredentialsGradleConfig).not.toHaveBeenCalled();
|
||||
expect(restoreCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('injects Android credentials config when build credentials are present', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(injectCredentialsGradleConfig).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/workingdir/build'
|
||||
);
|
||||
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
|
||||
expect(injectConfigureVersionGradleConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the configure Android version phase as warning for legacy eas-build.gradle', async () => {
|
||||
const reportBuildPhaseStats = jest.fn();
|
||||
const job: Android.Job = {
|
||||
...createTestAndroidJob(),
|
||||
secrets: {
|
||||
buildCredentials: undefined,
|
||||
},
|
||||
version: {
|
||||
versionCode: '42',
|
||||
versionName: '1.2.3',
|
||||
},
|
||||
};
|
||||
const ctx = new BuildContext(job, {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
reportBuildPhaseStats,
|
||||
});
|
||||
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
|
||||
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the prepare credentials phase as warning for legacy eas-build.gradle', async () => {
|
||||
const reportBuildPhaseStats = jest.fn();
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
reportBuildPhaseStats,
|
||||
});
|
||||
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
|
||||
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('injects credentials and version config when both are configured', async () => {
|
||||
const reportBuildPhaseStats = jest.fn();
|
||||
const job: Android.Job = {
|
||||
...createTestAndroidJob(),
|
||||
version: {
|
||||
versionCode: '42',
|
||||
versionName: '1.2.3',
|
||||
},
|
||||
};
|
||||
const ctx = new BuildContext(job, {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
reportBuildPhaseStats,
|
||||
});
|
||||
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
|
||||
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(injectCredentialsGradleConfig).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/workingdir/build'
|
||||
);
|
||||
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
|
||||
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'/workingdir/build',
|
||||
{
|
||||
versionCode: '42',
|
||||
versionName: '1.2.3',
|
||||
}
|
||||
);
|
||||
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('runs the builder through hooks', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { BuildJob } from '@expo/eas-build-job';
|
||||
import { BuildWorkflow } from '@expo/steps';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { prepareProjectSourcesAsync } from '../../common/projectSources';
|
||||
import { BuildContext } from '../../context';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { findAndUploadXcodeBuildLogsAsync } from '../../ios/xcodeBuildLogs';
|
||||
import { runCustomBuildAsync } from '../custom';
|
||||
|
||||
@@ -74,4 +76,60 @@ describe(runCustomBuildAsync, () => {
|
||||
|
||||
expect(prepareProjectSourcesAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('awaits drainPendingMetricUploads after workflow execution', async () => {
|
||||
let resolveDrain!: () => void;
|
||||
const drainSpy = jest
|
||||
.spyOn(CustomBuildContext.prototype, 'drainPendingMetricUploads')
|
||||
.mockReturnValue(
|
||||
new Promise<void>(resolve => {
|
||||
resolveDrain = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
let resolved = false;
|
||||
const resultPromise = runCustomBuildAsync(ctx).then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
await new Promise(r => setImmediate(r));
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
resolveDrain();
|
||||
await resultPromise;
|
||||
expect(resolved).toBe(true);
|
||||
|
||||
drainSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('awaits drainPendingMetricUploads even when workflow throws', async () => {
|
||||
let resolveDrain!: () => void;
|
||||
const drainSpy = jest
|
||||
.spyOn(CustomBuildContext.prototype, 'drainPendingMetricUploads')
|
||||
.mockReturnValue(
|
||||
new Promise<void>(resolve => {
|
||||
resolveDrain = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const executeSpy = jest
|
||||
.spyOn(BuildWorkflow.prototype, 'executeAsync')
|
||||
.mockRejectedValue(new Error('workflow failed'));
|
||||
|
||||
let rejected = false;
|
||||
const resultPromise = runCustomBuildAsync(ctx).catch(err => {
|
||||
rejected = true;
|
||||
throw err;
|
||||
});
|
||||
|
||||
await new Promise(r => setImmediate(r));
|
||||
expect(rejected).toBe(false);
|
||||
|
||||
resolveDrain();
|
||||
await expect(resultPromise).rejects.toThrow('workflow failed');
|
||||
expect(rejected).toBe(true);
|
||||
|
||||
drainSpy.mockRestore();
|
||||
executeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -10,13 +10,21 @@ import {
|
||||
resolveGradleCommand,
|
||||
runGradleCommand,
|
||||
} from '../android/gradle';
|
||||
import { configureBuildGradle } from '../android/gradleConfig';
|
||||
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
import { setupAsync } from '../common/setup';
|
||||
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreGradleCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
injectConfigureVersionGradleConfig,
|
||||
injectCredentialsGradleConfig,
|
||||
warnIfLegacyEasBuildGradleExists,
|
||||
} from '../steps/utils/android/gradleConfig';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
@@ -77,6 +85,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await restoreGradleCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
|
||||
@@ -89,7 +103,7 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
appConfig: ctx.appConfig,
|
||||
appConfig: await ctx.appConfig,
|
||||
platform: ctx.job.platform,
|
||||
workflow: ctx.job.type,
|
||||
env: ctx.env,
|
||||
@@ -101,10 +115,22 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
nullthrows(ctx.job.secrets, 'Secrets must be defined for non-custom builds').buildCredentials
|
||||
) {
|
||||
await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
|
||||
await warnIfLegacyEasBuildGradleExists(ctx);
|
||||
await restoreCredentials(ctx);
|
||||
await configureBuildGradle(ctx);
|
||||
await injectCredentialsGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.job.version?.versionCode || ctx.job.version?.versionName) {
|
||||
await ctx.runBuildPhase(BuildPhase.CONFIGURE_ANDROID_VERSION, async () => {
|
||||
await warnIfLegacyEasBuildGradleExists(ctx);
|
||||
await injectConfigureVersionGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory(), {
|
||||
versionCode: ctx.job.version?.versionCode,
|
||||
versionName: ctx.job.version?.versionName,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CONFIGURE_EXPO_UPDATES, async () => {
|
||||
await configureExpoUpdatesIfInstalledAsync(ctx, {
|
||||
resolvedRuntimeVersion: resolvedExpoUpdatesRuntimeVersion?.runtimeVersion ?? null,
|
||||
@@ -180,6 +206,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
await saveGradleCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
|
||||
|
||||
@@ -94,6 +94,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
|
||||
// do nothing, it's a non-breaking error.
|
||||
}
|
||||
}
|
||||
await customBuildCtx.drainPendingMetricUploads();
|
||||
}
|
||||
} catch (err: any) {
|
||||
err.artifacts = ctx.artifacts;
|
||||
|
||||
@@ -103,7 +103,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
appConfig: ctx.appConfig,
|
||||
appConfig: await ctx.appConfig,
|
||||
platform: ctx.job.platform,
|
||||
workflow: ctx.job.type,
|
||||
env: ctx.env,
|
||||
|
||||
@@ -24,7 +24,8 @@ export async function installDependenciesAsync({
|
||||
let args: string[];
|
||||
switch (packageManager) {
|
||||
case PackageManager.NPM: {
|
||||
args = useFrozenLockfile ? ['ci', '--include=dev'] : ['install'];
|
||||
args = useFrozenLockfile ? ['ci'] : ['install'];
|
||||
args.push('--include=dev');
|
||||
break;
|
||||
}
|
||||
case PackageManager.PNPM: {
|
||||
@@ -44,7 +45,12 @@ export async function installDependenciesAsync({
|
||||
];
|
||||
}
|
||||
} else {
|
||||
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
|
||||
args = [
|
||||
'install',
|
||||
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
|
||||
'--production',
|
||||
'false',
|
||||
];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { BuildJob, BuildPhase, Ios, Job, Platform } from '@expo/eas-build-job';
|
||||
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
|
||||
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
|
||||
import {
|
||||
BuildJob,
|
||||
BuildPhase,
|
||||
BuildTrigger,
|
||||
Ios,
|
||||
Job,
|
||||
Platform,
|
||||
UserError,
|
||||
} from '@expo/eas-build-job';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import nullthrows from 'nullthrows';
|
||||
@@ -107,7 +113,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
|
||||
const appConfig = ctx.appConfig;
|
||||
const appConfig = await ctx.appConfig;
|
||||
ctx.logger.info('Using app configuration:');
|
||||
ctx.logger.info(JSON.stringify(appConfig, null, 2));
|
||||
await validateAppConfigAsync(ctx, appConfig);
|
||||
@@ -115,12 +121,13 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
|
||||
if (ctx.job.triggeredBy === BuildTrigger.GIT_BASED_INTEGRATION) {
|
||||
await ctx.runBuildPhase(BuildPhase.EAS_BUILD_INTERNAL, async () => {
|
||||
if (!ctx.appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
|
||||
const appConfig = await ctx.appConfig;
|
||||
if (!appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
|
||||
throw new Error(
|
||||
'The "ios.bundleIdentifier" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#bundleidentifier.'
|
||||
);
|
||||
}
|
||||
if (!ctx.appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
|
||||
if (!appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
|
||||
throw new Error(
|
||||
'The "android.package" is required to be set in app config for builds triggered by GitHub integration. Learn more: https://docs.expo.dev/versions/latest/config/app/#package.'
|
||||
);
|
||||
@@ -281,7 +288,7 @@ async function validateAppConfigAsync(
|
||||
extraMessage =
|
||||
'If you are using environment variables to switch between projects in app.config.js/app.config.ts, make sure those variables are also set inside EAS Build. You can do that using "env" field in eas.json or EAS environment variables. ';
|
||||
}
|
||||
throw new UserFacingError(
|
||||
throw new UserError(
|
||||
'EAS_BUILD_PROJECT_ID_MISMATCH',
|
||||
`The value of the "extra.eas.projectId" field (${appConfig.extra.eas.projectId}) in the app config does not match the current project id (${ctx.env.EAS_BUILD_PROJECT_ID}). ${extraMessage}Learn more: https://expo.fyi/eas-config-mismatch.`
|
||||
);
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface BuildContextOptions {
|
||||
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
|
||||
skipNativeBuild?: boolean;
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
}
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
@@ -82,6 +83,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
|
||||
) => void;
|
||||
public readonly skipNativeBuild?: boolean;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
public artifacts: Artifacts = {};
|
||||
|
||||
private readonly _isLocal: boolean;
|
||||
@@ -94,7 +96,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
private buildPhase?: BuildPhase;
|
||||
private buildPhaseSkipped = false;
|
||||
private buildPhaseHasWarnings = false;
|
||||
private _appConfig?: ExpoConfig;
|
||||
private _appConfig?: Promise<ExpoConfig>;
|
||||
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
|
||||
public readonly graphqlClient: Client;
|
||||
|
||||
@@ -110,6 +112,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
this._job = job;
|
||||
this._metadata = options.metadata;
|
||||
this.skipNativeBuild = options.skipNativeBuild;
|
||||
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
|
||||
this.reportBuildPhaseStats = options.reportBuildPhaseStats;
|
||||
|
||||
const environmentSecrets = this.getEnvironmentSecrets(job);
|
||||
@@ -172,14 +175,14 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
public get packageManager(): PackageManager {
|
||||
return resolvePackageManager(this.getReactNativeProjectDirectory());
|
||||
}
|
||||
public get appConfig(): ExpoConfig {
|
||||
public get appConfig(): Promise<ExpoConfig> {
|
||||
if (!this._appConfig) {
|
||||
this._appConfig = readAppConfig({
|
||||
projectDir: this.getReactNativeProjectDirectory(),
|
||||
env: this.env,
|
||||
logger: this.logger,
|
||||
sdkVersion: this.metadata?.sdkVersion,
|
||||
}).exp;
|
||||
}).then(config => config.exp);
|
||||
}
|
||||
return this._appConfig;
|
||||
}
|
||||
@@ -284,7 +287,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
private async handleBuildPhaseErrorAsync(
|
||||
err: any,
|
||||
buildPhase: BuildPhase
|
||||
): Promise<errors.BuildError> {
|
||||
): Promise<errors.ExpoError> {
|
||||
const buildError = await resolveBuildPhaseErrorAsync(
|
||||
err,
|
||||
this.logBuffer.getPhaseLogs(buildPhase),
|
||||
@@ -299,7 +302,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
// leaving message empty, website will display err.stack which already includes err.message
|
||||
this.logger.error({ err }, '');
|
||||
} else {
|
||||
this.logger.error(`Error: ${buildError.userFacingMessage}`);
|
||||
this.logger.error(`Error: ${buildError.message}`);
|
||||
}
|
||||
return buildError;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
StaticJobInterpolationContext,
|
||||
} from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, ExternalBuildContextProvider } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, ExternalBuildContextProvider, StepMetric } from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import assert from 'assert';
|
||||
import path from 'path';
|
||||
|
||||
import { ArtifactToUpload, BuildContext } from './context';
|
||||
import { uploadStepMetricsToWwwAsync } from './utils/stepMetrics';
|
||||
|
||||
const platformToBuildRuntimePlatform: Record<Platform, BuildRuntimePlatform> = {
|
||||
[Platform.ANDROID]: BuildRuntimePlatform.LINUX,
|
||||
@@ -53,11 +55,14 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
public readonly startTime: Date;
|
||||
|
||||
public readonly logger: bunyan;
|
||||
public readonly graphqlClient: Client;
|
||||
public readonly runtimeApi: BuilderRuntimeApi;
|
||||
public job: TJob;
|
||||
public metadata?: Metadata;
|
||||
|
||||
private _env: Env;
|
||||
private readonly expoApiV2BaseUrl?: string;
|
||||
private readonly pendingMetricUploads: Promise<void>[] = [];
|
||||
|
||||
constructor(buildCtx: BuildContext<TJob>) {
|
||||
this._env = buildCtx.env;
|
||||
@@ -65,6 +70,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.metadata = buildCtx.metadata;
|
||||
|
||||
this.logger = buildCtx.logger.child({ phase: BuildPhase.CUSTOM });
|
||||
this.graphqlClient = buildCtx.graphqlClient;
|
||||
this.projectSourceDirectory = path.join(buildCtx.workingdir, 'temporary-custom-build');
|
||||
this.projectTargetDirectory = path.join(buildCtx.workingdir, 'build');
|
||||
this.defaultWorkingDirectory = buildCtx.getReactNativeProjectDirectory();
|
||||
@@ -72,6 +78,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.runtimeApi = {
|
||||
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
|
||||
};
|
||||
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
|
||||
this.startTime = new Date();
|
||||
}
|
||||
|
||||
@@ -139,4 +146,26 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
};
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
public reportStepMetric(metric: StepMetric): void {
|
||||
const workflowJobId = this._env.__WORKFLOW_JOB_ID;
|
||||
const robotAccessToken = this.job.secrets?.robotAccessToken;
|
||||
|
||||
if (!workflowJobId || !robotAccessToken || !this.expoApiV2BaseUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const p = uploadStepMetricsToWwwAsync({
|
||||
workflowJobId,
|
||||
robotAccessToken,
|
||||
expoApiV2BaseUrl: this.expoApiV2BaseUrl,
|
||||
stepMetrics: [metric],
|
||||
logger: this.logger,
|
||||
});
|
||||
this.pendingMetricUploads.push(p);
|
||||
}
|
||||
|
||||
public async drainPendingMetricUploads(): Promise<void> {
|
||||
await Promise.allSettled(this.pendingMetricUploads);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { BuildPhase, Generic } from '@expo/eas-build-job';
|
||||
import { Result, asyncResult } from '@expo/results';
|
||||
import { BuildStepGlobalContext, BuildWorkflow, StepsConfigParser, errors } from '@expo/steps';
|
||||
import fs from 'fs/promises';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { prepareProjectSourcesAsync } from './common/projectSources';
|
||||
import { BuildContext } from './context';
|
||||
@@ -10,12 +11,17 @@ import { getEasFunctionGroups } from './steps/easFunctionGroups';
|
||||
import { getEasFunctions } from './steps/easFunctions';
|
||||
import { uploadJobOutputsToWwwAsync } from './utils/outputs';
|
||||
import { retryAsync } from './utils/retry';
|
||||
import { uploadStepMetricsToWwwAsync } from './utils/stepMetrics';
|
||||
|
||||
export async function runGenericJobAsync(
|
||||
ctx: BuildContext<Generic.Job>,
|
||||
{ expoApiV2BaseUrl }: { expoApiV2BaseUrl: string }
|
||||
ctx: BuildContext<Generic.Job>
|
||||
): Promise<{ runResult: Result<void>; buildWorkflow: BuildWorkflow }> {
|
||||
// expoApiV2BaseUrl is empty when running a local build, but generic jobs
|
||||
// are never executed locally, so it is always present here.
|
||||
const expoApiV2BaseUrl = nullthrows(
|
||||
ctx.expoApiV2BaseUrl,
|
||||
'expoApiV2BaseUrl is required for generic jobs'
|
||||
);
|
||||
|
||||
const customBuildCtx = new CustomBuildContext(ctx);
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {
|
||||
@@ -60,22 +66,15 @@ export async function runGenericJobAsync(
|
||||
const runResult = await asyncResult(workflow.executeAsync());
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
|
||||
await uploadJobOutputsToWwwAsync(globalContext, {
|
||||
logger: ctx.logger,
|
||||
expoApiV2BaseUrl,
|
||||
});
|
||||
|
||||
const workflowJobId = globalContext.env.__WORKFLOW_JOB_ID;
|
||||
const robotAccessToken = ctx.job.secrets?.robotAccessToken;
|
||||
|
||||
if (workflowJobId && robotAccessToken) {
|
||||
await uploadStepMetricsToWwwAsync({
|
||||
workflowJobId,
|
||||
robotAccessToken,
|
||||
expoApiV2BaseUrl,
|
||||
stepMetrics: globalContext.stepMetrics,
|
||||
const results = await Promise.allSettled([
|
||||
uploadJobOutputsToWwwAsync(globalContext, {
|
||||
logger: ctx.logger,
|
||||
});
|
||||
expoApiV2BaseUrl,
|
||||
}),
|
||||
customBuildCtx.drainPendingMetricUploads(),
|
||||
]);
|
||||
if (results[0].status === 'rejected') {
|
||||
throw results[0].reason;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { provisioningProfile } from './fixtures';
|
||||
import { BuildContext } from '../../../context';
|
||||
@@ -73,5 +74,45 @@ describe('ProvisioningProfile class', () => {
|
||||
await pp.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('should match a certificate that is not at index 0 in DeveloperCertificates', async () => {
|
||||
const pp = new ProvisioningProfile(
|
||||
ctx,
|
||||
Buffer.from(provisioningProfile.dataBase64, 'base64'),
|
||||
keychain.data.path,
|
||||
'testapp',
|
||||
'Abc 123'
|
||||
);
|
||||
|
||||
try {
|
||||
await pp.init();
|
||||
|
||||
// Prepend an unrelated certificate so the real one moves to index 1
|
||||
const dummyCert = Buffer.from('dummy-certificate-data');
|
||||
(pp as any).developerCertificates = [dummyCert, ...(pp as any).developerCertificates];
|
||||
|
||||
// The original cert fingerprint should still match
|
||||
expect(() => {
|
||||
pp.verifyCertificate(provisioningProfile.certFingerprint);
|
||||
}).not.toThrow();
|
||||
|
||||
// A non-matching fingerprint should still fail
|
||||
expect(() => {
|
||||
pp.verifyCertificate('2137');
|
||||
}).toThrowError(/don't match/);
|
||||
|
||||
// The dummy cert's fingerprint should also match
|
||||
const dummyFingerprint = crypto
|
||||
.createHash('sha1')
|
||||
.update(new Uint8Array(dummyCert))
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
expect(() => {
|
||||
pp.verifyCertificate(dummyFingerprint);
|
||||
}).not.toThrow();
|
||||
} finally {
|
||||
await pp.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,7 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
|
||||
private readonly profilePath: string;
|
||||
private profileData?: ProvisioningProfileData;
|
||||
private developerCertificates: Buffer[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly ctx: BuildContext<TJob>,
|
||||
@@ -77,11 +78,12 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
}
|
||||
|
||||
public verifyCertificate(fingerprint: string): void {
|
||||
const devCertFingerprint = this.genDerCertFingerprint();
|
||||
if (devCertFingerprint !== fingerprint) {
|
||||
const devCertFingerprints = this.getAllDerCertFingerprints();
|
||||
if (!devCertFingerprints.includes(fingerprint)) {
|
||||
throw new errors.CredentialsDistCertMismatchError(
|
||||
`Provisioning profile and distribution certificate don't match.
|
||||
Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}`
|
||||
`Provisioning profile and distribution certificate don't match.\n` +
|
||||
`Profile's certificate fingerprints = [${devCertFingerprints.join(', ')}], ` +
|
||||
`distribution certificate fingerprint = ${fingerprint}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -114,6 +116,10 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
] as string;
|
||||
const bundleIdentifier = applicationIdentifier.replace(/^.+?\./, '');
|
||||
|
||||
this.developerCertificates = (plistData.DeveloperCertificates as string[]).map((cert: string) =>
|
||||
Buffer.from(cert, 'base64')
|
||||
);
|
||||
|
||||
this.profileData = {
|
||||
path: this.profilePath,
|
||||
target: this.target,
|
||||
@@ -121,7 +127,7 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
teamId: (plistData.TeamIdentifier as string[])[0],
|
||||
uuid: plistData.UUID as string,
|
||||
name: plistData.Name as string,
|
||||
developerCertificate: Buffer.from((plistData.DeveloperCertificates as string[])[0], 'base64'),
|
||||
developerCertificate: this.developerCertificates[0],
|
||||
certificateCommonName: this.certificateCommonName,
|
||||
distributionType: this.resolveDistributionType(plistData),
|
||||
};
|
||||
@@ -137,11 +143,9 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
}
|
||||
}
|
||||
|
||||
private genDerCertFingerprint(): string {
|
||||
return crypto
|
||||
.createHash('sha1')
|
||||
.update(new Uint8Array(this.data.developerCertificate))
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
private getAllDerCertFingerprints(): string[] {
|
||||
return this.developerCertificates.map(cert =>
|
||||
crypto.createHash('sha1').update(new Uint8Array(cert)).digest('hex').toUpperCase()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,10 +17,10 @@ import { injectAndroidCredentialsFunction } from './functions/injectAndroidCrede
|
||||
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
|
||||
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
|
||||
import { createInstallPodsBuildFunction } from './functions/installPods';
|
||||
import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest';
|
||||
import { createPrebuildBuildFunction } from './functions/prebuild';
|
||||
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
|
||||
import { createRepackBuildFunction } from './functions/repack';
|
||||
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
|
||||
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
|
||||
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
|
||||
import {
|
||||
@@ -80,7 +80,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createSubmissionEntityFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
|
||||
createInternalEasMaestroTestFunction(ctx),
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
];
|
||||
|
||||
if (ctx.hasBuildJob()) {
|
||||
|
||||
@@ -51,7 +51,6 @@ export function createEasMaestroTestFunctionGroup(
|
||||
steps.push(
|
||||
new BuildStep(globalCtx, {
|
||||
id: BuildStep.getNewId(),
|
||||
name: 'install_app',
|
||||
displayName: `Install app to Simulator`,
|
||||
command: `
|
||||
# shopt -s nullglob is necessary not to try to install
|
||||
@@ -97,7 +96,6 @@ export function createEasMaestroTestFunctionGroup(
|
||||
steps.push(
|
||||
new BuildStep(globalCtx, {
|
||||
id: BuildStep.getNewId(),
|
||||
name: 'install_app',
|
||||
displayName: `Install app to Emulator`,
|
||||
command: `
|
||||
# shopt -s globstar is necessary to add /**/ support
|
||||
@@ -134,7 +132,6 @@ export function createEasMaestroTestFunctionGroup(
|
||||
steps.push(
|
||||
new BuildStep(globalCtx, {
|
||||
id: BuildStep.getNewId(),
|
||||
name: 'maestro_test',
|
||||
ifCondition: '${ always() }',
|
||||
displayName: `maestro test ${flowPath}`,
|
||||
command: `maestro test ${flowPath}`,
|
||||
|
||||
@@ -0,0 +1,805 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import {
|
||||
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 () => {
|
||||
vol.fromJSON({
|
||||
// JUnit XML (primary data)
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="2" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.5" status="SUCCESS"/>',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
|
||||
' <failure>Tap failed</failure>',
|
||||
' </testcase>',
|
||||
' </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');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
name: 'home',
|
||||
path: '.maestro/home.yml',
|
||||
status: 'passed',
|
||||
errorMessage: null,
|
||||
duration: 10500,
|
||||
retryCount: 0,
|
||||
tags: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
name: 'login',
|
||||
path: '.maestro/login.yml',
|
||||
status: 'failed',
|
||||
errorMessage: 'Tap failed',
|
||||
duration: 5000,
|
||||
retryCount: 0,
|
||||
tags: [],
|
||||
properties: {},
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('calculates retryCount from timestamp directory occurrences', 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'),
|
||||
// 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');
|
||||
expect(results[0].path).toBe('home');
|
||||
expect(results[0].retryCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty array when no JUnit files found', 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',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts tags from JUnit properties and separates from other properties', 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">',
|
||||
' <properties>',
|
||||
' <property name="env" value="staging"/>',
|
||||
' <property name="tags" value="e2e, smoke"/>',
|
||||
' </properties>',
|
||||
' </testcase>',
|
||||
' </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');
|
||||
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
|
||||
'/junit/junit-report-flow-1-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
|
||||
' <failure>Timeout</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Attempt 1: login PASSED
|
||||
'/junit/junit-report-flow-1-attempt-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
|
||||
' </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');
|
||||
|
||||
// Should return 2 results — one per attempt
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'login',
|
||||
path: '.maestro/login.yml',
|
||||
status: 'failed',
|
||||
errorMessage: 'Timeout',
|
||||
duration: 5000,
|
||||
retryCount: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'login',
|
||||
path: '.maestro/login.yml',
|
||||
status: 'passed',
|
||||
errorMessage: null,
|
||||
duration: 3000,
|
||||
retryCount: 1,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns per-attempt results for reuse_devices=true (all flows in every attempt)', async () => {
|
||||
vol.fromJSON({
|
||||
// Attempt 0: home FAILED, login PASSED
|
||||
'/junit-reports/android-maestro-junit-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="2" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
|
||||
' <failure>Timeout</failure>',
|
||||
' </testcase>',
|
||||
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Attempt 1: both PASSED
|
||||
'/junit-reports/android-maestro-junit-attempt-1.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'),
|
||||
'/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');
|
||||
|
||||
// 4 results: 2 flows × 2 attempts
|
||||
expect(results).toHaveLength(4);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ name: 'home', status: 'failed', retryCount: 0 }),
|
||||
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 0 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
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>',
|
||||
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
|
||||
' <failure>Timeout</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="0">',
|
||||
' <testcase id="login" name="login" classname="login" time="3.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Attempt 1: all passed across shards
|
||||
'/junit-reports/android-maestro-junit-attempt-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="4.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="0">',
|
||||
' <testcase id="login" name="login" classname="login" time="2.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/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');
|
||||
|
||||
expect(results).toHaveLength(4);
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ name: 'home', status: 'failed', retryCount: 0 }),
|
||||
expect.objectContaining({ name: 'home', status: 'passed', retryCount: 1 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 0 }),
|
||||
expect.objectContaining({ name: 'login', status: 'passed', retryCount: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('backward compat: reuse_devices=true with retries but old single JUnit file', 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>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tmp/maestro-reports-abc123/junit-report-flow-2.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
|
||||
' <failure>Failed</failure>',
|
||||
' </testcase>',
|
||||
' </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'
|
||||
);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'home', path: '.maestro/home.yml', status: 'passed' }),
|
||||
expect.objectContaining({ name: 'login', path: '.maestro/login.yml', status: 'failed' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
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, () => {
|
||||
it('parses a single testcase with SUCCESS status', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0" time="10.5">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.5" status="SUCCESS">',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results).toEqual([
|
||||
{
|
||||
name: 'home',
|
||||
status: 'passed',
|
||||
duration: 10500,
|
||||
errorMessage: null,
|
||||
tags: [],
|
||||
properties: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses a failed testcase with failure message', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
|
||||
' <failure>Element not visible</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results).toEqual([
|
||||
{
|
||||
name: 'login',
|
||||
status: 'failed',
|
||||
duration: 5000,
|
||||
errorMessage: 'Element not visible',
|
||||
tags: [],
|
||||
properties: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts properties from testcase', 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">',
|
||||
' <properties>',
|
||||
' <property name="env" value="staging"/>',
|
||||
' <property name="priority" value="high"/>',
|
||||
' </properties>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].properties).toEqual({ env: 'staging', priority: 'high' });
|
||||
});
|
||||
|
||||
it('extracts tags from "tags" property as comma-separated values', 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">',
|
||||
' <properties>',
|
||||
' <property name="tags" value="smoke, critical, auth"/>',
|
||||
' <property name="priority" value="high"/>',
|
||||
' </properties>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].tags).toEqual(['smoke', 'critical', 'auth']);
|
||||
expect(results[0].properties).toEqual({ priority: 'high' });
|
||||
});
|
||||
|
||||
it('returns empty tags when no "tags" property exists (older Maestro)', 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">',
|
||||
' <properties>',
|
||||
' <property name="priority" value="high"/>',
|
||||
' </properties>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].tags).toEqual([]);
|
||||
expect(results[0].properties).toEqual({ priority: 'high' });
|
||||
});
|
||||
|
||||
it('parses multiple testcases across multiple testsuites (shards)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="1">',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
|
||||
' <failure>Tap failed</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'home', status: 'passed' }),
|
||||
expect.objectContaining({ name: 'login', status: 'failed', errorMessage: 'Tap failed' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('parses multiple JUnit XML files in the same directory', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/junit-report-flow-1.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'),
|
||||
'/junit/junit-report-flow-2.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
|
||||
' <failure>Tap failed</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles missing time attribute (defaults to 0)', 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" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].duration).toBe(0);
|
||||
});
|
||||
|
||||
it('handles invalid time attribute (defaults to 0)', 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="not_a_number" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].duration).toBe(0);
|
||||
});
|
||||
|
||||
it('uses @_status attribute for pass/fail (not <failure> presence)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].status).toBe('failed');
|
||||
expect(results[0].errorMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts error message from <error> element', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
|
||||
' <error>Runtime exception occurred</error>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].status).toBe('failed');
|
||||
expect(results[0].errorMessage).toBe('Runtime exception occurred');
|
||||
});
|
||||
|
||||
it('returns empty array when directory does not exist', async () => {
|
||||
const results = await parseJUnitTestCases('/nonexistent');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when no XML files found', async () => {
|
||||
vol.fromJSON({ '/junit/.gitkeep': '' });
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips invalid XML files gracefully', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/bad.xml': 'not xml at all',
|
||||
'/junit/good.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 parseJUnitTestCases('/junit');
|
||||
expect(results).toEqual([expect.objectContaining({ name: 'home', status: 'passed' })]);
|
||||
});
|
||||
|
||||
it('handles testcase with no properties element', 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'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].properties).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
import { Client } from '@urql/core';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createReportMaestroTestResultsFunction } from '../reportMaestroTestResults';
|
||||
|
||||
const JUNIT_PASS = [
|
||||
'<?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 JUNIT_FAIL = [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
|
||||
' <failure>Tap failed</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n');
|
||||
|
||||
const FLOW_AI = JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
});
|
||||
|
||||
describe(createReportMaestroTestResultsFunction, () => {
|
||||
let mockMutationFn: jest.Mock;
|
||||
let mockGraphqlClient: Client;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMutationFn = jest.fn();
|
||||
mockGraphqlClient = {
|
||||
mutation: jest.fn().mockReturnValue({
|
||||
toPromise: mockMutationFn,
|
||||
}),
|
||||
} as unknown as Client;
|
||||
});
|
||||
|
||||
function createStep(overrides?: {
|
||||
callInputs?: Record<string, unknown>;
|
||||
staticContextContent?: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
}) {
|
||||
const ctx = { graphqlClient: mockGraphqlClient };
|
||||
const fn = createReportMaestroTestResultsFunction(ctx as any);
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
projectTargetDirectory: '/root/project',
|
||||
staticContextContent: {
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
job: { secrets: { robotAccessToken: 'test-token' } },
|
||||
...overrides?.staticContextContent,
|
||||
},
|
||||
});
|
||||
globalCtx.updateEnv(overrides?.env ?? { __WORKFLOW_JOB_ID: 'job-uuid-123' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
junit_report_directory: '/junit',
|
||||
tests_directory: '/tests',
|
||||
...overrides?.callInputs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
expect(variables.input.workflowJobId).toBe('job-uuid-123');
|
||||
expect(variables.input.testCaseResults).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'home',
|
||||
path: '.maestro/home.yml',
|
||||
status: 'PASSED',
|
||||
errorMessage: null,
|
||||
duration: 10000,
|
||||
retryCount: 0,
|
||||
properties: {},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends tags and properties from JUnit XML', 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">',
|
||||
' <properties>',
|
||||
' <property name="testCaseId" value="TC-001"/>',
|
||||
' <property name="priority" value="high"/>',
|
||||
' <property name="tags" value="e2e"/>',
|
||||
' </properties>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
expect(variables.input.testCaseResults[0].tags).toEqual(['e2e']);
|
||||
expect(variables.input.testCaseResults[0].properties).toEqual({
|
||||
testCaseId: 'TC-001',
|
||||
priority: 'high',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports failed flow with correct status and errorMessage', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': JUNIT_FAIL,
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
expect(variables.input.testCaseResults[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'FAILED',
|
||||
errorMessage: 'Tap failed',
|
||||
duration: 5000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// robotAccessToken guard removed — Generic.JobZ requires robotAccessToken (z.string(),
|
||||
// not optional), so it's always present in workflow jobs. graphqlClient is already
|
||||
// initialized with the token in BuildContext constructor.
|
||||
|
||||
it('skips report when junit_report_directory is empty string (upstream step failed)', async () => {
|
||||
const step = createStep({
|
||||
callInputs: { junit_report_directory: '' },
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips report when __WORKFLOW_JOB_ID env is not set', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': JUNIT_PASS,
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
const step = createStep({ env: {} });
|
||||
await step.executeAsync();
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips report when no JUnit files found', async () => {
|
||||
const step = createStep();
|
||||
await step.executeAsync();
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not throw when GraphQL returns error', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': JUNIT_PASS,
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
error: { message: 'Something went wrong' },
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
});
|
||||
|
||||
it('does not throw when mutation rejects', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': JUNIT_PASS,
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await createStep().executeAsync();
|
||||
});
|
||||
|
||||
it('skips report when duplicate testcase names found', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="2" failures="0">',
|
||||
' <testcase id="login" name="login" classname="login" time="10.0" status="SUCCESS"/>',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends per-attempt results with same name but different retryCount', async () => {
|
||||
vol.fromJSON({
|
||||
// Two JUnit files for same flow (per-attempt)
|
||||
'/junit/junit-report-flow-1-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
|
||||
' <failure>Tap failed</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/junit/junit-report-flow-1-attempt-1.xml': JUNIT_PASS,
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
'/tests/2026-01-28_055420/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }, { id: 'id-2' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
expect(variables.input.testCaseResults).toHaveLength(2);
|
||||
expect(variables.input.testCaseResults[0]).toEqual(
|
||||
expect.objectContaining({ name: 'home', status: 'FAILED', retryCount: 0 })
|
||||
);
|
||||
expect(variables.input.testCaseResults[1]).toEqual(
|
||||
expect.objectContaining({ name: 'home', status: 'PASSED', retryCount: 1 })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses default directories when inputs are not provided', async () => {
|
||||
vol.fromJSON({
|
||||
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
|
||||
'/home/expo/.maestro/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
const ctx = { graphqlClient: mockGraphqlClient };
|
||||
const fn = createReportMaestroTestResultsFunction(ctx as any);
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
projectTargetDirectory: '/root/project',
|
||||
staticContextContent: {
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
job: { secrets: { robotAccessToken: 'test-token' } },
|
||||
},
|
||||
});
|
||||
globalCtx.updateEnv({ __WORKFLOW_JOB_ID: 'job-uuid-123', HOME: '/home/expo' });
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {},
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
retryAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: {
|
||||
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
|
||||
getAvailableDevicesAsync: jest.fn(),
|
||||
createAsync: jest.fn(),
|
||||
cloneAsync: jest.fn(),
|
||||
startAsync: jest.fn(),
|
||||
waitForReadyAsync: jest.fn(),
|
||||
disableWindowAndTransitionAnimationsAsync: jest.fn(),
|
||||
deleteAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
|
||||
const logger = createMockLogger();
|
||||
const fn = createStartAndroidEmulatorBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home', ...envOverrides });
|
||||
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs,
|
||||
});
|
||||
return Object.assign(step, { logger });
|
||||
}
|
||||
|
||||
function createStartResult(serialId: string) {
|
||||
return {
|
||||
serialId: serialId as any,
|
||||
emulatorPromise: Promise.resolve({}) as any,
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedAndroidUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
mockedAndroidUtils.createAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.startAsync.mockResolvedValue(createStartResult('emulator-default'));
|
||||
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
|
||||
|
||||
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
|
||||
let lastErr: unknown;
|
||||
for (let attemptCount = 0; attemptCount <= retryOptions.retries; attemptCount++) {
|
||||
try {
|
||||
return await fn(attemptCount);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
});
|
||||
});
|
||||
|
||||
it('retries base emulator startup with increasing readiness timeouts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-1111'))
|
||||
.mockResolvedValueOnce(createStartResult('emulator-2222'));
|
||||
mockedAndroidUtils.waitForReadyAsync
|
||||
.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-1111',
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-2222',
|
||||
timeoutMs: 120_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-2222',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-1111',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('retries clone startup independently and cleans up failed clone attempts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-base'))
|
||||
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-1'))
|
||||
.mockResolvedValueOnce(createStartResult('emulator-clone-1-attempt-2'))
|
||||
.mockResolvedValueOnce(createStartResult('emulator-clone-2-attempt-1'));
|
||||
|
||||
mockedAndroidUtils.waitForReadyAsync
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('clone network unavailable'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await createStep({ count: 2 }).executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-base',
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-clone-1-attempt-1',
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-clone-1-attempt-2',
|
||||
timeoutMs: 120_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-clone-2-attempt-1',
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-base',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-clone-1-attempt-2',
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-clone-2-attempt-1',
|
||||
})
|
||||
);
|
||||
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-clone-1-attempt-1',
|
||||
deviceName: 'eas-simulator-1' as AndroidVirtualDeviceName,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails after exhausting all startup attempts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-attempt-1'))
|
||||
.mockResolvedValueOnce(createStartResult('emulator-attempt-2'))
|
||||
.mockResolvedValueOnce(createStartResult('emulator-attempt-3'));
|
||||
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
|
||||
|
||||
await expect(createStep().executeAsync()).rejects.toThrow('network unavailable');
|
||||
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-3',
|
||||
timeoutMs: 180_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('skips animation scale adjustments when opt out env var is disabled', async () => {
|
||||
const step = createStep(undefined, {
|
||||
ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE: 'false',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
isClosedVersionTrainError,
|
||||
isInvalidBundleIdentifierError,
|
||||
isMissingPurposeStringError,
|
||||
isSdkVersionIssueError,
|
||||
parseMissingUsageDescriptionKeys,
|
||||
} from '../uploadToAsc';
|
||||
|
||||
describe(isClosedVersionTrainError, () => {
|
||||
it('returns true when all errors are closed-version-train codes', () => {
|
||||
expect(
|
||||
isClosedVersionTrainError([
|
||||
{ code: '90062' },
|
||||
{ code: '90186' },
|
||||
{ code: '90478' },
|
||||
{ code: '90062' },
|
||||
])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(
|
||||
isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { code: '12345' }])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isClosedVersionTrainError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isInvalidBundleIdentifierError, () => {
|
||||
it('returns true when all errors are invalid-bundle-id codes', () => {
|
||||
expect(
|
||||
isInvalidBundleIdentifierError([{ code: '90054' }, { code: '90055' }, { code: '90055' }])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isInvalidBundleIdentifierError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isMissingPurposeStringError, () => {
|
||||
it('returns true when all errors are missing-purpose-string codes', () => {
|
||||
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isMissingPurposeStringError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(isSdkVersionIssueError, () => {
|
||||
it('returns true when all errors are SDK-version-issue codes', () => {
|
||||
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any other error code is present', () => {
|
||||
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when there are no errors', () => {
|
||||
expect(isSdkVersionIssueError([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMissingUsageDescriptionKeys, () => {
|
||||
it('extracts missing UsageDescription keys from ASC messages', () => {
|
||||
expect(
|
||||
parseMissingUsageDescriptionKeys([
|
||||
{
|
||||
description:
|
||||
'The Info.plist file should contain a NSPhotoLibraryUsageDescription key with a user-facing purpose string. (90683)',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
|
||||
},
|
||||
])
|
||||
).toEqual(['NSPhotoLibraryUsageDescription', 'NSCameraUsageDescription']);
|
||||
});
|
||||
|
||||
it('deduplicates repeated keys', () => {
|
||||
expect(
|
||||
parseMissingUsageDescriptionKeys([
|
||||
{
|
||||
description:
|
||||
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
|
||||
},
|
||||
])
|
||||
).toEqual(['NSCameraUsageDescription']);
|
||||
});
|
||||
|
||||
it('returns an empty array when no usage description key is present', () => {
|
||||
expect(
|
||||
parseMissingUsageDescriptionKeys([{ description: 'Some other upload validation error.' }])
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -34,18 +34,20 @@ export function calculateEASUpdateRuntimeVersionFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, inputs, outputs }) => {
|
||||
const appConfig = readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
|
||||
}).exp;
|
||||
const appConfig = (
|
||||
await readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: stepCtx.global.staticContext.metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
|
||||
const platform =
|
||||
(inputs.platform.value as Platform) ?? stepCtx.global.staticContext.job.platform;
|
||||
|
||||
@@ -42,18 +42,20 @@ export function configureEASUpdateIfInstalledFunction(): BuildFunction {
|
||||
assert(job.platform, 'Configuring EAS Update in generic jobs is not supported.');
|
||||
const metadata = stepCtx.global.staticContext.metadata as Metadata | undefined;
|
||||
|
||||
const appConfig = readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: metadata?.sdkVersion,
|
||||
}).exp;
|
||||
const appConfig = (
|
||||
await readAppConfig({
|
||||
projectDir: stepCtx.workingDirectory,
|
||||
env: Object.keys(env).reduce(
|
||||
(acc, key) => {
|
||||
acc[key] = env[key] ?? '';
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
logger: stepCtx.logger,
|
||||
sdkVersion: metadata?.sdkVersion,
|
||||
})
|
||||
).exp;
|
||||
|
||||
const channelInput = inputs.channel.value as string | undefined;
|
||||
const runtimeVersionInput = inputs.runtime_version.value as string | undefined;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -56,7 +56,7 @@ export function createDownloadArtifactFunction(): BuildFunction {
|
||||
const interpolationContext = stepsCtx.global.getInterpolationContext();
|
||||
|
||||
if (!('workflow' in interpolationContext)) {
|
||||
throw new UserFacingError(
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
|
||||
'No workflow found in the interpolation context.'
|
||||
);
|
||||
@@ -64,7 +64,7 @@ export function createDownloadArtifactFunction(): BuildFunction {
|
||||
|
||||
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
|
||||
if (!robotAccessToken) {
|
||||
throw new UserFacingError(
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
|
||||
'No robot access token found in the job secrets.'
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -134,7 +134,7 @@ export async function downloadBuildAsync({
|
||||
});
|
||||
|
||||
if (matchingFiles.length === 0) {
|
||||
throw new UserFacingError(
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_NO_MATCHING_FILES',
|
||||
`No ${extensions.map(ext => `.${ext}`).join(', ')} entries found in the archive.`
|
||||
);
|
||||
|
||||
@@ -1,621 +0,0 @@
|
||||
import { GenericArtifactType } from '@expo/eas-build-job';
|
||||
import { PipeMode, bunyan } from '@expo/logger';
|
||||
import { Result, asyncResult, result } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
AndroidDeviceSerialId,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
|
||||
import { PlatformToProperNounMap } from '../../utils/strings';
|
||||
|
||||
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: '__maestro_test',
|
||||
__metricsId: 'eas/__maestro_test',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'platform',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
id: 'flow_paths',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
id: 'retries',
|
||||
defaultValue: 1,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'include_tags',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'exclude_tags',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
id: 'shards',
|
||||
defaultValue: 1,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
id: 'output_format',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
id: 'record_screen',
|
||||
defaultValue: false,
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'test_reports_artifact_id',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs: _inputs, env, outputs }) => {
|
||||
// inputs come in form of { value: unknown }. Here we parse them into a typed and validated object.
|
||||
const {
|
||||
platform,
|
||||
flow_paths,
|
||||
retries,
|
||||
include_tags,
|
||||
exclude_tags,
|
||||
shards,
|
||||
output_format,
|
||||
record_screen,
|
||||
} = z
|
||||
.object({
|
||||
platform: z.enum(['ios', 'android']),
|
||||
flow_paths: z.array(z.string()),
|
||||
retries: z.number().default(1),
|
||||
include_tags: z.string().optional(),
|
||||
exclude_tags: z.string().optional(),
|
||||
shards: z.number().default(1),
|
||||
output_format: z.string().optional(),
|
||||
record_screen: z.boolean().default(false),
|
||||
})
|
||||
.parse(
|
||||
Object.fromEntries(Object.entries(_inputs).map(([key, value]) => [key, value.value]))
|
||||
);
|
||||
|
||||
const flowPathsToExecute: string[] = [];
|
||||
for (const flowPath of flow_paths) {
|
||||
const flowPaths = await findMaestroPathsFlowsToExecuteAsync({
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
flowPath,
|
||||
logger: stepCtx.logger,
|
||||
includeTags: include_tags ? include_tags.split(',') : undefined,
|
||||
excludeTags: exclude_tags ? exclude_tags.split(',') : undefined,
|
||||
});
|
||||
if (flowPaths.length === 0) {
|
||||
stepCtx.logger.warn(`No flows to execute found in "${flowPath}".`);
|
||||
continue;
|
||||
}
|
||||
stepCtx.logger.info(
|
||||
`Marking for execution:\n- ${flowPaths
|
||||
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
|
||||
.join('\n- ')}`
|
||||
);
|
||||
stepCtx.logger.info('');
|
||||
flowPathsToExecute.push(...flowPaths);
|
||||
}
|
||||
|
||||
// TODO: Add support for shards. (Shouldn't be too difficult.)
|
||||
if (shards > 1) {
|
||||
stepCtx.logger.warn(
|
||||
'Sharding support has been temporarily disabled. Running tests on a single shard.'
|
||||
);
|
||||
}
|
||||
|
||||
// eas/__maestro_test does not start devices, it expects a single device to be already running
|
||||
// and configured with the app. Here we find the booted device and stop it.
|
||||
|
||||
let sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
const bootedDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'booted',
|
||||
});
|
||||
if (bootedDevices.length === 0) {
|
||||
throw new Error('No booted iOS Simulator found.');
|
||||
} else if (bootedDevices.length > 1) {
|
||||
throw new Error('Multiple booted iOS Simulators found.');
|
||||
}
|
||||
|
||||
const device = bootedDevices[0];
|
||||
stepCtx.logger.info(`Running tests on iOS Simulator: ${device.name}.`);
|
||||
|
||||
stepCtx.logger.info(`Preparing Simulator for tests...`);
|
||||
await spawnAsync('xcrun', ['simctl', 'shutdown', device.udid], {
|
||||
logger: stepCtx.logger,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
sourceDeviceIdentifier = device.name;
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
const connectedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
|
||||
if (connectedDevices.length === 0) {
|
||||
throw new Error('No booted Android Emulator found.');
|
||||
} else if (connectedDevices.length > 1) {
|
||||
throw new Error('Multiple booted Android Emulators found.');
|
||||
}
|
||||
|
||||
const { serialId } = connectedDevices[0];
|
||||
const adbEmuAvdNameResult = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
|
||||
mode: PipeMode.COMBINED,
|
||||
env,
|
||||
});
|
||||
const avdName = adbEmuAvdNameResult.stdout
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')[0] as AndroidVirtualDeviceName;
|
||||
stepCtx.logger.info(`Running tests on Android Emulator: ${avdName}.`);
|
||||
|
||||
stepCtx.logger.info(`Preparing Emulator for tests...`);
|
||||
await spawnAsync('adb', ['-s', serialId, 'emu', 'kill'], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
// Waiting for emulator to get killed, see ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL.
|
||||
await setTimeout(1000);
|
||||
|
||||
sourceDeviceIdentifier = avdName;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// During tests we generate reports and device logs. We store them in temporary directories
|
||||
// and upload them once all tests are done. When a test is retried, new reports overwrite
|
||||
// the old ones. The files are named "flow-${index}" for easier identification.
|
||||
const maestroReportsDir = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'maestro-reports-')
|
||||
);
|
||||
const deviceLogsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'device-logs-'));
|
||||
|
||||
const failedFlows: string[] = [];
|
||||
|
||||
for (const [flowIndex, flowPath] of flowPathsToExecute.entries()) {
|
||||
stepCtx.logger.info('');
|
||||
|
||||
// If output_format is empty or noop, we won't use this.
|
||||
const outputPath = path.join(
|
||||
maestroReportsDir,
|
||||
[
|
||||
`${output_format ? output_format + '-' : ''}report-flow-${flowIndex + 1}`,
|
||||
MaestroOutputFormatToExtensionMap[output_format ?? 'noop'],
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('.')
|
||||
);
|
||||
|
||||
for (let attemptCount = 0; attemptCount < retries; attemptCount++) {
|
||||
const localDeviceName = `eas-simulator-${flowIndex}-${attemptCount}` as
|
||||
| IosSimulatorName
|
||||
| AndroidVirtualDeviceName;
|
||||
|
||||
// If the test passes, but the recording fails, we don't want to make the test fail,
|
||||
// so we return two separate results.
|
||||
const {
|
||||
fnResult: { fnResult, recordingResult },
|
||||
logsResult,
|
||||
} = await withCleanDeviceAsync({
|
||||
platform,
|
||||
sourceDeviceIdentifier,
|
||||
localDeviceName,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
fn: async ({ deviceIdentifier }) => {
|
||||
return await maybeWithScreenRecordingAsync({
|
||||
shouldRecord: record_screen,
|
||||
platform,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger: stepCtx.logger,
|
||||
fn: async () => {
|
||||
stepCtx.logger.info('');
|
||||
|
||||
const [command, ...args] = getMaestroTestCommand({
|
||||
flow_path: flowPath,
|
||||
output_format,
|
||||
output_path: outputPath,
|
||||
});
|
||||
|
||||
try {
|
||||
await spawnAsync(command, args, {
|
||||
logger: stepCtx.logger,
|
||||
cwd: stepCtx.workingDirectory,
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
} finally {
|
||||
stepCtx.logger.info('');
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Move device logs to the device logs directory.
|
||||
if (logsResult?.ok) {
|
||||
try {
|
||||
const extension = path.extname(logsResult.value.outputPath);
|
||||
const destinationPath = path.join(deviceLogsDir, `flow-${flowIndex}${extension}`);
|
||||
|
||||
await fs.promises.rm(destinationPath, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
});
|
||||
await fs.promises.rename(logsResult.value.outputPath, destinationPath);
|
||||
} catch (err) {
|
||||
stepCtx.logger.warn({ err }, 'Failed to prepare device logs for upload.');
|
||||
}
|
||||
} else if (logsResult?.reason) {
|
||||
stepCtx.logger.error({ err: logsResult.reason }, 'Failed to collect device logs.');
|
||||
}
|
||||
|
||||
const isLastAttempt = fnResult.ok || attemptCount === retries - 1;
|
||||
if (isLastAttempt && recordingResult.value) {
|
||||
try {
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
logger: stepCtx.logger,
|
||||
artifact: {
|
||||
// TODO(sjchmiela): Add metadata to artifacts so we don't need to encode flow path and attempt in the name.
|
||||
name: `Screen Recording (${flowIndex}-${path.basename(
|
||||
flowPath,
|
||||
path.extname(flowPath)
|
||||
)})`,
|
||||
paths: [recordingResult.value],
|
||||
type: GenericArtifactType.OTHER,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
stepCtx.logger.warn({ err }, 'Failed to upload screen recording.');
|
||||
}
|
||||
}
|
||||
|
||||
if (fnResult.ok) {
|
||||
stepCtx.logger.info(`Test passed.`);
|
||||
// Break out of the retry loop.
|
||||
break;
|
||||
}
|
||||
|
||||
if (attemptCount < retries - 1) {
|
||||
stepCtx.logger.info(`Retrying test...`);
|
||||
stepCtx.logger.info('');
|
||||
continue;
|
||||
}
|
||||
|
||||
// fnResult.reason is not super interesting, but it does print out the full command so we can keep it for debugging purposes.
|
||||
stepCtx.logger.error({ err: fnResult.reason }, 'Test errored.');
|
||||
failedFlows.push(flowPath);
|
||||
}
|
||||
}
|
||||
|
||||
stepCtx.logger.info('');
|
||||
|
||||
// When all tests are done, we upload the reports and device logs.
|
||||
const generatedMaestroReports = await fs.promises.readdir(maestroReportsDir);
|
||||
if (generatedMaestroReports.length === 0) {
|
||||
stepCtx.logger.warn('No reports were generated.');
|
||||
} else {
|
||||
stepCtx.logger.info(`Uploading reports...`);
|
||||
try {
|
||||
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
|
||||
logger: stepCtx.logger,
|
||||
artifact: {
|
||||
name: `${PlatformToProperNounMap[platform]} Maestro Test Reports (${output_format})`,
|
||||
paths: [maestroReportsDir],
|
||||
type: GenericArtifactType.OTHER,
|
||||
},
|
||||
});
|
||||
if (artifactId) {
|
||||
outputs.test_reports_artifact_id.set(artifactId);
|
||||
}
|
||||
} catch (err) {
|
||||
stepCtx.logger.error({ err }, 'Failed to upload reports.');
|
||||
}
|
||||
}
|
||||
|
||||
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
|
||||
if (generatedDeviceLogs.length === 0) {
|
||||
stepCtx.logger.warn('No device logs were successfully collected.');
|
||||
} else {
|
||||
stepCtx.logger.info(`Uploading device logs...`);
|
||||
try {
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
logger: stepCtx.logger,
|
||||
artifact: {
|
||||
name: `Maestro Test Device Logs`,
|
||||
paths: [deviceLogsDir],
|
||||
type: GenericArtifactType.OTHER,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
stepCtx.logger.error({ err }, 'Failed to upload device logs.');
|
||||
}
|
||||
}
|
||||
|
||||
stepCtx.logger.info('');
|
||||
|
||||
// If any tests failed, we throw an error to mark the step as failed.
|
||||
if (failedFlows.length > 0) {
|
||||
throw new Error(
|
||||
`Some Maestro tests failed:\n- ${failedFlows
|
||||
.map(flowPath => path.relative(stepCtx.workingDirectory, flowPath))
|
||||
.join('\n- ')}`
|
||||
);
|
||||
} else {
|
||||
stepCtx.logger.info('All Maestro tests passed.');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getMaestroTestCommand(params: {
|
||||
flow_path: string;
|
||||
output_format: string | undefined;
|
||||
/** Unused if `output_format` is undefined */
|
||||
output_path: string;
|
||||
}): [command: string, ...args: string[]] {
|
||||
let outputFormatFlags: string[] = [];
|
||||
if (params.output_format) {
|
||||
outputFormatFlags = [`--format`, params.output_format, `--output`, params.output_path];
|
||||
}
|
||||
|
||||
return ['maestro', 'test', ...outputFormatFlags, params.flow_path] as [
|
||||
command: string,
|
||||
...args: string[],
|
||||
];
|
||||
}
|
||||
|
||||
const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
|
||||
junit: 'xml',
|
||||
html: 'html',
|
||||
};
|
||||
|
||||
async function withCleanDeviceAsync<TResult>({
|
||||
platform,
|
||||
sourceDeviceIdentifier,
|
||||
localDeviceName,
|
||||
env,
|
||||
logger,
|
||||
fn,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
platform: 'ios' | 'android';
|
||||
sourceDeviceIdentifier: IosSimulatorName | AndroidVirtualDeviceName;
|
||||
localDeviceName: IosSimulatorName | AndroidVirtualDeviceName;
|
||||
fn: ({
|
||||
deviceIdentifier,
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
|
||||
}) => Promise<TResult>;
|
||||
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
|
||||
// Clone and start the device
|
||||
|
||||
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
logger.info(`Cloning iOS Simulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
|
||||
await IosSimulatorUtils.cloneAsync({
|
||||
sourceDeviceIdentifier: sourceDeviceIdentifier as IosSimulatorName,
|
||||
destinationDeviceName: localDeviceName as IosSimulatorName,
|
||||
env,
|
||||
});
|
||||
logger.info(`Starting iOS Simulator ${localDeviceName}...`);
|
||||
const { udid } = await IosSimulatorUtils.startAsync({
|
||||
deviceIdentifier: localDeviceName as IosSimulatorName,
|
||||
env,
|
||||
});
|
||||
logger.info(`Waiting for iOS Simulator ${localDeviceName} to be ready...`);
|
||||
await IosSimulatorUtils.waitForReadyAsync({
|
||||
udid,
|
||||
env,
|
||||
});
|
||||
localDeviceIdentifier = localDeviceName as IosSimulatorName;
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
logger.info(`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName}...`);
|
||||
await AndroidEmulatorUtils.cloneAsync({
|
||||
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
|
||||
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(`Starting Android Emulator ${localDeviceName}...`);
|
||||
const { serialId } = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
env,
|
||||
});
|
||||
localDeviceIdentifier = serialId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Run the function
|
||||
|
||||
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
|
||||
|
||||
// Stop the device
|
||||
|
||||
let logsResult: Result<{ outputPath: string }> | null = null;
|
||||
|
||||
try {
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
logger.info(`Collecting logs from ${localDeviceName}...`);
|
||||
logsResult = await asyncResult(
|
||||
IosSimulatorUtils.collectLogsAsync({
|
||||
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
|
||||
env,
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Cleaning up ${localDeviceName}...`);
|
||||
await IosSimulatorUtils.deleteAsync({
|
||||
deviceIdentifier: localDeviceIdentifier as IosSimulatorName,
|
||||
env,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
logger.info(`Collecting logs from ${localDeviceName}...`);
|
||||
logsResult = await asyncResult(
|
||||
AndroidEmulatorUtils.collectLogsAsync({
|
||||
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
|
||||
env,
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Cleaning up ${localDeviceName}...`);
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
|
||||
env,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error cleaning up device: ${err}`);
|
||||
}
|
||||
|
||||
return { fnResult: fnResult.enforceValue(), logsResult };
|
||||
}
|
||||
|
||||
/** Runs provided `fn` function, optionally wrapping it with starting and stopping screen recording. */
|
||||
async function maybeWithScreenRecordingAsync<TResult>({
|
||||
shouldRecord,
|
||||
platform,
|
||||
deviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
fn,
|
||||
}: {
|
||||
// As weird as it is, it's more convenient to have this function like `maybeWith...`
|
||||
// than "withScreenRecordingAsync" and `withScreenRecordingAsync(fn)` vs `fn` in the caller.
|
||||
shouldRecord: boolean;
|
||||
platform: 'ios' | 'android';
|
||||
deviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
fn: () => Promise<TResult>;
|
||||
}): Promise<{ fnResult: Result<TResult>; recordingResult: Result<string | null> }> {
|
||||
if (!shouldRecord) {
|
||||
return { fnResult: await asyncResult(fn()), recordingResult: result(null) };
|
||||
}
|
||||
|
||||
let recordingResult: Result<{
|
||||
recordingSpawn: SpawnPromise<SpawnResult>;
|
||||
outputPath?: string;
|
||||
}>;
|
||||
|
||||
// Start screen recording
|
||||
|
||||
logger.info(`Starting screen recording on ${deviceIdentifier}...`);
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
recordingResult = await asyncResult(
|
||||
IosSimulatorUtils.startScreenRecordingAsync({
|
||||
deviceIdentifier: deviceIdentifier as IosSimulatorName,
|
||||
env,
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'android': {
|
||||
recordingResult = await asyncResult(
|
||||
AndroidEmulatorUtils.startScreenRecordingAsync({
|
||||
serialId: deviceIdentifier as AndroidDeviceSerialId,
|
||||
env,
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recordingResult.ok) {
|
||||
logger.warn('Failed to start screen recording.', recordingResult.reason);
|
||||
}
|
||||
|
||||
// Run the function
|
||||
|
||||
const fnResult = await asyncResult(fn());
|
||||
|
||||
// If recording failed there's nothing to stop, so we return the results
|
||||
|
||||
if (!recordingResult.ok) {
|
||||
return { fnResult, recordingResult: result(recordingResult.reason) };
|
||||
}
|
||||
|
||||
// If recording started, finish it
|
||||
|
||||
try {
|
||||
logger.info(`Stopping screen recording on ${deviceIdentifier}...`);
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
await IosSimulatorUtils.stopScreenRecordingAsync({
|
||||
recordingSpawn: recordingResult.value.recordingSpawn,
|
||||
});
|
||||
return {
|
||||
fnResult,
|
||||
// We know outputPath is defined, because startIosScreenRecording() should have filled it.
|
||||
recordingResult: result(recordingResult.value.outputPath!),
|
||||
};
|
||||
}
|
||||
case 'android': {
|
||||
const { outputPath } = await AndroidEmulatorUtils.stopScreenRecordingAsync({
|
||||
serialId: deviceIdentifier as AndroidDeviceSerialId,
|
||||
recordingSpawn: recordingResult.value.recordingSpawn,
|
||||
env,
|
||||
});
|
||||
return { fnResult, recordingResult: result(outputPath) };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to stop screen recording.', err);
|
||||
|
||||
return { fnResult, recordingResult: result(err as Error) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface MaestroFlowResult {
|
||||
name: string;
|
||||
path: string;
|
||||
status: 'passed' | 'failed';
|
||||
errorMessage: string | null;
|
||||
duration: number; // milliseconds
|
||||
retryCount: number;
|
||||
tags: string[];
|
||||
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}$/;
|
||||
|
||||
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';
|
||||
duration: number; // milliseconds
|
||||
errorMessage: string | null;
|
||||
tags: string[];
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
const xmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
// Ensure single-element arrays are always arrays
|
||||
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
||||
});
|
||||
|
||||
// Internal helper — not exported. Parses a single JUnit XML file.
|
||||
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const parsed = xmlParser.parse(content);
|
||||
|
||||
const testsuites = parsed?.testsuites?.testsuite;
|
||||
if (!Array.isArray(testsuites)) {
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const suite of testsuites) {
|
||||
const testcases = suite?.testcase;
|
||||
if (!Array.isArray(testcases)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const tc of testcases) {
|
||||
const name = tc['@_name'];
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timeStr = tc['@_time'];
|
||||
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
|
||||
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
|
||||
|
||||
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
|
||||
const failureText =
|
||||
tc.failure != null
|
||||
? typeof tc.failure === 'string'
|
||||
? tc.failure
|
||||
: (tc.failure?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorText =
|
||||
tc.error != null
|
||||
? typeof tc.error === 'string'
|
||||
? tc.error
|
||||
: (tc.error?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorMessage: string | null = failureText ?? errorText ?? null;
|
||||
|
||||
const rawProperties: { '@_name': string; '@_value': string }[] =
|
||||
tc.properties?.property ?? [];
|
||||
const properties: Record<string, string> = {};
|
||||
for (const prop of rawProperties) {
|
||||
const propName = prop['@_name'];
|
||||
const value = prop['@_value'];
|
||||
if (typeof propName !== 'string' || typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
properties[propName] = value;
|
||||
}
|
||||
|
||||
const tagsValue = properties['tags'];
|
||||
const tags: string[] = tagsValue
|
||||
? tagsValue
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
delete properties['tags'];
|
||||
|
||||
results.push({ name, status, duration, errorMessage, tags, properties });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed XML files
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(junitDirectory);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const xmlFiles = entries.filter(f => f.endsWith('.xml'));
|
||||
if (xmlFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
for (const xmlFile of xmlFiles) {
|
||||
results.push(...(await parseJUnitFile(path.join(junitDirectory, xmlFile))));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
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
|
||||
): Promise<MaestroFlowResult[]> {
|
||||
// 1. Parse JUnit XML files, tracking which file each result came from
|
||||
let junitEntries: string[];
|
||||
try {
|
||||
junitEntries = await fs.readdir(junitDirectory);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
|
||||
if (xmlFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
interface JUnitResultWithSource {
|
||||
result: JUnitTestCaseResult;
|
||||
sourceFile: string;
|
||||
}
|
||||
|
||||
const junitResultsWithSource: JUnitResultWithSource[] = [];
|
||||
for (const xmlFile of xmlFiles) {
|
||||
const fileResults = await parseJUnitFile(path.join(junitDirectory, xmlFile));
|
||||
for (const result of fileResults) {
|
||||
junitResultsWithSource.push({ result, sourceFile: xmlFile });
|
||||
}
|
||||
}
|
||||
|
||||
if (junitResultsWithSource.length === 0) {
|
||||
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[] = [];
|
||||
|
||||
// Parse attempt index from filename pattern: *-attempt-N.*
|
||||
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
||||
|
||||
// Group results by flow name
|
||||
const resultsByName = new Map<string, JUnitResultWithSource[]>();
|
||||
for (const entry of junitResultsWithSource) {
|
||||
const group = resultsByName.get(entry.result.name) ?? [];
|
||||
group.push(entry);
|
||||
resultsByName.set(entry.result.name, group);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
results.push({
|
||||
name: flowName,
|
||||
path: relativePath,
|
||||
status: result.status,
|
||||
errorMessage: result.errorMessage,
|
||||
duration: result.duration,
|
||||
retryCount,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
@@ -51,10 +51,7 @@ export function createReadIpaInfoBuildFunction(): BuildFunction {
|
||||
const ipaPathInput = z.string().parse(inputs.ipa_path.value);
|
||||
const ipaPath = path.resolve(stepCtx.workingDirectory, ipaPathInput);
|
||||
if (!(await fs.pathExists(ipaPath))) {
|
||||
throw new UserFacingError(
|
||||
'EAS_READ_IPA_INFO_FILE_NOT_FOUND',
|
||||
`IPA file not found: ${ipaPath}`
|
||||
);
|
||||
throw new UserError('EAS_READ_IPA_INFO_FILE_NOT_FOUND', `IPA file not found: ${ipaPath}`);
|
||||
}
|
||||
|
||||
const ipaInfo = await readIpaInfoAsync(ipaPath);
|
||||
@@ -72,7 +69,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
|
||||
|
||||
const bundleIdentifier = infoPlist.CFBundleIdentifier;
|
||||
if (typeof bundleIdentifier !== 'string') {
|
||||
throw new UserFacingError(
|
||||
throw new UserError(
|
||||
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
|
||||
'Failed to read IPA info: Missing or invalid CFBundleIdentifier in Info.plist'
|
||||
);
|
||||
@@ -80,7 +77,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
|
||||
|
||||
const bundleShortVersion = infoPlist.CFBundleShortVersionString;
|
||||
if (typeof bundleShortVersion !== 'string') {
|
||||
throw new UserFacingError(
|
||||
throw new UserError(
|
||||
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
|
||||
'Failed to read IPA info: Missing or invalid CFBundleShortVersionString in Info.plist'
|
||||
);
|
||||
@@ -88,7 +85,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
|
||||
|
||||
const bundleVersion = infoPlist.CFBundleVersion;
|
||||
if (typeof bundleVersion !== 'string') {
|
||||
throw new UserFacingError(
|
||||
throw new UserError(
|
||||
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
|
||||
'Failed to read IPA info: Missing or invalid CFBundleVersion in Info.plist'
|
||||
);
|
||||
@@ -100,11 +97,11 @@ export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
|
||||
bundleVersion,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UserFacingError) {
|
||||
if (error instanceof UserError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new UserFacingError(
|
||||
throw new UserError(
|
||||
'EAS_READ_IPA_INFO_FAILED',
|
||||
`Failed to read IPA info: ${(error as Error).message}`,
|
||||
{ cause: error }
|
||||
@@ -118,10 +115,7 @@ function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
|
||||
const parsedBinaryPlists = bplistParser.parseBuffer(data);
|
||||
const parsedBinaryPlist = parsedBinaryPlists[0];
|
||||
if (!parsedBinaryPlist || typeof parsedBinaryPlist !== 'object') {
|
||||
throw new UserFacingError(
|
||||
'EAS_READ_IPA_INFO_INVALID_BINARY_PLIST',
|
||||
'Invalid binary plist in IPA'
|
||||
);
|
||||
throw new UserError('EAS_READ_IPA_INFO_INVALID_BINARY_PLIST', 'Invalid binary plist in IPA');
|
||||
}
|
||||
return parsedBinaryPlist as Record<string, unknown>;
|
||||
}
|
||||
@@ -135,7 +129,7 @@ async function readInfoPlistBufferFromIpaAsync(ipaPath: string): Promise<Buffer>
|
||||
const entries = Object.values(await zip.entries());
|
||||
const infoPlistEntry = entries.find(entry => INFO_PLIST_PATH_REGEXP.test(entry.name));
|
||||
if (!infoPlistEntry) {
|
||||
throw new UserFacingError(
|
||||
throw new UserError(
|
||||
'EAS_READ_IPA_INFO_INFO_PLIST_NOT_FOUND',
|
||||
`Failed to read IPA info: Could not find Info.plist in ${ipaPath}`
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import resolveFrom from 'resolve-from';
|
||||
|
||||
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
|
||||
import IosCredentialsManager from '../utils/ios/credentials/manager';
|
||||
import { resolvePackageVersionAsync } from '../../utils/packageManager';
|
||||
|
||||
export function createRepackBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -51,6 +52,11 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'js_bundle_only',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'repack_version',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
@@ -82,7 +88,10 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
);
|
||||
}
|
||||
|
||||
const repackSpawnAsync = createSpawnAsyncStepAdapter({ verbose, logger: stepsCtx.logger });
|
||||
const repackSpawnAsync = createSpawnAsyncStepAdapter({
|
||||
verbose,
|
||||
logger: stepsCtx.logger,
|
||||
});
|
||||
|
||||
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-`));
|
||||
const workingDirectory = path.join(tmpDir, 'working-directory');
|
||||
@@ -98,13 +107,20 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
sourcemapOutput: undefined,
|
||||
}
|
||||
: undefined;
|
||||
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
|
||||
|
||||
const resolvedRepackVersion =
|
||||
(await resolvePackageVersionAsync({
|
||||
logger: stepsCtx.logger,
|
||||
packageName: inputs.repack_package.value as string,
|
||||
distTag: inputs.repack_version.value as string,
|
||||
})) ?? (inputs.repack_version.value as string);
|
||||
stepsCtx.logger.info(
|
||||
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value}`
|
||||
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
|
||||
);
|
||||
const repackApp = await installAndImportRepackAsync({
|
||||
packageName: inputs.repack_package.value as string,
|
||||
version: inputs.repack_version.value as string,
|
||||
version: resolvedRepackVersion,
|
||||
});
|
||||
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
|
||||
|
||||
@@ -118,6 +134,7 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
outputPath,
|
||||
workingDirectory,
|
||||
exportEmbedOptions,
|
||||
jsBundleOnly,
|
||||
iosSigningOptions: await resolveIosSigningOptionsAsync({
|
||||
job: stepsCtx.global.staticContext.job,
|
||||
logger: stepsCtx.logger,
|
||||
@@ -146,6 +163,7 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
outputPath,
|
||||
workingDirectory,
|
||||
exportEmbedOptions,
|
||||
jsBundleOnly,
|
||||
androidSigningOptions,
|
||||
logger: stepsCtx.logger,
|
||||
spawnAsync: repackSpawnAsync,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
const CREATE_MUTATION = graphql(`
|
||||
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
|
||||
workflowDeviceTestCaseResult {
|
||||
createWorkflowDeviceTestCaseResults(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FLOW_STATUS_TO_TEST_CASE_RESULT_STATUS: Record<string, 'PASSED' | 'FAILED' | undefined> = {
|
||||
passed: 'PASSED',
|
||||
failed: 'FAILED',
|
||||
} satisfies Record<MaestroFlowResult['status'], 'PASSED' | 'FAILED'>;
|
||||
|
||||
export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'report_maestro_test_results',
|
||||
name: 'Report Maestro Test Results',
|
||||
__metricsId: 'eas/report_maestro_test_results',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'junit_report_directory',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
defaultValue: '${{ env.HOME }}/.maestro/tests',
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'tests_directory',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
defaultValue: '${{ env.HOME }}/.maestro/tests',
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs }) => {
|
||||
const { logger } = stepsCtx;
|
||||
const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID;
|
||||
if (!workflowJobId) {
|
||||
logger.info('Not running in a workflow job, skipping test results report');
|
||||
return;
|
||||
}
|
||||
const junitDirectory = (inputs.junit_report_directory.value as string | undefined) ?? '';
|
||||
if (!junitDirectory) {
|
||||
logger.info('No JUnit directory provided, skipping test results report');
|
||||
return;
|
||||
}
|
||||
const testsDirectory = inputs.tests_directory.value as string;
|
||||
|
||||
try {
|
||||
const flowResults = await parseMaestroResults(
|
||||
junitDirectory,
|
||||
testsDirectory,
|
||||
stepsCtx.workingDirectory
|
||||
);
|
||||
if (flowResults.length === 0) {
|
||||
logger.info('No maestro test results found, skipping report');
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect truly conflicting results: same (name, retryCount) pair means different flow files
|
||||
// share the same name (Maestro config override), which we can't disambiguate.
|
||||
// Same name with different retryCount is expected (per-attempt results from retries).
|
||||
const seen = new Set<string>();
|
||||
const conflicting = new Set<string>();
|
||||
for (const r of flowResults) {
|
||||
const key = `${r.name}:${r.retryCount}`;
|
||||
if (seen.has(key)) {
|
||||
conflicting.add(r.name);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
if (conflicting.size > 0) {
|
||||
logger.error(
|
||||
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
|
||||
', '
|
||||
)}. Skipping report. Ensure each Maestro flow has a unique name.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const testCaseResults = flowResults.flatMap(f => {
|
||||
const status = FLOW_STATUS_TO_TEST_CASE_RESULT_STATUS[f.status];
|
||||
if (!status) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: f.name,
|
||||
path: f.path,
|
||||
status,
|
||||
errorMessage: f.errorMessage,
|
||||
duration: f.duration,
|
||||
retryCount: f.retryCount,
|
||||
tags: f.tags,
|
||||
properties: f.properties,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_MUTATION, {
|
||||
input: {
|
||||
workflowJobId,
|
||||
testCaseResults,
|
||||
},
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
logger.error({ error: result.error }, 'GraphQL error creating test case results');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Reported ${testCaseResults.length} test case result(s).`);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Failed to create test case results');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
BuildStepInputValueTypeName,
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import fs from 'fs';
|
||||
import nullthrows from 'nullthrows';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { sendCcacheStatsAsync } from './ccacheStats';
|
||||
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
|
||||
@@ -16,6 +19,7 @@ import {
|
||||
generateDefaultBuildCacheKeyAsync,
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
import { TurtleFetchError } from '../../utils/turtleFetch';
|
||||
|
||||
export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
@@ -50,6 +54,15 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
|
||||
if (platform === Platform.ANDROID) {
|
||||
await restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -176,6 +189,106 @@ export async function restoreCcacheAsync({
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const gradlePropertiesPath = path.join(workingDirectory, 'android', 'gradle.properties');
|
||||
const gradlePropertiesContent = await fs.promises.readFile(gradlePropertiesPath, 'utf-8');
|
||||
await fs.promises.writeFile(
|
||||
gradlePropertiesPath,
|
||||
`${gradlePropertiesContent}\n\norg.gradle.caching=true\n`
|
||||
);
|
||||
|
||||
// Configure cache cleanup via init script (works with both Gradle 8 and 9,
|
||||
// org.gradle.cache.cleanup property was removed in Gradle 9)
|
||||
const initScriptDir = path.join(os.homedir(), '.gradle', 'init.d');
|
||||
await fs.promises.mkdir(initScriptDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
|
||||
[
|
||||
'def cacheDir = new File(System.getProperty("user.home"), ".gradle/caches/build-cache-1")',
|
||||
'def countBefore = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
|
||||
'println "[EAS] Gradle build cache entries before cleanup: ${countBefore}"',
|
||||
'',
|
||||
'beforeSettings { settings ->',
|
||||
' try {',
|
||||
' settings.caches {',
|
||||
' cleanup = Cleanup.ALWAYS',
|
||||
' buildCache {',
|
||||
' setRemoveUnusedEntriesAfterDays(3)',
|
||||
' }',
|
||||
' }',
|
||||
' println "[EAS] Configured Gradle cache cleanup via init script"',
|
||||
' } catch (Exception e) {',
|
||||
' println "[EAS] Failed to configure cache cleanup: ${e.message}"',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'gradle.buildFinished {',
|
||||
' def countAfter = cacheDir.exists() ? cacheDir.listFiles()?.length ?: 0 : 0',
|
||||
' println "[EAS] Gradle build cache entries after build: ${countAfter} (was ${countBefore})"',
|
||||
'}',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
|
||||
logger.info(`Restoring Gradle cache key: ${cacheKey}`);
|
||||
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [buildCachePath],
|
||||
key: cacheKey,
|
||||
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
|
||||
platform: Platform.ANDROID,
|
||||
});
|
||||
|
||||
await fs.promises.mkdir(gradleCachesPath, { recursive: true });
|
||||
await decompressCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory: gradleCachesPath,
|
||||
verbose: env.EXPO_DEBUG === '1',
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Gradle cache restored to ${gradleCachesPath} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TurtleFetchError && err.response?.status === 404) {
|
||||
logger.info('No Gradle cache found for this key');
|
||||
} else {
|
||||
logger.warn('Failed to restore Gradle cache: ', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function cacheStatsAsync({
|
||||
logger,
|
||||
env,
|
||||
|
||||
@@ -58,11 +58,6 @@ export function createRestoreCacheFunction(): BuildFunction {
|
||||
const { logger } = stepsCtx;
|
||||
|
||||
try {
|
||||
if (stepsCtx.global.staticContext.job.platform) {
|
||||
logger.error('Caches are not supported in build jobs yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = z
|
||||
.array(z.string())
|
||||
.parse(((inputs.path.value ?? '') as string).split(/[\r\n]+/))
|
||||
|
||||
@@ -10,8 +10,13 @@ import {
|
||||
import fs from 'fs';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
|
||||
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
|
||||
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -46,6 +51,15 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
|
||||
if (platform === Platform.ANDROID) {
|
||||
await saveGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -135,3 +149,66 @@ export async function saveCcacheAsync({
|
||||
logger.error({ err }, 'Failed to save cache');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
|
||||
try {
|
||||
await fs.promises.access(buildCachePath);
|
||||
} catch {
|
||||
logger.warn('No Gradle build cache found, skipping save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cacheKey = await generateGradleCacheKeyAsync(workingDirectory);
|
||||
logger.info(`Saving Gradle cache key: ${cacheKey}`);
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
|
||||
logger.info('Compressing Gradle build cache...');
|
||||
const { archivePath } = await compressCacheAsync({
|
||||
paths: [buildCachePath],
|
||||
workingDirectory: gradleCachesPath,
|
||||
verbose: env.EXPO_DEBUG === '1',
|
||||
logger,
|
||||
});
|
||||
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
logger.info(`Gradle cache archive size: ${formatBytes(size)}`);
|
||||
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key: cacheKey,
|
||||
paths: [buildCachePath],
|
||||
size,
|
||||
platform: Platform.ANDROID,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save Gradle cache');
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
|
||||
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
|
||||
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
|
||||
|
||||
export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
@@ -54,6 +57,15 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
const systemImagePackage = `${inputs.system_image_package.value}`;
|
||||
// We can cast because allowedValueTypeName validated this is a string.
|
||||
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
|
||||
const shouldAdjustAnimationScale =
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
|
||||
|
||||
if (!shouldAdjustAnimationScale) {
|
||||
logger.info(
|
||||
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Making sure system image is installed');
|
||||
await retryAsync(
|
||||
@@ -72,25 +84,85 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Creating emulator device');
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
let emulatorPromise = null;
|
||||
let serialId = null;
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
const attempt = attemptCount + 1;
|
||||
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
|
||||
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
|
||||
let attemptSerialId = null;
|
||||
|
||||
logger.info('Starting emulator device');
|
||||
const { emulatorPromise, serialId } = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
env,
|
||||
serialId,
|
||||
});
|
||||
logger.info(`${deviceName} is ready.`);
|
||||
try {
|
||||
logger.info(`Creating emulator device${attemptSuffix}.`);
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`Starting emulator device${attemptSuffix}.`);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
attemptSerialId = startResult.serialId;
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
env,
|
||||
serialId: attemptSerialId,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
if (shouldAdjustAnimationScale) {
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
env,
|
||||
logger,
|
||||
serialId: attemptSerialId,
|
||||
});
|
||||
}
|
||||
logger.info(`${deviceName} is ready.`);
|
||||
|
||||
serialId = attemptSerialId;
|
||||
emulatorPromise = startResult.emulatorPromise;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`
|
||||
);
|
||||
try {
|
||||
if (attemptSerialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: attemptSerialId,
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: ANDROID_STARTUP_RETRIES_COUNT,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!serialId || !emulatorPromise) {
|
||||
throw new Error(`Failed to start emulator ${deviceName}.`);
|
||||
}
|
||||
|
||||
const count = Number(inputs.count.value ?? 1);
|
||||
if (count > 1) {
|
||||
@@ -105,27 +177,77 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cloneIdentifier = `eas-simulator-${i + 1}` as AndroidVirtualDeviceName;
|
||||
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}...`);
|
||||
await AndroidEmulatorUtils.cloneAsync({
|
||||
sourceDeviceName: deviceName,
|
||||
destinationDeviceName: cloneIdentifier,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
const attempt = attemptCount + 1;
|
||||
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
|
||||
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
|
||||
let cloneSerialId = null;
|
||||
try {
|
||||
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}${attemptSuffix}.`);
|
||||
await AndroidEmulatorUtils.cloneAsync({
|
||||
sourceDeviceName: deviceName,
|
||||
destinationDeviceName: cloneIdentifier,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Starting emulator device');
|
||||
const { serialId } = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
});
|
||||
logger.info(`Starting emulator device${attemptSuffix}.`);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
});
|
||||
cloneSerialId = startResult.serialId;
|
||||
|
||||
logger.info('Waiting for emulator to become ready');
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
env,
|
||||
});
|
||||
logger.info('Waiting for emulator to become ready');
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId: cloneSerialId,
|
||||
env,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
if (shouldAdjustAnimationScale) {
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
env,
|
||||
logger,
|
||||
serialId: cloneSerialId,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`${cloneIdentifier} is ready.`);
|
||||
logger.info(`${cloneIdentifier} is ready.`);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`${cloneIdentifier} failed to start on attempt ${attempt}/${maxAttempts}.`
|
||||
);
|
||||
try {
|
||||
if (cloneSerialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: cloneSerialId,
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
logger.warn({ err: cleanupErr }, `Failed to clean up ${cloneIdentifier}.`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: ANDROID_STARTUP_RETRIES_COUNT,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
@@ -12,6 +14,8 @@ import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient';
|
||||
import { AscApiUtils } from '../utils/ios/AscApiUtils';
|
||||
import { readIpaInfoAsync } from './readIpaInfo';
|
||||
|
||||
export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -101,34 +105,21 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
|
||||
const client = new AscApiClient({ token, logger: stepsCtx.logger });
|
||||
|
||||
stepsCtx.logger.info('Reading App information...');
|
||||
const appResponse = await client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{ 'fields[apps]': ['bundleId', 'name'] },
|
||||
{ id: appleAppIdentifier }
|
||||
);
|
||||
stepsCtx.logger.info(
|
||||
`Uploading Build to "${appResponse.data.attributes.name}" (${appResponse.data.attributes.bundleId})...`
|
||||
`Reading App information for Apple app identifier: ${appleAppIdentifier}...`
|
||||
);
|
||||
const appResponse = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
|
||||
const ascAppBundleIdentifier = appResponse.data.attributes.bundleId;
|
||||
stepsCtx.logger.info(
|
||||
`Uploading Build to "${appResponse.data.attributes.name}" (${ascAppBundleIdentifier})...`
|
||||
);
|
||||
|
||||
stepsCtx.logger.info('Creating Build Upload...');
|
||||
const buildUploadResponse = await client.postAsync('/v1/buildUploads', {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
attributes: {
|
||||
platform: 'IOS',
|
||||
cfBundleShortVersionString: bundleShortVersion,
|
||||
cfBundleVersion: bundleVersion,
|
||||
},
|
||||
relationships: {
|
||||
app: {
|
||||
data: {
|
||||
type: 'apps',
|
||||
id: appleAppIdentifier,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
const buildUploadResponse = await AscApiUtils.createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier,
|
||||
bundleShortVersion,
|
||||
bundleVersion,
|
||||
});
|
||||
|
||||
const buildUploadId = buildUploadResponse.data.id;
|
||||
@@ -234,6 +225,9 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
|
||||
stepsCtx.logger.info('Checking build upload status...');
|
||||
const waitingForBuildStartedAt = Date.now();
|
||||
const waitingLogIntervalMs = 10 * 1000;
|
||||
let lastWaitLogTime = 0;
|
||||
let lastWaitLogState: string | null = null;
|
||||
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
|
||||
const {
|
||||
data: {
|
||||
@@ -246,7 +240,14 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
);
|
||||
|
||||
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
|
||||
stepsCtx.logger.info(`Waiting for build upload to complete... (status = ${state.state})`);
|
||||
const now = Date.now();
|
||||
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
|
||||
stepsCtx.logger.info(
|
||||
`Waiting for build upload to complete... (status = ${state.state})`
|
||||
);
|
||||
lastWaitLogTime = now;
|
||||
lastWaitLogState = state.state;
|
||||
}
|
||||
await setTimeout(2000);
|
||||
continue;
|
||||
}
|
||||
@@ -265,6 +266,74 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
}
|
||||
|
||||
if (state.state === 'FAILED') {
|
||||
if (isSdkVersionIssueError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
|
||||
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
|
||||
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
|
||||
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isInvalidBundleIdentifierError(errors)) {
|
||||
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
|
||||
const ipaBundleIdentifier = ipaInfoResult.ok
|
||||
? ipaInfoResult.value.bundleIdentifier
|
||||
: null;
|
||||
|
||||
let visibleAppsSummary: string | null = null;
|
||||
try {
|
||||
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
|
||||
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
|
||||
} catch {
|
||||
// Ok to fail, this is just trying to be helpful.
|
||||
}
|
||||
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
|
||||
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
|
||||
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
|
||||
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
|
||||
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
|
||||
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
|
||||
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
|
||||
'\n\nOther App Store Connect apps visible to this API key:\n' +
|
||||
(visibleAppsSummary ?? ' (unable to retrieve)'),
|
||||
{
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isMissingPurposeStringError(errors)) {
|
||||
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
|
||||
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
|
||||
`${
|
||||
missingUsageDescriptionKeys.length > 0
|
||||
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
|
||||
'\n- '
|
||||
)}\n\n`
|
||||
: ''
|
||||
}` +
|
||||
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
|
||||
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
|
||||
'If you do not use CNG, update your app target Info.plist directly.',
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isClosedVersionTrainError(errors)) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
|
||||
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
|
||||
'This usually means the version train is closed or lower than a previously approved version. ' +
|
||||
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
|
||||
);
|
||||
}
|
||||
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
|
||||
} else if (state.state === 'COMPLETE') {
|
||||
stepsCtx.logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
|
||||
@@ -279,6 +348,40 @@ function itemizeMessages(messages: { description: string; code: string }[]): str
|
||||
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
|
||||
}
|
||||
|
||||
export function isClosedVersionTrainError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 &&
|
||||
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
|
||||
);
|
||||
}
|
||||
|
||||
export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90725');
|
||||
}
|
||||
|
||||
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
|
||||
);
|
||||
}
|
||||
|
||||
export function isMissingPurposeStringError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90683');
|
||||
}
|
||||
|
||||
export function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
|
||||
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
|
||||
const keys = new Set<string>();
|
||||
for (const message of messages) {
|
||||
const matches = message.description.matchAll(usageDescriptionKeyRegex);
|
||||
for (const match of matches) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
async function uploadChunksAsync({
|
||||
uploadOperations,
|
||||
ipaPath,
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function runGradleCommand({
|
||||
return line;
|
||||
}
|
||||
},
|
||||
env: { ...env, ...extraEnv },
|
||||
env: { ...env, ...extraEnv, LC_ALL: 'C.UTF-8' },
|
||||
});
|
||||
if (env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
|
||||
adjustOOMScore(spawnPromise, logger);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AndroidConfig } from '@expo/config-plugins';
|
||||
import { Android } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { templateString } from '@expo/template-file';
|
||||
import fs from 'fs-extra';
|
||||
@@ -6,6 +7,7 @@ import path from 'path';
|
||||
|
||||
import { EasBuildConfigureVersionGradleTemplate } from '../../../templates/EasBuildConfigureVersionGradle';
|
||||
import { EasBuildInjectAndroidCredentialsGradle } from '../../../templates/EasBuildInjectAndroidCredentialsGradle';
|
||||
import { BuildContext } from '../../../context';
|
||||
|
||||
const APPLY_EAS_BUILD_INJECT_CREDENTIALS_GRADLE_LINE =
|
||||
'apply from: "./eas-build-inject-android-credentials.gradle"';
|
||||
@@ -51,6 +53,26 @@ async function deleteEasBuildConfigureVersionGradle(workingDir: string): Promise
|
||||
await fs.remove(targetPath);
|
||||
}
|
||||
|
||||
let legacyEasBuildGradleWarningEmitted = false;
|
||||
|
||||
export async function warnIfLegacyEasBuildGradleExists(
|
||||
ctx: BuildContext<Android.Job>
|
||||
): Promise<void> {
|
||||
const legacyGradlePath = getLegacyEasBuildGradlePath(ctx.getReactNativeProjectDirectory());
|
||||
if (
|
||||
(await fs.pathExists(legacyGradlePath)) &&
|
||||
(process.env.NODE_ENV === 'test' || !legacyEasBuildGradleWarningEmitted)
|
||||
) {
|
||||
legacyEasBuildGradleWarningEmitted = true;
|
||||
ctx.logger.warn('eas-build.gradle script is deprecated, please remove it from your project.');
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
}
|
||||
}
|
||||
|
||||
function getLegacyEasBuildGradlePath(projectRoot: string): string {
|
||||
return path.join(projectRoot, 'android/app/eas-build.gradle');
|
||||
}
|
||||
|
||||
function getEasBuildInjectCredentialsGradlePath(workingDir: string): string {
|
||||
return path.join(workingDir, 'android/app/eas-build-inject-android-credentials.gradle');
|
||||
}
|
||||
@@ -113,7 +135,7 @@ function hasLine(haystack: string, needle: string): boolean {
|
||||
haystack
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split('\n')
|
||||
// Check for both single and double quotes
|
||||
// Check for both single and double quotes.
|
||||
.some(line => line === needle || line === needle.replace(/"/g, "'"))
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,43 @@ type ApiSchema = {
|
||||
};
|
||||
};
|
||||
|
||||
const AscErrorResponseSchema = z.object({
|
||||
errors: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
detail: z.string().optional(),
|
||||
source: z.unknown().optional(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
|
||||
const GetApi = {
|
||||
'/v1/apps': {
|
||||
path: z.object({}),
|
||||
request: z.object({
|
||||
'fields[apps]': z.array(z.enum(['bundleId', 'name'])).refine(opts => {
|
||||
return opts.includes('bundleId') && opts.includes('name');
|
||||
}),
|
||||
limit: z.number().int().min(1).max(200).optional(),
|
||||
}),
|
||||
response: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
type: z.literal('apps'),
|
||||
id: z.string(),
|
||||
attributes: z.object({
|
||||
bundleId: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
},
|
||||
'/v1/apps/:id': {
|
||||
path: z.object({
|
||||
id: z.string(),
|
||||
@@ -222,6 +258,13 @@ const PatchApi = {
|
||||
},
|
||||
} satisfies ApiSchema;
|
||||
|
||||
export type AscApiClientGetApi = {
|
||||
[Path in keyof typeof GetApi]: {
|
||||
request: z.input<(typeof GetApi)[Path]['request']>;
|
||||
response: z.output<(typeof GetApi)[Path]['response']>;
|
||||
};
|
||||
};
|
||||
|
||||
export type AscApiClientPostApi = {
|
||||
[Path in keyof typeof PostApi]: {
|
||||
request: z.input<(typeof PostApi)[Path]['request']>;
|
||||
@@ -236,6 +279,17 @@ export type AscApiClientPatchApi = {
|
||||
};
|
||||
};
|
||||
|
||||
export class AscApiRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly responseJson: z.output<typeof AscErrorResponseSchema>,
|
||||
options?: { cause?: unknown }
|
||||
) {
|
||||
super(message, { cause: options?.cause });
|
||||
}
|
||||
}
|
||||
|
||||
export class AscApiClient {
|
||||
private readonly baseUrl = 'https://api.appstoreconnect.apple.com';
|
||||
private readonly token: string;
|
||||
@@ -346,12 +400,31 @@ export class AscApiClient {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const parsedAscErrorResponse = await asyncResult(
|
||||
(async () => AscErrorResponseSchema.parse(JSON.parse(text)))()
|
||||
);
|
||||
if (parsedAscErrorResponse.ok) {
|
||||
throw new AscApiRequestError(
|
||||
`Unexpected response (${response.status}) from App Store Connect: ${text}`,
|
||||
response.status,
|
||||
parsedAscErrorResponse.value,
|
||||
{ cause: response }
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected response (${response.status}) from App Store Connect: ${text}`, {
|
||||
cause: response,
|
||||
});
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const text = await response.text();
|
||||
const parsedJson = await asyncResult((async () => JSON.parse(text))());
|
||||
if (!parsedJson.ok) {
|
||||
throw new Error(
|
||||
`Malformed JSON response from App Store Connect (${response.status}): ${text}`
|
||||
);
|
||||
}
|
||||
const json = parsedJson.value;
|
||||
|
||||
this.logger?.debug(`Response from App Store Connect: ${JSON.stringify(json, null, 2)}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
|
||||
import {
|
||||
AscApiClient,
|
||||
AscApiClientGetApi,
|
||||
AscApiClientPostApi,
|
||||
AscApiRequestError,
|
||||
} from './AscApiClient';
|
||||
|
||||
export namespace AscApiUtils {
|
||||
export async function getAppInfoAsync({
|
||||
client,
|
||||
appleAppIdentifier,
|
||||
}: {
|
||||
client: Pick<AscApiClient, 'getAsync'>;
|
||||
appleAppIdentifier: string;
|
||||
}): Promise<AscApiClientGetApi['/v1/apps/:id']['response']> {
|
||||
try {
|
||||
return await client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{ 'fields[apps]': ['bundleId', 'name'] },
|
||||
{ id: appleAppIdentifier }
|
||||
);
|
||||
} catch (error) {
|
||||
const notFoundErrors =
|
||||
error instanceof AscApiRequestError && error.status === 404
|
||||
? error.responseJson.errors
|
||||
: [];
|
||||
const isAppNotFoundError =
|
||||
notFoundErrors.length > 0 && notFoundErrors.every(item => item.code === 'NOT_FOUND');
|
||||
if (!isAppNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
let visibleAppsSummary: string | null = null;
|
||||
try {
|
||||
const apps = await AscApiUtils.getAppsAsync({ client, limit: 10 });
|
||||
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
|
||||
} catch {
|
||||
// Don't hide the original NOT_FOUND error with a secondary lookup failure.
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
|
||||
`App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
|
||||
'Verify the configured application identifier and that the App Store Connect API key has access to the application in the correct App Store Connect account.' +
|
||||
(visibleAppsSummary
|
||||
? `\n\nExample applications visible to this API key:\n${visibleAppsSummary}`
|
||||
: ''),
|
||||
{
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
cause: error,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier,
|
||||
bundleShortVersion,
|
||||
bundleVersion,
|
||||
}: {
|
||||
client: Pick<AscApiClient, 'postAsync'>;
|
||||
appleAppIdentifier: string;
|
||||
bundleShortVersion: string;
|
||||
bundleVersion: string;
|
||||
}): Promise<AscApiClientPostApi['/v1/buildUploads']['response']> {
|
||||
try {
|
||||
return await client.postAsync('/v1/buildUploads', {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
attributes: {
|
||||
platform: 'IOS',
|
||||
cfBundleShortVersionString: bundleShortVersion,
|
||||
cfBundleVersion: bundleVersion,
|
||||
},
|
||||
relationships: {
|
||||
app: {
|
||||
data: {
|
||||
type: 'apps',
|
||||
id: appleAppIdentifier,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errors =
|
||||
error instanceof AscApiRequestError && error.status === 409
|
||||
? error.responseJson.errors
|
||||
: [];
|
||||
const isDuplicateVersionError =
|
||||
errors.length > 0 &&
|
||||
errors.every(item => item.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE');
|
||||
|
||||
if (isDuplicateVersionError) {
|
||||
throw new UserError(
|
||||
'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
|
||||
`Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
|
||||
'App Store Connect requires unique build numbers within each app version (version train). ' +
|
||||
'Increment it by setting ios.buildNumber in app.json, or set "autoIncrement": true in eas.json (recommended). Then rebuild and resubmit.',
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
|
||||
cause: error,
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAppsAsync({
|
||||
client,
|
||||
limit,
|
||||
}: {
|
||||
client: Pick<AscApiClient, 'getAsync'>;
|
||||
limit?: number;
|
||||
}): Promise<AscApiClientGetApi['/v1/apps']['response']['data']> {
|
||||
const appsResponse = await client.getAsync('/v1/apps', {
|
||||
'fields[apps]': ['bundleId', 'name'],
|
||||
limit: limit ?? 10,
|
||||
});
|
||||
return appsResponse.data;
|
||||
}
|
||||
|
||||
export function formatAppsList(apps: AscApiClientGetApi['/v1/apps']['response']['data']): string {
|
||||
return (
|
||||
apps
|
||||
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
|
||||
.join('\n') || ' (none)'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import { AscApiClient } from '../AscApiClient';
|
||||
import { AscApiClient, AscApiRequestError } from '../AscApiClient';
|
||||
|
||||
// nock needs real fetch implementation
|
||||
jest.unmock('node-fetch');
|
||||
@@ -280,4 +280,54 @@ describe(AscApiClient, () => {
|
||||
});
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('throws AscApiRequestError for structured ASC error payload', async () => {
|
||||
const appId = '1491144534';
|
||||
const responseFixture = {
|
||||
errors: [
|
||||
{
|
||||
status: '409',
|
||||
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
|
||||
title:
|
||||
'The provided entity includes an attribute with a value that has already been used',
|
||||
detail: 'The bundle version must be higher than the previously uploaded version.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/apps/${appId}`)
|
||||
.query({ 'fields[apps]': 'bundleId,name' })
|
||||
.reply(409, responseFixture);
|
||||
|
||||
await expect(
|
||||
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
|
||||
).rejects.toBeInstanceOf(AscApiRequestError);
|
||||
});
|
||||
|
||||
it('throws regular Error for non-structured ASC error payload', async () => {
|
||||
const appId = '1491144534';
|
||||
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/apps/${appId}`)
|
||||
.query({ 'fields[apps]': 'bundleId,name' })
|
||||
.reply(503, 'Service unavailable');
|
||||
|
||||
await expect(
|
||||
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
|
||||
).rejects.not.toBeInstanceOf(AscApiRequestError);
|
||||
});
|
||||
|
||||
it('throws controlled error when success response body is not valid JSON', async () => {
|
||||
const appId = '1491144534';
|
||||
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/apps/${appId}`)
|
||||
.query({ 'fields[apps]': 'bundleId,name' })
|
||||
.reply(200, 'not-json');
|
||||
|
||||
await expect(
|
||||
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
|
||||
).rejects.toThrow('Malformed JSON response from App Store Connect (200): not-json');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { AscApiRequestError } from '../AscApiClient';
|
||||
import { AscApiUtils } from '../AscApiUtils';
|
||||
|
||||
describe('AscApiUtils', () => {
|
||||
describe('getAppInfoAsync', () => {
|
||||
it('returns app info when lookup succeeds', async () => {
|
||||
const response = {
|
||||
data: {
|
||||
type: 'apps',
|
||||
id: '1491144534',
|
||||
attributes: {
|
||||
name: 'Example App',
|
||||
bundleId: 'com.example.app',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const client = {
|
||||
getAsync: jest.fn().mockResolvedValue(response),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1491144534' })
|
||||
).resolves.toEqual(response);
|
||||
});
|
||||
|
||||
it('throws UserError with visible apps when app id is not found', async () => {
|
||||
const notFoundPayload = {
|
||||
errors: [
|
||||
{
|
||||
status: '404',
|
||||
code: 'NOT_FOUND',
|
||||
detail: "There is no resource of type 'apps' with id '1234567890'",
|
||||
},
|
||||
],
|
||||
};
|
||||
const notFoundError = new AscApiRequestError(
|
||||
'Unexpected response (404) from App Store Connect',
|
||||
404,
|
||||
notFoundPayload
|
||||
);
|
||||
const client = {
|
||||
getAsync: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(notFoundError)
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
type: 'apps',
|
||||
id: '1111111111',
|
||||
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
message: expect.stringMatching(
|
||||
/App Store Connect app for application identifier 1234567890 was not found[\s\S]*- Visible App \(com\.visible\.app\) \(ID: 1111111111\)/
|
||||
),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rethrows original not-found error when app-list lookup fails', async () => {
|
||||
const notFoundPayload = {
|
||||
errors: [
|
||||
{
|
||||
status: '404',
|
||||
code: 'NOT_FOUND',
|
||||
},
|
||||
],
|
||||
};
|
||||
const notFoundError = new AscApiRequestError(
|
||||
'Unexpected response (404) from App Store Connect',
|
||||
404,
|
||||
notFoundPayload
|
||||
);
|
||||
|
||||
const listingError = new Error('listing failed');
|
||||
const client = {
|
||||
getAsync: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(notFoundError)
|
||||
.mockRejectedValueOnce(listingError),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
|
||||
).rejects.toBe(notFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAppsList', () => {
|
||||
it('formats visible apps', () => {
|
||||
expect(
|
||||
AscApiUtils.formatAppsList([
|
||||
{
|
||||
type: 'apps',
|
||||
id: '1111111111',
|
||||
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
|
||||
},
|
||||
{
|
||||
type: 'apps',
|
||||
id: '2222222222',
|
||||
attributes: { name: 'Second App', bundleId: 'com.second.app' },
|
||||
},
|
||||
])
|
||||
).toBe(
|
||||
'- Visible App (com.visible.app) (ID: 1111111111)\n- Second App (com.second.app) (ID: 2222222222)'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a placeholder when no visible apps are found', () => {
|
||||
expect(AscApiUtils.formatAppsList([])).toBe(' (none)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBuildUploadAsync', () => {
|
||||
it('throws UserError when ASC duplicate version error is returned', async () => {
|
||||
const payload = {
|
||||
errors: [
|
||||
{
|
||||
status: '409',
|
||||
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
|
||||
detail: 'The bundle version must be higher than the previously uploaded version.',
|
||||
},
|
||||
],
|
||||
};
|
||||
const duplicateError = new AscApiRequestError(
|
||||
'Unexpected response (409) from App Store Connect',
|
||||
409,
|
||||
payload
|
||||
);
|
||||
|
||||
const client = {
|
||||
postAsync: jest.fn().mockRejectedValue(duplicateError),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
})
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
|
||||
message: expect.stringContaining('Increment Build Number'),
|
||||
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns response when upload initialization succeeds', async () => {
|
||||
const response = {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
id: 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const client = {
|
||||
postAsync: jest.fn().mockResolvedValue(response),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
})
|
||||
).resolves.toEqual(response);
|
||||
});
|
||||
|
||||
it('rethrows when error payload includes mixed error codes', async () => {
|
||||
const payload = {
|
||||
errors: [
|
||||
{
|
||||
status: '409',
|
||||
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
|
||||
},
|
||||
{
|
||||
status: '409',
|
||||
code: 'SOME_OTHER_ERROR',
|
||||
},
|
||||
],
|
||||
};
|
||||
const mixedError = new AscApiRequestError(
|
||||
'Unexpected response (409) from App Store Connect',
|
||||
409,
|
||||
payload
|
||||
);
|
||||
const client = {
|
||||
postAsync: jest.fn().mockRejectedValue(mixedError),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
})
|
||||
).rejects.toBe(mixedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ export default class ProvisioningProfile {
|
||||
|
||||
private readonly profilePath: string;
|
||||
private profileData?: ProvisioningProfileData;
|
||||
private developerCertificates: Buffer[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly profile: Buffer,
|
||||
@@ -73,11 +74,12 @@ export default class ProvisioningProfile {
|
||||
}
|
||||
|
||||
public verifyCertificate(fingerprint: string): void {
|
||||
const devCertFingerprint = this.genDerCertFingerprint();
|
||||
if (devCertFingerprint !== fingerprint) {
|
||||
const devCertFingerprints = this.getAllDerCertFingerprints();
|
||||
if (!devCertFingerprints.includes(fingerprint)) {
|
||||
throw new errors.CredentialsDistCertMismatchError(
|
||||
`Provisioning profile and distribution certificate don't match.
|
||||
Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}`
|
||||
`Provisioning profile and distribution certificate don't match.\n` +
|
||||
`Profile's certificate fingerprints = [${devCertFingerprints.join(', ')}], ` +
|
||||
`distribution certificate fingerprint = ${fingerprint}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -110,6 +112,10 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
] as string;
|
||||
const bundleIdentifier = applicationIdentifier.replace(/^.+?\./, '');
|
||||
|
||||
this.developerCertificates = (plistData.DeveloperCertificates as string[]).map((cert: string) =>
|
||||
Buffer.from(cert, 'base64')
|
||||
);
|
||||
|
||||
this.profileData = {
|
||||
path: this.profilePath,
|
||||
target: this.target,
|
||||
@@ -117,7 +123,7 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
teamId: (plistData.TeamIdentifier as string[])[0],
|
||||
uuid: plistData.UUID as string,
|
||||
name: plistData.Name as string,
|
||||
developerCertificate: Buffer.from((plistData.DeveloperCertificates as string[])[0], 'base64'),
|
||||
developerCertificate: this.developerCertificates[0],
|
||||
certificateCommonName: this.certificateCommonName,
|
||||
distributionType: this.resolveDistributionType(plistData),
|
||||
};
|
||||
@@ -133,11 +139,9 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
}
|
||||
}
|
||||
|
||||
private genDerCertFingerprint(): string {
|
||||
return crypto
|
||||
.createHash('sha1')
|
||||
.update(new Uint8Array(this.data.developerCertificate))
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
private getAllDerCertFingerprints(): string[] {
|
||||
return this.developerCertificates.map(cert =>
|
||||
crypto.createHash('sha1').update(new Uint8Array(cert)).digest('hex').toUpperCase()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
export const EasBuildGradle = `// Build integration with EAS
|
||||
|
||||
import java.nio.file.Paths
|
||||
|
||||
def versionCodeFromEnv = System.getenv("EAS_BUILD_ANDROID_VERSION_CODE")
|
||||
def versionNameFromEnv = System.getenv("EAS_BUILD_ANDROID_VERSION_NAME")
|
||||
|
||||
android {
|
||||
defaultConfig {
|
||||
if (versionCodeFromEnv) {
|
||||
versionCode = Integer.parseInt(versionCodeFromEnv)
|
||||
}
|
||||
if (versionNameFromEnv) {
|
||||
versionName = versionNameFromEnv
|
||||
}
|
||||
}
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
if (versionCodeFromEnv) {
|
||||
output.versionCodeOverride = Integer.parseInt(versionCodeFromEnv)
|
||||
}
|
||||
if (versionNameFromEnv) {
|
||||
output.versionNameOverride = versionNameFromEnv
|
||||
}
|
||||
}
|
||||
}
|
||||
signingConfigs {
|
||||
release {
|
||||
def credentialsJson = Paths.get(System.getenv("EAS_BUILD_WORKINGDIR")).resolve("credentials.json").toFile();
|
||||
def credentials = new groovy.json.JsonSlurper().parse(credentialsJson)
|
||||
def keystorePath = Paths.get(credentials.android.keystore.keystorePath);
|
||||
|
||||
storeFile keystorePath.toFile()
|
||||
storePassword credentials.android.keystore.keystorePassword
|
||||
keyAlias credentials.android.keystore.keyAlias
|
||||
if (credentials.android.keystore.containsKey("keyPassword")) {
|
||||
keyPassword credentials.android.keystore.keyPassword
|
||||
} else {
|
||||
// key password is required by Gradle, but PKCS keystores don't have one
|
||||
// using the keystore password seems to satisfy the requirement
|
||||
keyPassword credentials.android.keystore.keystorePassword
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig android.signingConfigs.release
|
||||
}
|
||||
debug {
|
||||
signingConfig android.signingConfigs.release
|
||||
}
|
||||
}
|
||||
}`;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import assert from 'assert';
|
||||
import FastGlob from 'fast-glob';
|
||||
@@ -17,6 +18,8 @@ export type AndroidDeviceName = string & z.BRAND<'AndroidDeviceName'>;
|
||||
export type AndroidDeviceSerialId = string & z.BRAND<'AndroidDeviceSerialId'>;
|
||||
|
||||
export namespace AndroidEmulatorUtils {
|
||||
const RETRY_INTERVAL_MS = 1_000;
|
||||
|
||||
export const defaultSystemImagePackage = `system-images;android-30;default;${
|
||||
process.arch === 'arm64' ? 'arm64-v8a' : 'x86_64'
|
||||
}`;
|
||||
@@ -352,10 +355,15 @@ export namespace AndroidEmulatorUtils {
|
||||
export async function waitForReadyAsync({
|
||||
serialId,
|
||||
env,
|
||||
timeoutMs = 3 * 60 * 1_000,
|
||||
logger,
|
||||
}: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
logger?: bunyan;
|
||||
}): Promise<void> {
|
||||
const retries = Math.max(0, Math.ceil(timeoutMs / RETRY_INTERVAL_MS) - 1);
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const { stdout } = await spawn(
|
||||
@@ -367,17 +375,80 @@ export namespace AndroidEmulatorUtils {
|
||||
if (!stdout.startsWith('1')) {
|
||||
throw new Error(`Emulator (${serialId}) boot has not completed.`);
|
||||
}
|
||||
|
||||
const hasNetworkConnection = await hasNetworkConnectionAsync({ serialId, env });
|
||||
if (!hasNetworkConnection) {
|
||||
throw new Error(`Emulator (${serialId}) network is not ready.`);
|
||||
}
|
||||
},
|
||||
{
|
||||
// Retry every second for 3 minutes.
|
||||
retryOptions: {
|
||||
retries: 3 * 60,
|
||||
retryIntervalMs: 1_000,
|
||||
retries,
|
||||
retryIntervalMs: RETRY_INTERVAL_MS,
|
||||
},
|
||||
logger,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function disableWindowAndTransitionAnimationsAsync({
|
||||
serialId,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
logger.info('Disabling Android emulator window animations.');
|
||||
try {
|
||||
await spawn(
|
||||
'adb',
|
||||
['-s', serialId, 'shell', 'settings', 'put', 'global', 'window_animation_scale', '0'],
|
||||
{ env }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable Android emulator window animations.');
|
||||
}
|
||||
|
||||
logger.info('Disabling Android emulator transition animations.');
|
||||
try {
|
||||
await spawn(
|
||||
'adb',
|
||||
['-s', serialId, 'shell', 'settings', 'put', 'global', 'transition_animation_scale', '0'],
|
||||
{ env }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable Android emulator transition animations.');
|
||||
}
|
||||
}
|
||||
|
||||
async function hasNetworkConnectionAsync({
|
||||
serialId,
|
||||
env,
|
||||
}: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<boolean> {
|
||||
const networkReadyCheckCommand = env.ANDROID_EMULATOR_NETWORK_READY_COMMAND?.trim();
|
||||
if (networkReadyCheckCommand) {
|
||||
const customNetworkCheckResult = await asyncResult(
|
||||
spawn('adb', ['-s', serialId, 'shell', networkReadyCheckCommand], { env })
|
||||
);
|
||||
return customNetworkCheckResult.ok;
|
||||
}
|
||||
|
||||
const netcatResult = await asyncResult(
|
||||
spawn(
|
||||
'adb',
|
||||
['-s', serialId, 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
|
||||
// Close stdin to make netcat exit cleanly on Android images that don't support `-z`.
|
||||
{ env, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
)
|
||||
);
|
||||
return netcatResult.ok;
|
||||
}
|
||||
|
||||
export async function collectLogsAsync({
|
||||
serialId,
|
||||
env,
|
||||
@@ -419,18 +490,13 @@ export namespace AndroidEmulatorUtils {
|
||||
return { outputPath };
|
||||
}
|
||||
|
||||
export async function deleteAsync({
|
||||
export async function stopAsync({
|
||||
serialId,
|
||||
env,
|
||||
}: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
|
||||
env,
|
||||
});
|
||||
const deviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0];
|
||||
|
||||
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env });
|
||||
|
||||
await retryAsync(
|
||||
@@ -443,12 +509,40 @@ export namespace AndroidEmulatorUtils {
|
||||
{
|
||||
retryOptions: {
|
||||
retries: 3 * 60,
|
||||
retryIntervalMs: 1_000,
|
||||
retryIntervalMs: RETRY_INTERVAL_MS,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await spawn('avdmanager', ['delete', 'avd', '-n', deviceName], { env });
|
||||
export async function deleteAsync({
|
||||
serialId,
|
||||
deviceName,
|
||||
env,
|
||||
}: {
|
||||
serialId?: AndroidDeviceSerialId;
|
||||
deviceName?: AndroidVirtualDeviceName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
let resolvedDeviceName = deviceName;
|
||||
if (!resolvedDeviceName && serialId) {
|
||||
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
|
||||
env,
|
||||
});
|
||||
resolvedDeviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0] as
|
||||
| AndroidVirtualDeviceName
|
||||
| undefined;
|
||||
}
|
||||
|
||||
assert(resolvedDeviceName, 'Either "serialId" or "deviceName" must resolve to a device name.');
|
||||
|
||||
const serialIdToStop =
|
||||
serialId ?? (await getSerialIdAsync({ deviceName: resolvedDeviceName, env })) ?? undefined;
|
||||
if (serialIdToStop) {
|
||||
await stopAsync({ serialId: serialIdToStop, env });
|
||||
}
|
||||
|
||||
await spawn('avdmanager', ['delete', 'avd', '-n', resolvedDeviceName], { env });
|
||||
}
|
||||
|
||||
export async function startScreenRecordingAsync({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
// We need to use real fs for cloning devices to work.
|
||||
jest.unmock('fs');
|
||||
@@ -104,7 +105,10 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
expect(stdout).toContain('data');
|
||||
|
||||
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env: process.env });
|
||||
await AndroidEmulatorUtils.stopAsync({
|
||||
serialId,
|
||||
env: process.env,
|
||||
});
|
||||
await asyncResult(emulatorPromise);
|
||||
|
||||
const cloneDeviceName = (deviceName + '-clone') as AndroidVirtualDeviceName;
|
||||
@@ -131,12 +135,133 @@ describe('AndroidEmulatorUtils', () => {
|
||||
expect(stdoutClone).toContain('data');
|
||||
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
});
|
||||
await asyncResult(emulatorPromiseClone);
|
||||
}, 60_000);
|
||||
|
||||
it('should delete a running emulator by device name', async () => {
|
||||
const deviceName = 'android-emulator-delete-by-name-test' as AndroidVirtualDeviceName;
|
||||
const avdPath = `${process.env.HOME}/.android/avd/${deviceName}.avd`;
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
});
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName,
|
||||
env: process.env,
|
||||
});
|
||||
await asyncResult(emulatorPromise);
|
||||
|
||||
const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env });
|
||||
expect(devices.map(device => device.serialId)).not.toContain(serialId);
|
||||
|
||||
await expect(fs.promises.access(avdPath)).rejects.toThrow();
|
||||
}, 60_000);
|
||||
|
||||
it('should retry when first startup forces network check command failure', async () => {
|
||||
const deviceName = 'android-emulator-network-retry-test' as AndroidVirtualDeviceName;
|
||||
let attemptCounter = 0;
|
||||
let sawNetworkNotReadyError = false;
|
||||
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
attemptCounter += 1;
|
||||
const shouldFailNetworkCheck = attemptCount === 0;
|
||||
const envForAttempt: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
|
||||
ANDROID_EMULATOR_NETWORK_READY_COMMAND: shouldFailNetworkCheck
|
||||
? 'echo "network not ready" && exit 1'
|
||||
: 'echo "network ready"',
|
||||
};
|
||||
|
||||
let serialId: AndroidDeviceSerialId | null = null;
|
||||
let emulatorPromise: Promise<unknown> | null = null;
|
||||
|
||||
try {
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
env: envForAttempt,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: envForAttempt,
|
||||
});
|
||||
serialId = startResult.serialId;
|
||||
emulatorPromise = asyncResult(startResult.emulatorPromise);
|
||||
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
env: envForAttempt,
|
||||
timeoutMs: 30_000,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
});
|
||||
await emulatorPromise;
|
||||
} catch (err: any) {
|
||||
if (err?.message?.includes('network is not ready')) {
|
||||
sawNetworkNotReadyError = true;
|
||||
}
|
||||
try {
|
||||
if (serialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName,
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn('Cleanup failed during retry test', cleanupError);
|
||||
}
|
||||
if (emulatorPromise) {
|
||||
await emulatorPromise;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
retryOptions: {
|
||||
retries: 1,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(sawNetworkNotReadyError).toBe(true);
|
||||
expect(attemptCounter).toBe(2);
|
||||
}, 360_000);
|
||||
|
||||
it('should work with screen recording', async () => {
|
||||
const deviceName = 'android-emulator-screen-recording-test' as AndroidVirtualDeviceName;
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
|
||||
-400
@@ -1,400 +0,0 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import bunyan from 'bunyan';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { findMaestroPathsFlowsToExecuteAsync } from '../findMaestroPathsFlowsToExecuteAsync';
|
||||
|
||||
// We use node:fs
|
||||
jest.unmock('node:fs');
|
||||
// and fg uses fs.
|
||||
jest.unmock('fs');
|
||||
jest.setTimeout(15000);
|
||||
|
||||
/**
|
||||
*
|
||||
* You need to have a Simulator running to run these tests.
|
||||
*
|
||||
*/
|
||||
|
||||
describe('findMaestroPathsFlowsToExecuteAsync', () => {
|
||||
const fixturesDir = path.join(__dirname, 'fixtures', 'maestro-flows');
|
||||
const singleFileDir = path.join(__dirname, 'fixtures', 'single-file');
|
||||
const configFlowsDir = path.join(__dirname, 'fixtures', 'config-flows');
|
||||
const noConfigFlowsDir = path.join(__dirname, 'fixtures', 'no-config-flows');
|
||||
|
||||
let logger: bunyan;
|
||||
|
||||
beforeAll(() => {
|
||||
logger = createMockLogger();
|
||||
});
|
||||
|
||||
// Helper function to run maestro test and determine which flows it executed
|
||||
// by parsing the output
|
||||
async function getMaestroFlowList(
|
||||
flowPath: string,
|
||||
includeTags: string[] = [],
|
||||
excludeTags: string[] = []
|
||||
): Promise<string[]> {
|
||||
const args = ['test', flowPath, '--no-ansi'];
|
||||
|
||||
if (includeTags.length > 0) {
|
||||
args.push('--include-tags', includeTags.join(','));
|
||||
}
|
||||
|
||||
if (excludeTags.length > 0) {
|
||||
args.push('--exclude-tags', excludeTags.join(','));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawnAsync('maestro', args, {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
return parseMaestroExecutedFlows(result.stdout + result.stderr);
|
||||
} catch (error: any) {
|
||||
const output = (error.stdout || '') + (error.stderr || '') + (error.message || '');
|
||||
return parseMaestroExecutedFlows(output);
|
||||
}
|
||||
}
|
||||
|
||||
function parseMaestroExecutedFlows(output: string): string[] {
|
||||
return output
|
||||
.split('\n')
|
||||
.filter(line => line.includes('[Passed]') || line.includes('> Flow'))
|
||||
.map(line => {
|
||||
if (line.includes('[Passed]')) {
|
||||
return line.split('[Passed]')[1].trim().split(' ')[0];
|
||||
}
|
||||
return line.split('> Flow')[1].trim();
|
||||
})
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Helper function to run our implementation
|
||||
async function getOurFlowList(
|
||||
flowPath: string,
|
||||
includeTags: string[] = [],
|
||||
excludeTags: string[] = []
|
||||
): Promise<string[]> {
|
||||
const result = await findMaestroPathsFlowsToExecuteAsync({
|
||||
workingDirectory: process.cwd(),
|
||||
flowPath: path.relative(process.cwd(), flowPath),
|
||||
includeTags,
|
||||
excludeTags,
|
||||
logger,
|
||||
});
|
||||
|
||||
return result.map(filePath => path.basename(filePath, path.extname(filePath))).sort();
|
||||
}
|
||||
|
||||
describe('Directory-based flow discovery', () => {
|
||||
it('should discover all flows when no tags are specified', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir);
|
||||
const ourFlows = await getOurFlowList(fixturesDir);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should filter flows by include tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['auth']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should filter flows by exclude tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should filter flows by both include and exclude tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, ['smoke'], ['regression']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['smoke'], ['regression']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should handle multiple include tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, ['auth', 'smoke']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['auth', 'smoke']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should handle multiple exclude tags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, [], ['slow', 'regression']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, [], ['slow', 'regression']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should not include nested flows in subdirectories', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(fixturesDir, ['nested']);
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['nested']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
});
|
||||
|
||||
it('should exclude config files from flow discovery', async () => {
|
||||
const ourFlows = await getOurFlowList(fixturesDir);
|
||||
|
||||
expect(ourFlows.every(flow => !flow.endsWith('config.yaml'))).toBe(true);
|
||||
expect(ourFlows.every(flow => !flow.endsWith('config.yml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should exclude non-YAML files from flow discovery', async () => {
|
||||
const ourFlows = await getOurFlowList(fixturesDir);
|
||||
|
||||
expect(ourFlows).not.toContain('non-flow-file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Single file scenarios', () => {
|
||||
it('should handle single file input', async () => {
|
||||
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
|
||||
|
||||
const maestroFlows = await getMaestroFlowList(singleFile);
|
||||
const ourFlows = await getOurFlowList(singleFile);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
expect(ourFlows).toHaveLength(1);
|
||||
expect(ourFlows[0]).toBe(path.basename(singleFile, path.extname(singleFile)));
|
||||
});
|
||||
|
||||
it('should handle single file with tag filtering (include)', async () => {
|
||||
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
|
||||
|
||||
// This file has 'smoke' tag, so should be included
|
||||
const maestroFlows = await getMaestroFlowList(singleFile, ['smoke']);
|
||||
const ourFlows = await getOurFlowList(singleFile, ['smoke']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
expect(ourFlows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle single file with tag filtering (exclude)', async () => {
|
||||
const singleFile = path.join(singleFileDir, 'standalone-flow.yaml');
|
||||
|
||||
// This file has 'smoke' tag, so should be excluded
|
||||
const maestroFlows = await getMaestroFlowList(singleFile, [], ['smoke']);
|
||||
const ourFlows = await getOurFlowList(singleFile, [], ['smoke']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
expect(ourFlows).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle non-existent paths gracefully', async () => {
|
||||
const nonExistentPath = '/path/that/does/not/exist';
|
||||
|
||||
// Our implementation should return empty array for non-existent paths
|
||||
await expect(getOurFlowList(nonExistentPath)).rejects.toThrow('ENOENT');
|
||||
});
|
||||
|
||||
it('should handle empty directories', async () => {
|
||||
const emptyDir = path.join(__dirname, 'fixtures', 'empty-dir');
|
||||
await fs.mkdir(emptyDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const ourFlows = await getOurFlowList(emptyDir);
|
||||
expect(ourFlows).toEqual([]);
|
||||
} finally {
|
||||
await fs.rmdir(emptyDir);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle flows that cannot be parsed', async () => {
|
||||
// The invalid-flow.yaml has extra fields that might cause parsing issues
|
||||
// but should still be discovered if tag filtering passes
|
||||
const ourFlows = await getOurFlowList(fixturesDir, ['invalid']);
|
||||
|
||||
// Should either include the file (if parsing is lenient) or exclude it (if parsing fails)
|
||||
// The important thing is that it doesn't crash
|
||||
expect(Array.isArray(ourFlows)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance comparison', () => {
|
||||
it('should complete discovery in reasonable time', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await getOurFlowList(fixturesDir);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
expect(duration).toBeLessThan(5000); // Should complete within 5 seconds
|
||||
});
|
||||
});
|
||||
|
||||
describe('Detailed flow content verification', () => {
|
||||
it('should correctly identify flows with specific tag combinations', async () => {
|
||||
// Test complex tag filtering scenarios
|
||||
const authFlows = await getOurFlowList(fixturesDir, ['auth']);
|
||||
const smokeFlows = await getOurFlowList(fixturesDir, ['smoke']);
|
||||
const criticalFlows = await getOurFlowList(fixturesDir, ['critical']);
|
||||
|
||||
// Verify specific files are included/excluded correctly
|
||||
expect(authFlows).toContain('tagged-flow-auth');
|
||||
expect(authFlows).not.toContain('nested-flow'); // Subdirectory flows excluded by default
|
||||
expect(smokeFlows).toContain('tagged-flow-smoke');
|
||||
expect(criticalFlows).toContain('tagged-flow-smoke');
|
||||
});
|
||||
|
||||
it('should handle flows without tags correctly', async () => {
|
||||
// When no filters are applied, flows without tags should be included
|
||||
const allFlows = await getOurFlowList(fixturesDir);
|
||||
expect(allFlows).toContain('no-tags-flow');
|
||||
expect(allFlows).toContain('basic-flow');
|
||||
|
||||
// When include tags are specified, flows without tags should be excluded
|
||||
const taggedFlows = await getOurFlowList(fixturesDir, ['auth']);
|
||||
expect(taggedFlows).not.toContain('no-tags-flow');
|
||||
expect(taggedFlows).not.toContain('basic-flow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('config.yaml flow patterns', () => {
|
||||
it('should discover flows using config.yaml patterns', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configFlowsDir);
|
||||
const ourFlows = await getOurFlowList(configFlowsDir);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
|
||||
// Verify specific files are included based on config patterns
|
||||
expect(ourFlows).toContain('login'); // features/auth/login.yaml
|
||||
expect(ourFlows).toContain('logout'); // features/auth/logout.yaml
|
||||
expect(ourFlows).toContain('navigation'); // features/core/navigation.yaml
|
||||
expect(ourFlows).toContain('api.spec'); // specs/api.spec.yaml
|
||||
expect(ourFlows).toContain('ui.spec'); // specs/ui.spec.yaml
|
||||
|
||||
// Verify top-level file is excluded (not matching patterns)
|
||||
expect(ourFlows).not.toContain('ignored-top-level');
|
||||
});
|
||||
|
||||
it('should handle tag filtering with config.yaml patterns', async () => {
|
||||
const authFlows = await getOurFlowList(configFlowsDir, ['auth']);
|
||||
const maestroAuthFlows = await getMaestroFlowList(configFlowsDir, ['auth']);
|
||||
|
||||
expect(authFlows).toEqual(maestroAuthFlows);
|
||||
expect(authFlows).toContain('login');
|
||||
expect(authFlows).toContain('logout');
|
||||
expect(authFlows).not.toContain('navigation');
|
||||
expect(authFlows).not.toContain('api.spec');
|
||||
});
|
||||
|
||||
it('should handle exclude tags with config.yaml patterns', async () => {
|
||||
const nonSmokeFlows = await getOurFlowList(configFlowsDir, [], ['smoke']);
|
||||
const maestroNonSmokeFlows = await getMaestroFlowList(configFlowsDir, [], ['smoke']);
|
||||
|
||||
expect(nonSmokeFlows).toEqual(maestroNonSmokeFlows);
|
||||
expect(nonSmokeFlows).not.toContain('login'); // has smoke tag
|
||||
expect(nonSmokeFlows).not.toContain('navigation'); // has smoke tag
|
||||
expect(nonSmokeFlows).toContain('logout'); // no smoke tag
|
||||
expect(nonSmokeFlows).toContain('api.spec'); // no smoke tag
|
||||
});
|
||||
|
||||
it('should handle multiple include tags with config.yaml patterns', async () => {
|
||||
const multiTagFlows = await getOurFlowList(configFlowsDir, ['auth', 'integration']);
|
||||
const maestroMultiTagFlows = await getMaestroFlowList(configFlowsDir, [
|
||||
'auth',
|
||||
'integration',
|
||||
]);
|
||||
|
||||
expect(multiTagFlows).toEqual(maestroMultiTagFlows);
|
||||
expect(multiTagFlows).toContain('login'); // auth tag
|
||||
expect(multiTagFlows).toContain('logout'); // auth tag
|
||||
expect(multiTagFlows).toContain('api.spec'); // integration tag
|
||||
expect(multiTagFlows).toContain('ui.spec'); // integration tag
|
||||
expect(multiTagFlows).not.toContain('navigation'); // only core/smoke tags
|
||||
});
|
||||
});
|
||||
|
||||
describe('config.yaml includeTags/excludeTags merging', () => {
|
||||
const configTagsDir = path.join(__dirname, 'fixtures', 'config-tags');
|
||||
|
||||
it('should respect includeTags/excludeTags from config.yaml when no CLI tags provided', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configTagsDir);
|
||||
const ourFlows = await getOurFlowList(configTagsDir);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
|
||||
// Based on fixtures, only flows matching config includeTags and not matching excludeTags should be included
|
||||
expect(ourFlows).toContain('flow-inc-only');
|
||||
expect(ourFlows).not.toContain('flow-exc-only');
|
||||
expect(ourFlows).not.toContain('flow-inc-and-exc');
|
||||
expect(ourFlows).not.toContain('flow-neutral');
|
||||
expect(ourFlows).not.toContain('flow-cli-exclude');
|
||||
expect(ourFlows).not.toContain('flow-cli-include');
|
||||
});
|
||||
|
||||
it('should merge CLI includeTags with config includeTags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cliInclude']);
|
||||
const ourFlows = await getOurFlowList(configTagsDir, ['cliInclude']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
// Now flows with either cfgInclude or cliInclude should be included, minus excluded ones
|
||||
expect(ourFlows).toContain('flow-inc-only');
|
||||
expect(ourFlows).toContain('flow-cli-include');
|
||||
expect(ourFlows).not.toContain('flow-exc-only');
|
||||
expect(ourFlows).not.toContain('flow-inc-and-exc');
|
||||
expect(ourFlows).not.toContain('flow-cli-exclude');
|
||||
});
|
||||
|
||||
it('should merge CLI excludeTags with config excludeTags', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configTagsDir, [], ['cliExclude']);
|
||||
const ourFlows = await getOurFlowList(configTagsDir, [], ['cliExclude']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
// Exclude both cfgExclude and cliExclude
|
||||
expect(ourFlows).toContain('flow-inc-only');
|
||||
expect(ourFlows).not.toContain('flow-exc-only');
|
||||
expect(ourFlows).not.toContain('flow-cli-exclude');
|
||||
expect(ourFlows).not.toContain('flow-inc-and-exc');
|
||||
});
|
||||
|
||||
it('should apply both merged include and exclude tags correctly', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
|
||||
const ourFlows = await getOurFlowList(configTagsDir, ['cfgInclude'], ['cfgExclude']);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
expect(ourFlows).toContain('flow-inc-only');
|
||||
expect(ourFlows).not.toContain('flow-exc-only');
|
||||
expect(ourFlows).not.toContain('flow-inc-and-exc');
|
||||
expect(ourFlows).not.toContain('flow-neutral');
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility (no config.yaml)', () => {
|
||||
it('should use default "*" pattern when no config.yaml exists', async () => {
|
||||
const maestroFlows = await getMaestroFlowList(noConfigFlowsDir);
|
||||
const ourFlows = await getOurFlowList(noConfigFlowsDir);
|
||||
|
||||
expect(ourFlows).toEqual(maestroFlows);
|
||||
|
||||
// Should include top-level files
|
||||
expect(ourFlows).toContain('top-level-flow');
|
||||
|
||||
// Should exclude subdirectory files (default "*" behavior)
|
||||
expect(ourFlows).not.toContain('nested-flow');
|
||||
});
|
||||
|
||||
it('should handle tag filtering without config.yaml', async () => {
|
||||
const basicFlows = await getOurFlowList(noConfigFlowsDir, ['basic']);
|
||||
const maestroBasicFlows = await getMaestroFlowList(noConfigFlowsDir, ['basic']);
|
||||
|
||||
expect(basicFlows).toEqual(maestroBasicFlows);
|
||||
expect(basicFlows).toContain('top-level-flow');
|
||||
|
||||
const noIncludeTagsFlows = await getOurFlowList(noConfigFlowsDir, []);
|
||||
const maestroNoIncludeTagsFlows = await getMaestroFlowList(noConfigFlowsDir, []);
|
||||
|
||||
expect(noIncludeTagsFlows).toEqual(maestroNoIncludeTagsFlows);
|
||||
expect(noIncludeTagsFlows).toContain('top-level-flow');
|
||||
expect(noIncludeTagsFlows).not.toContain('nested-flow');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
flows:
|
||||
- 'features/**/*.yaml'
|
||||
- 'specs/*.spec.yaml'
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "login"
|
||||
tags: ["auth", "smoke"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "logout"
|
||||
tags: ["auth"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "navigation"
|
||||
tags: ["core", "smoke"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "ignored-top-level"
|
||||
tags: ["ignored"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "api.spec"
|
||||
tags: ["api", "integration"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "ui.spec"
|
||||
tags: ["ui", "integration"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "basic-flow"
|
||||
---
|
||||
- assertTrue: true
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "invalid-flow"
|
||||
tags:
|
||||
- invalid
|
||||
---
|
||||
- assertTrue: true
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "no-tags-flow"
|
||||
---
|
||||
- assertTrue: true
|
||||
-1
@@ -1 +0,0 @@
|
||||
This is not a YAML file and should be ignored by flow discovery.
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "nested-flow"
|
||||
tags:
|
||||
- nested
|
||||
- auth
|
||||
---
|
||||
- assertTrue: true
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "tagged-flow-auth"
|
||||
tags:
|
||||
- auth
|
||||
- login
|
||||
---
|
||||
- assertTrue: true
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "tagged-flow-regression"
|
||||
tags:
|
||||
- regression
|
||||
- slow
|
||||
---
|
||||
- assertTrue: true
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "tagged-flow-smoke"
|
||||
tags:
|
||||
- smoke
|
||||
- critical
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "nested-flow"
|
||||
tags: ["nested"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "top-level-flow"
|
||||
tags: ["basic"]
|
||||
---
|
||||
- assertTrue: true
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
appId: com.example.app
|
||||
name: "standalone-flow"
|
||||
tags:
|
||||
- standalone
|
||||
- smoke
|
||||
---
|
||||
- assertTrue: true
|
||||
@@ -0,0 +1,243 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../retry', () => ({
|
||||
retryAsync: jest.fn(async fn => await fn(0)),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
|
||||
describe('AndroidEmulatorUtils', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedRetryAsync.mockImplementation(async fn => await fn(0));
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.waitForReadyAsync, () => {
|
||||
it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[3] === 'getprop') {
|
||||
return { stdout: '1\n', stderr: '' } as any;
|
||||
}
|
||||
if (args[3] === 'nc' && args[6] === '1.1.1.1' && args[7] === '443') {
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}
|
||||
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
|
||||
}) as any);
|
||||
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(mockedRetryAsync).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
retryOptions: expect.objectContaining({
|
||||
retries: 59,
|
||||
retryIntervalMs: 1_000,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
|
||||
{ env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when network is unavailable despite completed boot', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[3] === 'getprop') {
|
||||
return { stdout: '1\n', stderr: '' } as any;
|
||||
}
|
||||
if (args[3] === 'nc') {
|
||||
throw new Error('network unreachable');
|
||||
}
|
||||
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
|
||||
}) as any);
|
||||
|
||||
await expect(
|
||||
AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow('network is not ready');
|
||||
});
|
||||
|
||||
it('uses overridden network readiness command when provided', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[3] === 'getprop') {
|
||||
return { stdout: '1\n', stderr: '' } as any;
|
||||
}
|
||||
if (args[3] === 'exit 0') {
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}
|
||||
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
|
||||
}) as any);
|
||||
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: {
|
||||
...process.env,
|
||||
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
['-s', 'emulator-5554', 'shell', 'exit 0'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync, () => {
|
||||
it('sets window and transition animation scales to zero', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'Disabling Android emulator window animations.'
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'adb',
|
||||
[
|
||||
'-s',
|
||||
'emulator-5554',
|
||||
'shell',
|
||||
'settings',
|
||||
'put',
|
||||
'global',
|
||||
'window_animation_scale',
|
||||
'0',
|
||||
],
|
||||
{ env: process.env }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'Disabling Android emulator transition animations.'
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'-s',
|
||||
'emulator-5554',
|
||||
'shell',
|
||||
'settings',
|
||||
'put',
|
||||
'global',
|
||||
'transition_animation_scale',
|
||||
'0',
|
||||
],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
|
||||
it('logs and swallows failures when disabling animations', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('window failed'))
|
||||
.mockRejectedValueOnce(new Error('transition failed'));
|
||||
|
||||
await expect(
|
||||
AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
logger,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
err: expect.objectContaining({ message: 'window failed' }),
|
||||
}),
|
||||
'Failed to disable Android emulator window animations.'
|
||||
);
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
err: expect.objectContaining({ message: 'transition failed' }),
|
||||
}),
|
||||
'Failed to disable Android emulator transition animations.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.stopAsync, () => {
|
||||
it('kills emulator and waits for it to detach', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[2] === 'emu' && args[3] === 'kill') {
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}
|
||||
if (args[0] === 'devices') {
|
||||
return { stdout: 'List of devices attached\n\n', stderr: '' } as any;
|
||||
}
|
||||
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
|
||||
}) as any);
|
||||
|
||||
await AndroidEmulatorUtils.stopAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
|
||||
env: process.env,
|
||||
});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['devices', '-l'], {
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.deleteAsync, () => {
|
||||
it('stops by serial id and then deletes the AVD', async () => {
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
deviceName: 'eas-simulator-1' as any,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
|
||||
env: process.env,
|
||||
});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'avdmanager',
|
||||
['delete', 'avd', '-n', 'eas-simulator-1'],
|
||||
{
|
||||
env: process.env,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
|
||||
import { readAppConfig } from '../appConfig';
|
||||
|
||||
jest.mock('@expo/env', () => ({
|
||||
load: jest.fn(() => ({ FROM_DOTENV: 'true' })),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/config', () => ({
|
||||
getConfig: jest.fn(() => ({
|
||||
exp: { name: 'fallback-app', slug: 'fallback-app' },
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../expoCli');
|
||||
|
||||
const { expoCommandAsync } = jest.requireMock('../expoCli') as {
|
||||
expoCommandAsync: jest.Mock;
|
||||
};
|
||||
|
||||
const { getConfig } = jest.requireMock('@expo/config') as {
|
||||
getConfig: jest.Mock;
|
||||
};
|
||||
|
||||
const { load: loadEnv } = jest.requireMock('@expo/env') as {
|
||||
load: jest.Mock;
|
||||
};
|
||||
|
||||
const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
|
||||
const baseParams = {
|
||||
projectDir: '/project',
|
||||
env: { NODE_ENV: 'production' },
|
||||
logger,
|
||||
};
|
||||
|
||||
describe(readAppConfig, () => {
|
||||
it('returns config from expo CLI when it succeeds', async () => {
|
||||
const config = { exp: { name: 'test-app', slug: 'test-app' } };
|
||||
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
|
||||
|
||||
const result = await readAppConfig(baseParams);
|
||||
|
||||
expect(result).toEqual(config);
|
||||
expect(expoCommandAsync).toHaveBeenCalledWith(
|
||||
'/project',
|
||||
['config', '--json', '--full', '--type', 'public'],
|
||||
{ env: baseParams.env }
|
||||
);
|
||||
expect(getConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to @expo/config when expo CLI fails', async () => {
|
||||
expoCommandAsync.mockRejectedValue(new Error('expo not found'));
|
||||
|
||||
const result = await readAppConfig(baseParams);
|
||||
|
||||
expect(result).toEqual({ exp: { name: 'fallback-app', slug: 'fallback-app' } });
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('expo not found'));
|
||||
expect(getConfig).toHaveBeenCalledWith('/project', {
|
||||
skipSDKVersionRequirement: true,
|
||||
isPublicConfig: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when expo CLI returns invalid JSON and @expo/config also fails', async () => {
|
||||
expoCommandAsync.mockResolvedValue({ stdout: 'not json' });
|
||||
getConfig.mockImplementation(() => {
|
||||
throw new Error('@expo/config failed');
|
||||
});
|
||||
|
||||
await expect(readAppConfig(baseParams)).rejects.toThrow('@expo/config failed');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to parse JSON output from 'expo config'")
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when expo CLI output is missing exp field and @expo/config also fails', async () => {
|
||||
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify({ other: 'data' }) });
|
||||
getConfig.mockImplementation(() => {
|
||||
throw new Error('@expo/config failed');
|
||||
});
|
||||
|
||||
await expect(readAppConfig(baseParams)).rejects.toThrow('@expo/config failed');
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("missing 'exp' field"));
|
||||
});
|
||||
|
||||
it('loads env vars from dotenv for SDK >= 49', async () => {
|
||||
const config = { exp: { name: 'test-app', slug: 'test-app' } };
|
||||
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
|
||||
|
||||
await readAppConfig({ ...baseParams, sdkVersion: '49.0.0' });
|
||||
|
||||
expect(loadEnv).toHaveBeenCalledWith('/project');
|
||||
expect(expoCommandAsync).toHaveBeenCalledWith('/project', expect.any(Array), {
|
||||
env: { NODE_ENV: 'production', FROM_DOTENV: 'true' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not load env vars from dotenv for SDK < 49', async () => {
|
||||
const config = { exp: { name: 'test-app', slug: 'test-app' } };
|
||||
expoCommandAsync.mockResolvedValue({ stdout: JSON.stringify(config) });
|
||||
|
||||
await readAppConfig({ ...baseParams, sdkVersion: '48.0.0' });
|
||||
|
||||
expect(loadEnv).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import resolveFrom from 'resolve-from';
|
||||
import spawnAsync from '@expo/turtle-spawn';
|
||||
|
||||
import { expoCommandAsync, ExpoCLIModuleNotFoundError } from '../expoCli';
|
||||
|
||||
jest.mock('resolve-from', () => ({
|
||||
__esModule: true,
|
||||
default: Object.assign(jest.fn(), {
|
||||
silent: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
}));
|
||||
|
||||
const mockedResolveFrom = resolveFrom as jest.MockedFunction<typeof resolveFrom> & {
|
||||
silent: jest.MockedFunction<typeof resolveFrom.silent>;
|
||||
};
|
||||
|
||||
describe(expoCommandAsync, () => {
|
||||
it('resolves expo CLI and spawns with correct args', async () => {
|
||||
mockedResolveFrom.silent.mockReturnValue('/project/node_modules/expo/bin/cli');
|
||||
|
||||
await expoCommandAsync('/project', ['config', '--json'], { env: { FOO: 'bar' } });
|
||||
|
||||
expect(spawnAsync).toHaveBeenCalledWith(
|
||||
'/project/node_modules/expo/bin/cli',
|
||||
['config', '--json'],
|
||||
expect.objectContaining({
|
||||
cwd: '/project',
|
||||
stdio: 'pipe',
|
||||
env: { FOO: 'bar', EXPO_DEBUG: '0' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to expo/bin/cli.js when silent resolve returns null', async () => {
|
||||
mockedResolveFrom.silent.mockReturnValue(undefined);
|
||||
mockedResolveFrom.mockReturnValue('/project/node_modules/expo/bin/cli.js');
|
||||
|
||||
await expoCommandAsync('/project', ['config'], { env: {} });
|
||||
|
||||
expect(spawnAsync).toHaveBeenCalledWith(
|
||||
'/project/node_modules/expo/bin/cli.js',
|
||||
['config'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('throws ExpoCLIModuleNotFoundError when expo is not installed', async () => {
|
||||
mockedResolveFrom.silent.mockReturnValue(undefined);
|
||||
const moduleError = new Error('Cannot find module');
|
||||
(moduleError as any).code = 'MODULE_NOT_FOUND';
|
||||
mockedResolveFrom.mockImplementation(() => {
|
||||
throw moduleError;
|
||||
});
|
||||
|
||||
await expect(expoCommandAsync('/project', ['config'], { env: {} })).rejects.toThrow(
|
||||
ExpoCLIModuleNotFoundError
|
||||
);
|
||||
});
|
||||
|
||||
it('overrides EXPO_DEBUG to 0', async () => {
|
||||
mockedResolveFrom.silent.mockReturnValue('/project/node_modules/expo/bin/cli');
|
||||
|
||||
await expoCommandAsync('/project', ['config'], { env: { EXPO_DEBUG: '1' } });
|
||||
|
||||
expect(spawnAsync).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ EXPO_DEBUG: '0' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,14 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import {
|
||||
findPackagerRootDir,
|
||||
getPackageVersionFromPackageJson,
|
||||
resolvePackageManager,
|
||||
resolvePackageVersionAsync,
|
||||
shouldUseFrozenLockfile,
|
||||
} from '../packageManager';
|
||||
|
||||
@@ -68,6 +71,33 @@ describe(resolvePackageManager, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageVersionAsync, () => {
|
||||
const logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
|
||||
it('should resolve version from package name and dist tag', async () => {
|
||||
const version = await resolvePackageVersionAsync({
|
||||
logger,
|
||||
packageName: '@expo/repack-app',
|
||||
distTag: 'latest',
|
||||
});
|
||||
expect(semver.valid(version)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return null if version cannot be resolved', async () => {
|
||||
const version = await resolvePackageVersionAsync({
|
||||
logger,
|
||||
packageName: '@expo/repack-app',
|
||||
distTag: 'nonexistent-dist-tag',
|
||||
});
|
||||
expect(version).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(findPackagerRootDir, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
|
||||
@@ -24,7 +24,7 @@ describe(runExpoCliCommand, () => {
|
||||
|
||||
const mockCtx = mock<BuildContext<Android.Job>>();
|
||||
when(mockCtx.packageManager).thenReturn(PackageManager.NPM);
|
||||
when(mockCtx.appConfig).thenReturn(expoConfig);
|
||||
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
|
||||
const ctx = instance(mockCtx);
|
||||
|
||||
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
|
||||
@@ -38,7 +38,7 @@ describe(runExpoCliCommand, () => {
|
||||
|
||||
const mockCtx = mock<BuildContext<Android.Job>>();
|
||||
when(mockCtx.packageManager).thenReturn(PackageManager.NPM);
|
||||
when(mockCtx.appConfig).thenReturn(expoConfig);
|
||||
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
|
||||
const ctx = instance(mockCtx);
|
||||
|
||||
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
|
||||
@@ -52,7 +52,7 @@ describe(runExpoCliCommand, () => {
|
||||
|
||||
const mockCtx = mock<BuildContext<Android.Job>>();
|
||||
when(mockCtx.packageManager).thenReturn(PackageManager.PNPM);
|
||||
when(mockCtx.appConfig).thenReturn(expoConfig);
|
||||
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
|
||||
const ctx = instance(mockCtx);
|
||||
|
||||
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
|
||||
@@ -66,7 +66,7 @@ describe(runExpoCliCommand, () => {
|
||||
|
||||
const mockCtx = mock<BuildContext<Android.Job>>();
|
||||
when(mockCtx.packageManager).thenReturn(PackageManager.BUN);
|
||||
when(mockCtx.appConfig).thenReturn(expoConfig);
|
||||
when(mockCtx.appConfig).thenReturn(Promise.resolve(expoConfig));
|
||||
const ctx = instance(mockCtx);
|
||||
|
||||
void runExpoCliCommand({ args: ['doctor'], options: {}, packageManager: ctx.packageManager });
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { StepMetricsCollection } from '@expo/steps';
|
||||
import { StepMetric } from '@expo/steps';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { uploadStepMetricsToWwwAsync } from '../stepMetrics';
|
||||
import { turtleFetch } from '../turtleFetch';
|
||||
|
||||
jest.mock('../turtleFetch');
|
||||
jest.mock('../turtleFetch', () => ({
|
||||
...jest.requireActual('../turtleFetch'),
|
||||
turtleFetch: jest.fn(),
|
||||
}));
|
||||
const mockTurtleFetch = turtleFetch as jest.MockedFunction<typeof turtleFetch>;
|
||||
|
||||
describe(uploadStepMetricsToWwwAsync, () => {
|
||||
@@ -23,44 +26,52 @@ describe(uploadStepMetricsToWwwAsync, () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uploads step metrics to www', async () => {
|
||||
const stepMetrics: StepMetricsCollection = [
|
||||
{ metricsId: 'eas/checkout', result: 'success', durationMs: 1000, platform: 'linux' },
|
||||
{
|
||||
metricsId: 'eas/install_node_modules',
|
||||
result: 'failed',
|
||||
durationMs: 5000,
|
||||
platform: 'linux',
|
||||
},
|
||||
];
|
||||
it('uploads step metrics', async () => {
|
||||
const stepMetric: StepMetric = {
|
||||
metricsId: 'eas/checkout',
|
||||
result: 'success',
|
||||
durationMs: 1000,
|
||||
platform: 'linux',
|
||||
};
|
||||
|
||||
await uploadStepMetricsToWwwAsync({
|
||||
workflowJobId,
|
||||
robotAccessToken,
|
||||
expoApiV2BaseUrl,
|
||||
stepMetrics,
|
||||
stepMetrics: [stepMetric],
|
||||
logger: mockLogger,
|
||||
});
|
||||
|
||||
expect(mockTurtleFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockTurtleFetch).toHaveBeenCalledWith(
|
||||
`${expoApiV2BaseUrl}workflows/${workflowJobId}/metrics`,
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
json: { stepMetrics },
|
||||
json: { stepMetrics: [stepMetric] },
|
||||
headers: { Authorization: `Bearer ${robotAccessToken}` },
|
||||
retries: 2,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when stepMetrics is empty', async () => {
|
||||
await uploadStepMetricsToWwwAsync({
|
||||
workflowJobId,
|
||||
robotAccessToken,
|
||||
expoApiV2BaseUrl,
|
||||
stepMetrics: [],
|
||||
logger: mockLogger,
|
||||
});
|
||||
it('silently gives up on failure', async () => {
|
||||
mockTurtleFetch.mockRejectedValue(new Error('persistent failure'));
|
||||
|
||||
expect(mockTurtleFetch).not.toHaveBeenCalled();
|
||||
const stepMetric: StepMetric = {
|
||||
metricsId: 'eas/checkout',
|
||||
result: 'failed',
|
||||
durationMs: 500,
|
||||
platform: 'darwin',
|
||||
};
|
||||
|
||||
await expect(
|
||||
uploadStepMetricsToWwwAsync({
|
||||
workflowJobId,
|
||||
robotAccessToken,
|
||||
expoApiV2BaseUrl,
|
||||
stepMetrics: [stepMetric],
|
||||
logger: mockLogger,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -3,18 +3,70 @@ import { Env } from '@expo/eas-build-job';
|
||||
import { load } from '@expo/env';
|
||||
import { LoggerLevel, bunyan } from '@expo/logger';
|
||||
import semver from 'semver';
|
||||
import { expoCommandAsync } from './expoCli';
|
||||
|
||||
export function readAppConfig({
|
||||
projectDir,
|
||||
env,
|
||||
logger,
|
||||
sdkVersion,
|
||||
}: {
|
||||
interface ReadAppConfigParams {
|
||||
projectDir: string;
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
sdkVersion?: string;
|
||||
}): ProjectConfig {
|
||||
}
|
||||
|
||||
export async function readAppConfig(params: ReadAppConfigParams): Promise<ProjectConfig> {
|
||||
const shouldLoadEnvVarsFromDotenvFile =
|
||||
params.sdkVersion && semver.satisfies(params.sdkVersion, '>=49');
|
||||
if (shouldLoadEnvVarsFromDotenvFile) {
|
||||
const envVarsFromDotenvFile = load(params.projectDir) as Env;
|
||||
const env = { ...params.env, ...envVarsFromDotenvFile };
|
||||
params = { ...params, env };
|
||||
}
|
||||
|
||||
// Reading the app config is done in two steps/attempts. We first attempt to run `expo config` as a CLI,
|
||||
try {
|
||||
return await getAppConfigFromExpo(params);
|
||||
} catch (error: any) {
|
||||
params.logger.warn(
|
||||
'Failed to read the app config file with `expo config` command:\n' +
|
||||
`${error?.message || error}`
|
||||
);
|
||||
}
|
||||
|
||||
// If this fails, we fall back to directly using `@expo/config`
|
||||
// This can fail, since it's tied to a specific SDK version, so reading for older SDKs isn't guaranteed to work
|
||||
return getAppConfigFromExpoConfig(params);
|
||||
}
|
||||
|
||||
async function getAppConfigFromExpo({
|
||||
projectDir,
|
||||
env,
|
||||
}: ReadAppConfigParams): Promise<ProjectConfig> {
|
||||
const result = await expoCommandAsync(
|
||||
projectDir,
|
||||
['config', '--json', '--full', '--type', 'public'],
|
||||
{ env }
|
||||
);
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to parse JSON output from 'expo config'.\nOutput: ${result.stdout.slice(0, 500)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!('exp' in parsed)) {
|
||||
throw new Error(`Unexpected output from 'expo config': missing 'exp' field.`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getAppConfigFromExpoConfig({
|
||||
projectDir,
|
||||
env,
|
||||
logger,
|
||||
}: ReadAppConfigParams): ProjectConfig {
|
||||
const originalProcessExit = process.exit;
|
||||
const originalProcessCwd = process.cwd;
|
||||
const originalStdoutWrite = process.stdout.write;
|
||||
@@ -22,11 +74,8 @@ export function readAppConfig({
|
||||
const originalProcessEnv = process.env;
|
||||
|
||||
const stdoutStore: { text: string; level: LoggerLevel }[] = [];
|
||||
const shouldLoadEnvVarsFromDotenvFile = sdkVersion && semver.satisfies(sdkVersion, '>=49');
|
||||
const envVarsFromDotenvFile = shouldLoadEnvVarsFromDotenvFile ? load(projectDir) : {};
|
||||
const newEnvsToUse = { ...env, ...envVarsFromDotenvFile };
|
||||
try {
|
||||
process.env = newEnvsToUse;
|
||||
process.env = env;
|
||||
process.exit = () => {
|
||||
throw new Error('Failed to evaluate app config file');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import resolveFrom from 'resolve-from';
|
||||
import spawnAsync, { type SpawnOptions } from '@expo/turtle-spawn';
|
||||
|
||||
export class ExpoCLIModuleNotFoundError extends Error {}
|
||||
|
||||
function resolveExpoCLI(projectRoot: string): string {
|
||||
try {
|
||||
return (
|
||||
resolveFrom.silent(projectRoot, 'expo/bin/cli') ?? resolveFrom(projectRoot, 'expo/bin/cli.js')
|
||||
);
|
||||
} catch (e: any) {
|
||||
if (e.code === 'MODULE_NOT_FOUND') {
|
||||
throw new ExpoCLIModuleNotFoundError(`The \`expo\` package was not found.`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function expoCommandAsync(
|
||||
projectDir: string,
|
||||
args: string[],
|
||||
options: Omit<SpawnOptions, 'cwd'>
|
||||
) {
|
||||
const expoCliPath = resolveExpoCLI(projectDir);
|
||||
return spawnAsync(expoCliPath, args, {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
...options,
|
||||
env: {
|
||||
...options.env,
|
||||
// NOTE: If we're reading user configs, if a user has set this, it might cause excessive output
|
||||
// that can stop the command from being readable
|
||||
EXPO_DEBUG: '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -120,7 +120,7 @@ This would cause any updates published on the local machine to not be compatible
|
||||
);
|
||||
}
|
||||
|
||||
if (isEASUpdateConfigured(ctx)) {
|
||||
if (await isEASUpdateConfigured(ctx)) {
|
||||
if (ctx.job.updates?.channel !== undefined) {
|
||||
await configureEASExpoUpdatesAsync(ctx);
|
||||
} else {
|
||||
@@ -134,7 +134,7 @@ This would cause any updates published on the local machine to not be compatible
|
||||
} else if (isDevelopmentClient) {
|
||||
// NO-OP: Development clients don't need to have a channel set
|
||||
} else {
|
||||
const easUpdateUrl = ctx.appConfig.updates?.url ?? null;
|
||||
const easUpdateUrl = (await ctx.appConfig).updates?.url ?? null;
|
||||
const jobProfile = ctx.job.buildProfile ?? null;
|
||||
ctx.logger.warn(
|
||||
`This build has an invalid EAS Update configuration: update.url is set to "${easUpdateUrl}" in app config, but a channel is not specified${
|
||||
@@ -220,8 +220,8 @@ export async function getRuntimeVersionAsync(ctx: BuildContext<Job>): Promise<st
|
||||
}
|
||||
}
|
||||
|
||||
export function isEASUpdateConfigured(ctx: BuildContext<Job>): boolean {
|
||||
const rawUrl = ctx.appConfig.updates?.url;
|
||||
export async function isEASUpdateConfigured(ctx: BuildContext<Job>): Promise<boolean> {
|
||||
const rawUrl = (await ctx.appConfig).updates?.url;
|
||||
if (!rawUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import fg from 'fast-glob';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as yaml from 'yaml';
|
||||
import { z } from 'zod';
|
||||
|
||||
const FlowConfigSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const WorkspaceConfigSchema = z.object({
|
||||
flows: z.array(z.string()).optional(),
|
||||
executionOrder: z.record(z.string(), z.unknown()).optional(),
|
||||
includeTags: z.array(z.string()).optional(),
|
||||
excludeTags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type FlowConfig = z.infer<typeof FlowConfigSchema>;
|
||||
type WorkspaceConfig = z.infer<typeof WorkspaceConfigSchema>;
|
||||
|
||||
export async function findMaestroPathsFlowsToExecuteAsync({
|
||||
workingDirectory,
|
||||
flowPath,
|
||||
includeTags: _includeTags,
|
||||
excludeTags: _excludeTags,
|
||||
logger,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
flowPath: string;
|
||||
includeTags: string[] | undefined;
|
||||
excludeTags: string[] | undefined;
|
||||
logger: bunyan;
|
||||
}): Promise<string[]> {
|
||||
const absoluteFlowPath = path.resolve(workingDirectory, flowPath);
|
||||
// If it's a file, just return it (no validation needed)
|
||||
const stat = await fs.stat(absoluteFlowPath);
|
||||
|
||||
if (stat.isFile()) {
|
||||
logger.info(`Found a file: ${path.relative(workingDirectory, absoluteFlowPath)}`);
|
||||
return [absoluteFlowPath];
|
||||
}
|
||||
|
||||
// It's a directory - discover flow files
|
||||
logger.info(`Found a directory: ${path.relative(workingDirectory, absoluteFlowPath)}`);
|
||||
|
||||
// Check for workspace config
|
||||
logger.info(`Searching for workspace config...`);
|
||||
const workspaceConfig = await findAndParseWorkspaceConfigAsync({
|
||||
dirPath: absoluteFlowPath,
|
||||
workingDirectory,
|
||||
logger,
|
||||
});
|
||||
logger.info(`Using workspace config: ${JSON.stringify(workspaceConfig)}`);
|
||||
|
||||
if (workspaceConfig?.executionOrder) {
|
||||
logger.warn(`Execution order is not supported yet. Ignoring.`);
|
||||
}
|
||||
|
||||
logger.info(`Searching for flow files...`);
|
||||
const { flows } = await findAndParseFlowFilesAsync({
|
||||
dirPath: absoluteFlowPath,
|
||||
workingDirectory,
|
||||
workspaceConfig,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (flows.length === 0) {
|
||||
logger.info(
|
||||
`No valid flow files found in: ${path.relative(workingDirectory, absoluteFlowPath)}`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const includeTags = [...(_includeTags ?? []), ...(workspaceConfig?.includeTags ?? [])];
|
||||
const excludeTags = [...(_excludeTags ?? []), ...(workspaceConfig?.excludeTags ?? [])];
|
||||
|
||||
if (includeTags.length === 0 && excludeTags.length === 0) {
|
||||
logger.info(`No tags provided, returning all flows.`);
|
||||
return flows.map(({ path }) => path);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Filtering flows by tags. Tags to include: ${JSON.stringify(includeTags)}. Tags to exclude: ${
|
||||
JSON.stringify(excludeTags) ?? 'none'
|
||||
}.`
|
||||
);
|
||||
return flows
|
||||
.filter(({ config, path: flowPath }) => {
|
||||
const shouldInclude = matchesTags({
|
||||
flowTags: config?.tags ?? [],
|
||||
includeTags,
|
||||
excludeTags,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
shouldInclude
|
||||
? `- ${path.relative(workingDirectory, flowPath)} matches tags, including.`
|
||||
: `- ${path.relative(workingDirectory, flowPath)} does not match tags, excluding.`
|
||||
);
|
||||
|
||||
return shouldInclude;
|
||||
})
|
||||
.map(({ path }) => path);
|
||||
}
|
||||
|
||||
async function findAndParseWorkspaceConfigAsync({
|
||||
dirPath,
|
||||
workingDirectory,
|
||||
logger,
|
||||
}: {
|
||||
dirPath: string;
|
||||
workingDirectory: string;
|
||||
logger: bunyan;
|
||||
}): Promise<WorkspaceConfig | null> {
|
||||
const configPaths = await fg(['config.yaml', 'config.yml'], {
|
||||
cwd: dirPath,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
if (configPaths.length === 0) {
|
||||
logger.info(`No workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
const content = await fs.readFile(configPath, 'utf-8');
|
||||
const configDoc = yaml.parse(content);
|
||||
if (!configDoc) {
|
||||
logger.warn(
|
||||
`No content found in workspace config: ${path.relative(workingDirectory, configPath)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
logger.info(`Using workspace config from: ${path.relative(workingDirectory, configPath)}`);
|
||||
return WorkspaceConfigSchema.parse(configDoc);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Failed to parse workspace config: ${path.relative(workingDirectory, configPath)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`No valid workspace config found in: ${path.relative(workingDirectory, dirPath)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findAndParseFlowFilesAsync({
|
||||
workingDirectory,
|
||||
dirPath,
|
||||
workspaceConfig,
|
||||
logger,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
dirPath: string;
|
||||
workspaceConfig: WorkspaceConfig | null;
|
||||
logger: bunyan;
|
||||
}): Promise<{ flows: { config: FlowConfig; path: string }[] }> {
|
||||
const flows: { config: FlowConfig; path: string }[] = [];
|
||||
|
||||
// Determine flow patterns from config or use default
|
||||
const flowPatterns = workspaceConfig?.flows ?? ['*'];
|
||||
logger.info(`Using flow patterns: ${JSON.stringify(flowPatterns)}`);
|
||||
|
||||
// Use fast-glob to find matching files
|
||||
const matchedFiles = await fg(flowPatterns, {
|
||||
cwd: dirPath,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
ignore: ['*/config.yaml', '*/config.yml'], // Skip workspace config files
|
||||
});
|
||||
|
||||
logger.info(`Found ${matchedFiles.length} potential flow files`);
|
||||
|
||||
// Parse each matched file
|
||||
for (const filePath of matchedFiles) {
|
||||
// Skip non-YAML files
|
||||
const ext = path.extname(filePath);
|
||||
if (ext !== '.yaml' && ext !== '.yml') {
|
||||
logger.info(`Skipping non-YAML file: ${path.relative(workingDirectory, filePath)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await asyncResult(parseFlowFile(filePath));
|
||||
if (result.ok) {
|
||||
logger.info(`Found flow file: ${path.relative(workingDirectory, filePath)}`);
|
||||
flows.push({ config: result.value, path: filePath });
|
||||
} else {
|
||||
logger.info(
|
||||
{ err: result.reason },
|
||||
`Skipping flow file: ${path.relative(workingDirectory, filePath)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { flows };
|
||||
}
|
||||
|
||||
async function parseFlowFile(filePath: string): Promise<FlowConfig> {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const documents = yaml.parseAllDocuments(content);
|
||||
const configDoc = documents[0];
|
||||
if (!configDoc) {
|
||||
throw new Error(`No config section found in ${filePath}`);
|
||||
}
|
||||
return FlowConfigSchema.parse(configDoc.toJS());
|
||||
}
|
||||
|
||||
function matchesTags({
|
||||
flowTags,
|
||||
includeTags,
|
||||
excludeTags,
|
||||
}: {
|
||||
flowTags: string[];
|
||||
includeTags: string[];
|
||||
excludeTags: string[];
|
||||
}): boolean {
|
||||
// Include logic: if includeTags is empty OR flow has any of the include tags
|
||||
const includeMatch = includeTags.length === 0 || includeTags.some(tag => flowTags.includes(tag));
|
||||
|
||||
// Exclude logic: if excludeTags is empty OR flow has none of the exclude tags
|
||||
const excludeMatch = excludeTags.length === 0 || !excludeTags.some(tag => flowTags.includes(tag));
|
||||
|
||||
return includeMatch && excludeMatch;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as PackageManagerUtils from '@expo/package-manager';
|
||||
import { hashFiles } from '@expo/steps';
|
||||
import path from 'path';
|
||||
|
||||
import { findPackagerRootDir } from './packageManager';
|
||||
|
||||
export const GRADLE_CACHE_KEY_PREFIX = 'android-gradle-cache-';
|
||||
|
||||
export async function generateGradleCacheKeyAsync(workingDirectory: string): Promise<string> {
|
||||
const packagerRunDir = findPackagerRootDir(workingDirectory);
|
||||
const manager = PackageManagerUtils.createForProject(packagerRunDir);
|
||||
const lockPath = path.join(packagerRunDir, manager.lockFile);
|
||||
|
||||
try {
|
||||
return `${GRADLE_CACHE_KEY_PREFIX}${hashFiles([lockPath])}`;
|
||||
} catch (err: unknown) {
|
||||
throw new Error(
|
||||
`Failed to read lockfile for Gradle cache key generation: ${err instanceof Error ? err.message : err}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import * as PackageManagerUtils from '@expo/package-manager';
|
||||
import spawnAsync from '@expo/turtle-spawn';
|
||||
import semver from 'semver';
|
||||
@@ -27,6 +28,34 @@ export function resolvePackageManager(directory: string): PackageManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version of a package from the dist-tags.
|
||||
* Returns null if the version cannot be resolved.
|
||||
*/
|
||||
export async function resolvePackageVersionAsync({
|
||||
logger,
|
||||
packageName,
|
||||
distTag,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
packageName: string;
|
||||
distTag: string;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await spawnAsync('npm', ['view', packageName, 'dist-tags', '--json'], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const distTags = JSON.parse(stdout);
|
||||
if (distTag in distTags) {
|
||||
return distTags[distTag];
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
logger.warn(`Unable to resolve version for ${packageName}@${distTag}: ${message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findPackagerRootDir(currentDir: string): string {
|
||||
return PackageManagerUtils.resolveWorkspaceRoot(currentDir) ?? currentDir;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { StepMetricsCollection } from '@expo/steps';
|
||||
import { StepMetric } from '@expo/steps';
|
||||
|
||||
import { turtleFetch } from './turtleFetch';
|
||||
|
||||
@@ -13,29 +13,22 @@ export async function uploadStepMetricsToWwwAsync({
|
||||
workflowJobId: string;
|
||||
robotAccessToken: string;
|
||||
expoApiV2BaseUrl: string;
|
||||
stepMetrics: StepMetricsCollection;
|
||||
stepMetrics: StepMetric[];
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (stepMetrics.length === 0) {
|
||||
logger.debug('No step metrics to upload');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await turtleFetch(
|
||||
new URL(`workflows/${workflowJobId}/metrics`, expoApiV2BaseUrl).toString(),
|
||||
'POST',
|
||||
{
|
||||
json: { stepMetrics },
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
timeout: 20000,
|
||||
headers: { Authorization: `Bearer ${robotAccessToken}` },
|
||||
timeout: 5000,
|
||||
retries: 2,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
logger.info(`Uploaded ${stepMetrics.length} step metrics`);
|
||||
} catch {
|
||||
// Don't display unactionable error to the user, let's send it to Sentry in the future
|
||||
// Don't fail the build for metrics — silently give up
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "18.0.3",
|
||||
"version": "18.5.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "18.0.2"
|
||||
"@expo/steps": "18.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "18.0.1",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -19,10 +19,10 @@
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
|
||||
"test": "jest --config jest.config.ts",
|
||||
"test:watch": "jest --config jest.config.ts --watch",
|
||||
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"fs-extra": "^11.2.0",
|
||||
@@ -33,6 +33,7 @@
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "18.0.2",
|
||||
"version": "18.5.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -26,9 +26,9 @@
|
||||
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "yarn rebuild",
|
||||
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
|
||||
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
|
||||
"test": "jest --passWithNoTests",
|
||||
"clean": "rimraf build node_modules yarn-error.log *.tsbuildinfo"
|
||||
"clean": "rimraf build node_modules yarn-error.log \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "18.0.2",
|
||||
"version": "18.5.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -19,13 +19,13 @@
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && tsc -p tsconfig.build.json",
|
||||
"test": "jest --config jest.config.ts",
|
||||
"test:watch": "jest --config jest.config.ts --watch",
|
||||
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/logger": "18.5.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
@@ -34,6 +34,7 @@
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
|
||||
@@ -24,6 +24,98 @@ describe('StaticWorkflowInterpolationContextZ', () => {
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: '123e4567-e89b-42d3-a456-426614174000',
|
||||
state: 'processing',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts app_store_connect context without build_upload', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('rejects invalid app_store_connect build_upload context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
app_store_connect: {
|
||||
app: {
|
||||
id: '1234567890',
|
||||
},
|
||||
build_upload: {
|
||||
id: 'not-a-uuid',
|
||||
state: 'invalid-state',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid app and account context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
|
||||
@@ -1,30 +1,108 @@
|
||||
import { UserFacingError } from '../errors';
|
||||
import { ErrorCode, ExpoError, SystemError, UserError } from '../errors';
|
||||
import { BuildPhase } from '../logs';
|
||||
|
||||
describe(UserFacingError, () => {
|
||||
type ExpoErrorDetails = ConstructorParameters<typeof ExpoError>[1];
|
||||
|
||||
class TestExpoError extends ExpoError {
|
||||
constructor(message: string, details: ExpoErrorDetails) {
|
||||
super(message, details);
|
||||
}
|
||||
}
|
||||
|
||||
describe(ExpoError, () => {
|
||||
it('stores shared metadata and formats external payload', () => {
|
||||
const cause = new Error('root cause');
|
||||
const error = new TestExpoError('canonical message', {
|
||||
errorCode: 'ERR_CODE',
|
||||
trackingCode: 'TRACKING_CODE',
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
metadata: { packageName: '@typescript-eslint/typescript-estree' },
|
||||
cause,
|
||||
});
|
||||
|
||||
expect(error.errorCode).toBe('ERR_CODE');
|
||||
expect(error.trackingCode).toBe('TRACKING_CODE');
|
||||
expect(error.docsUrl).toBe('https://docs.example.dev');
|
||||
expect(error.metadata).toEqual({
|
||||
packageName: '@typescript-eslint/typescript-estree',
|
||||
});
|
||||
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(error.toExternalExpoError()).toEqual({
|
||||
errorCode: 'ERR_CODE',
|
||||
message: 'canonical message',
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(UserError, () => {
|
||||
it('supports docsUrl in options', () => {
|
||||
const error = new UserFacingError('ERR_CODE', 'message', {
|
||||
const error = new UserError('ERR_CODE', 'message', {
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
});
|
||||
|
||||
expect(error).toBeInstanceOf(ExpoError);
|
||||
expect(error.docsUrl).toBe('https://docs.example.dev');
|
||||
expect(error.cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it('supports cause in options', () => {
|
||||
const cause = new Error('root cause');
|
||||
const error = new UserFacingError('ERR_CODE', 'message', { cause });
|
||||
const error = new UserError('ERR_CODE', 'message', { cause });
|
||||
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it('supports docsUrl and cause in options', () => {
|
||||
it('supports trackingCode and buildPhase in options', () => {
|
||||
const error = new UserError('ERR_CODE', 'message', {
|
||||
trackingCode: 'TRACKING_CODE',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
});
|
||||
|
||||
expect(error.trackingCode).toBe('TRACKING_CODE');
|
||||
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
|
||||
});
|
||||
|
||||
it('supports docsUrl, metadata, and cause in options', () => {
|
||||
const cause = new Error('root cause');
|
||||
const error = new UserFacingError('ERR_CODE', 'message', {
|
||||
const error = new UserError('ERR_CODE', 'message', {
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
metadata: { packageName: 'expo' },
|
||||
cause,
|
||||
});
|
||||
|
||||
expect(error.docsUrl).toBe('https://docs.example.dev');
|
||||
expect(error.metadata).toEqual({ packageName: 'expo' });
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SystemError, () => {
|
||||
it('always formats with SERVER_ERROR and preserves trackingCode', () => {
|
||||
const cause = new Error('root cause');
|
||||
const error = new SystemError('system message', {
|
||||
trackingCode: 'TRACKING_CODE',
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
metadata: { packageName: '@expo/config' },
|
||||
cause,
|
||||
});
|
||||
|
||||
expect(error).toBeInstanceOf(ExpoError);
|
||||
expect(error.errorCode).toBe(ErrorCode.SERVER_ERROR);
|
||||
expect(error.trackingCode).toBe('TRACKING_CODE');
|
||||
expect(error.docsUrl).toBe('https://docs.example.dev');
|
||||
expect(error.metadata).toEqual({ packageName: '@expo/config' });
|
||||
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(error.toExternalExpoError()).toEqual({
|
||||
errorCode: ErrorCode.SERVER_ERROR,
|
||||
message: 'system message',
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -259,6 +259,19 @@ export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
app_store_connect: z
|
||||
.looseObject({
|
||||
app: z.looseObject({
|
||||
id: z.string(),
|
||||
}),
|
||||
build_upload: z
|
||||
.looseObject({
|
||||
id: z.string(),
|
||||
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type StaticWorkflowInterpolationContext = z.infer<
|
||||
|
||||
Loaded 100 of 318 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user