mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9088dbb93 | ||
|
|
f08392888d | ||
|
|
fbcc04eb62 |
No files matched your search
@@ -1,11 +0,0 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "eas-cli"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
mise trust
|
||||
mise install
|
||||
yarn
|
||||
yarn typecheck
|
||||
'''
|
||||
@@ -1,14 +1,9 @@
|
||||
**/build/**
|
||||
**/dist/**
|
||||
**/coverage/**
|
||||
**/fixtures/**
|
||||
!packages/eas-cli/src/commands/build/**
|
||||
!packages/eas-cli/src/build/**
|
||||
|
||||
packages/eas-cli/src/graphql/generated.ts
|
||||
packages/eas-cli/schema/**
|
||||
packages/eas-cli/graphql.schema.json
|
||||
packages/build-tools/schema.graphql
|
||||
|
||||
node_modules
|
||||
ts-declarations
|
||||
@@ -0,0 +1,51 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['universe/node'],
|
||||
plugins: ['async-protect', 'node'],
|
||||
rules: {
|
||||
'no-console': 'warn',
|
||||
'no-constant-condition': ['warn', { checkLoops: false }],
|
||||
'sort-imports': [
|
||||
'warn',
|
||||
{
|
||||
ignoreDeclarationSort: true,
|
||||
},
|
||||
],
|
||||
curly: 'warn',
|
||||
'import/no-cycle': 'error',
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{
|
||||
devDependencies: [
|
||||
'**/jest/**/*',
|
||||
'**/__tests__/**/*',
|
||||
'**/__mocks__/**/*',
|
||||
'**/__integration-tests__/**/*',
|
||||
],
|
||||
},
|
||||
],
|
||||
'import/no-relative-packages': 'error',
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'lodash',
|
||||
message: "Don't use lodash, it's heavy!",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'no-underscore-dangle': ['error', { allow: ['__typename'] }],
|
||||
'async-protect/async-suffix': 'error',
|
||||
'node/no-sync': 'error',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/__tests__/**/*.ts', '**/__mocks__/**/*.ts'],
|
||||
rules: {
|
||||
'async-protect/async-suffix': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -12,8 +12,6 @@ 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,7 +1,6 @@
|
||||
name: CHANGELOG.md PR check
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
@@ -30,7 +29,7 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
core.setFailed('Please add a changelog entry!')
|
||||
- uses: mshick/add-pr-comment@v2.8.2
|
||||
- uses: mshick/add-pr-comment@v2.1.0
|
||||
if: always()
|
||||
with:
|
||||
message-id: changelog-entry-check
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
|
||||
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
|
||||
|
||||
name: Release workflow
|
||||
|
||||
@@ -22,21 +22,149 @@ jobs:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: ${{ github.ref }}
|
||||
draft: true
|
||||
build-linux:
|
||||
name: Build for Linux
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn build
|
||||
- name: Ensure GraphQL schema and generated code is up-to-date
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli
|
||||
run: |
|
||||
yarn verify-graphql-code
|
||||
- name: Build tarballs
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli
|
||||
run: |
|
||||
yarn pretarball-ci
|
||||
yarn oclif pack:tarballs --no-xz --targets linux-x64,linux-arm
|
||||
- id: x64
|
||||
run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
|
||||
- id: arm
|
||||
run: echo "filename=$(ls eas-*-arm.tar.gz)" >> $GITHUB_OUTPUT
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
|
||||
- name: Upload linux-x64 tarball
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
asset_path: packages/eas-cli/dist/${{ steps.x64.outputs.filename }}
|
||||
asset_name: ${{ steps.x64.outputs.filename }}
|
||||
asset_content_type: application/gzip
|
||||
- name: Upload linux-arm tarball
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
asset_path: packages/eas-cli/dist/${{ steps.arm.outputs.filename }}
|
||||
asset_name: ${{ steps.arm.outputs.filename }}
|
||||
asset_content_type: application/gzip
|
||||
build-mac:
|
||||
name: Build for macOS
|
||||
# Use macOS to get the correct platform specific dependencies
|
||||
runs-on: macos-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- run: brew install python-setuptools
|
||||
env:
|
||||
HOMEBREW_NO_AUTO_UPDATE: 1
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn build
|
||||
- name: Build tarballs
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli
|
||||
run: |
|
||||
yarn pretarball-ci
|
||||
yarn oclif pack:tarballs --no-xz --targets darwin-x64,darwin-arm64
|
||||
- id: x64
|
||||
run: echo "filename=$(ls eas-*-x64.tar.gz)" >> $GITHUB_OUTPUT
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
|
||||
- id: arm64
|
||||
run: echo "filename=$(ls eas-*-arm64.tar.gz)" >> $GITHUB_OUTPUT
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli/dist
|
||||
- name: Upload darwin-x64 tarball
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
asset_path: packages/eas-cli/dist/${{ steps.x64.outputs.filename }}
|
||||
asset_name: ${{ steps.x64.outputs.filename }}
|
||||
asset_content_type: application/gzip
|
||||
- name: Upload darwin-arm64 tarball
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
asset_path: packages/eas-cli/dist/${{ steps.arm64.outputs.filename }}
|
||||
asset_name: ${{ steps.arm64.outputs.filename }}
|
||||
asset_content_type: application/gzip
|
||||
build-windows:
|
||||
name: Build for Windows
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: sudo apt-get install nsis
|
||||
- run: yarn build
|
||||
- run: |
|
||||
yarn pretarball-ci
|
||||
yarn oclif pack:win
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli
|
||||
- id: x64
|
||||
run: echo "filename=$(ls eas-*-x64.exe)" >> $GITHUB_OUTPUT
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
|
||||
- id: x86
|
||||
run: echo "filename=$(ls eas-*-x86.exe)" >> $GITHUB_OUTPUT
|
||||
working-directory: ${{ github.workspace }}/packages/eas-cli/dist/win32
|
||||
- name: Upload x64 installer
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
asset_path: packages/eas-cli/dist/win32/${{ steps.x64.outputs.filename }}
|
||||
asset_name: ${{ steps.x64.outputs.filename }}
|
||||
asset_content_type: application/gzip
|
||||
- name: Upload x86 installer
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
asset_path: packages/eas-cli/dist/win32/${{ steps.x86.outputs.filename }}
|
||||
asset_name: ${{ steps.x86.outputs.filename }}
|
||||
asset_content_type: application/gzip
|
||||
publish-to-npm:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
needs: [build-linux, build-mac, build-windows]
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
- name: Build
|
||||
run: yarn build
|
||||
- name: Publish packages to npm
|
||||
run: yarn run -T lerna publish from-package --yes --no-verify-access
|
||||
run: yarn lerna publish from-package --yes --no-verify-access
|
||||
- name: Add latest-eas-build-staging tag
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
@@ -64,12 +192,12 @@ jobs:
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
run: yarn install --immutable
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
- name: Resolve version
|
||||
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
- name: Update changelog
|
||||
working-directory: ./scripts
|
||||
run: yarn release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
|
||||
run: yarn --silent release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
|
||||
- name: Publish release
|
||||
run: gh release edit v$EAS_CLI_VERSION --draft=false -F /tmp/current-changelog
|
||||
env:
|
||||
@@ -96,8 +224,8 @@ jobs:
|
||||
SLACK_CHANNEL: eas-cli
|
||||
SLACK_COLOR: ${{ job.status }}
|
||||
SLACK_ICON: https://github.com/expo-bot.png?size=48
|
||||
SLACK_TITLE: "Released eas-cli@${{ env.EAS_CLI_VERSION }}"
|
||||
SLACK_TITLE: 'Released eas-cli@${{ env.EAS_CLI_VERSION }}'
|
||||
SLACK_MESSAGE: ${{ env.SLACK_CHANGELOG }}
|
||||
SLACK_USERNAME: "EAS CLI CI"
|
||||
SLACK_USERNAME: 'EAS CLI CI'
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
|
||||
SLACK_FOOTER: ""
|
||||
SLACK_FOOTER: ''
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Size
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, opened]
|
||||
concurrency:
|
||||
group: size-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
compare:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- uses: preactjs/compressed-size-action@v2
|
||||
with:
|
||||
build-script: 'build:tarballs:linux'
|
||||
repo-token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
pattern: './packages/eas-cli/dist/*.tar.gz'
|
||||
compression: 'none'
|
||||
strip-hash: "v\\d+.\\d+.\\d+-\\w+-"
|
||||
clean-script: 'clean'
|
||||
@@ -20,6 +20,6 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --immutable
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn verify-graphql-code
|
||||
working-directory: packages/eas-cli
|
||||
@@ -24,5 +24,5 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
- run: yarn install --immutable
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn typecheck
|
||||
+11
-20
@@ -22,14 +22,11 @@ jobs:
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup tools
|
||||
uses: ./.github/actions/setup-mise
|
||||
env:
|
||||
MISE_NODE_VERSION: ${{ matrix.node }}
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
env:
|
||||
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn typecheck
|
||||
- run: |
|
||||
git config --global user.email "[email protected]"
|
||||
@@ -41,37 +38,31 @@ jobs:
|
||||
- name: Run tests (affected)
|
||||
# PRs: only run tests for changed packages + dependents.
|
||||
if: ${{ !matrix.coverage && github.event_name == 'pull_request' }}
|
||||
run: yarn run -T lerna run test --since ${{ github.event.pull_request.base.sha }} --include-dependents
|
||||
run: yarn lerna run test --since ${{ github.event.pull_request.base.sha }} --include-dependents
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
- name: Run tests (all)
|
||||
# Pushes to main: run all package tests.
|
||||
if: ${{ !matrix.coverage && github.event_name == 'push' }}
|
||||
run: yarn run -T lerna run test
|
||||
run: yarn lerna run test
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
- name: Run tests (coverage)
|
||||
# Coverage on PRs and main; per-package reports are uploaded to Codecov.
|
||||
if: ${{ matrix.coverage }}
|
||||
run: yarn run -T lerna run test -- --coverage
|
||||
run: yarn lerna run test -- -- --coverage
|
||||
env:
|
||||
# Oclif seems to cause OOM errors when testing with coverage enabled.
|
||||
# Remove this whenever we solved those OOM errors.
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
- run: yarn lint
|
||||
- run: yarn fmt:check
|
||||
- name: Install scripts dependencies
|
||||
if: ${{ matrix.coverage }}
|
||||
run: yarn install --immutable
|
||||
- run: yarn lint --max-warnings=0
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
working-directory: ./scripts
|
||||
env:
|
||||
YARN_ENABLE_HARDENED_MODE: ${{ matrix.coverage == true && '1' || '0' }}
|
||||
- uses: codecov/codecov-action@v1
|
||||
if: ${{ matrix.coverage }}
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- if: ${{ matrix.coverage }}
|
||||
run: yarn lint-changelog
|
||||
- run: yarn lint-changelog
|
||||
|
||||
notify-slack:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
git checkout main
|
||||
git config --global user.email "[email protected]"
|
||||
git config --global user.name "Expo CI"
|
||||
yarn install --immutable
|
||||
yarn install --frozen-lockfile --check-files
|
||||
yarn build
|
||||
if [[ "$INPUT_VERSION" == "" ]]; then
|
||||
echo "Releasing with default version"
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-mise
|
||||
|
||||
- run: yarn install --immutable
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
|
||||
- run: yarn typecheck
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ dist
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
**/.yarn/install-state.gz
|
||||
*-debug.log
|
||||
*-error.log
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"bracketSameLine": true,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"ignorePatterns": [
|
||||
".github",
|
||||
"lerna.json",
|
||||
"**/build/**",
|
||||
"**/dist/**",
|
||||
"**/coverage/**",
|
||||
"**/fixtures/**",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"categories": {
|
||||
"correctness": "off",
|
||||
"suspicious": "off",
|
||||
"pedantic": "off",
|
||||
"perf": "off",
|
||||
"restriction": "off",
|
||||
"style": "off",
|
||||
"nursery": "off"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"**/build/**",
|
||||
"**/dist/**",
|
||||
"**/coverage/**",
|
||||
"**/fixtures/**",
|
||||
"**/__fixtures__/**",
|
||||
".github/**",
|
||||
"packages/eas-cli/schema/**",
|
||||
"packages/eas-cli/graphql.schema.json",
|
||||
"packages/build-tools/schema.graphql"
|
||||
],
|
||||
"plugins": ["import", "node", "typescript"],
|
||||
"jsPlugins": [
|
||||
{
|
||||
"name": "async-protect",
|
||||
"specifier": "eslint-plugin-async-protect"
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"no-console": "warn",
|
||||
"no-constant-condition": [
|
||||
"warn",
|
||||
{
|
||||
"checkLoops": false
|
||||
}
|
||||
],
|
||||
"sort-imports": [
|
||||
"warn",
|
||||
{
|
||||
"ignoreDeclarationSort": true
|
||||
}
|
||||
],
|
||||
"curly": "warn",
|
||||
"default": "off",
|
||||
"named": "off",
|
||||
"namespace": "off",
|
||||
"no-cycle": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "lodash",
|
||||
"message": "Don't use lodash, it's heavy!"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-underscore-dangle": [
|
||||
"error",
|
||||
{
|
||||
"allow": ["__typename"]
|
||||
}
|
||||
],
|
||||
"async-protect/async-suffix": "error",
|
||||
"node/no-sync": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/__tests__/**/*.ts", "**/__mocks__/**/*.ts"],
|
||||
"rules": {
|
||||
"async-protect/async-suffix": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"bracketSameLine": true,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
Vendored
+2
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"eslint.enable": true,
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/bower_components": true,
|
||||
@@ -6,4 +7,4 @@
|
||||
"**/src/**/build/**": false,
|
||||
"**/src/build/**": false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
defaultSemverRangePrefix: ''
|
||||
nodeLinker: node-modules
|
||||
@@ -3,4 +3,4 @@
|
||||
When working with this repository follow instructions from CLAUDE.md.
|
||||
|
||||
- [./CLAUDE.md](./CLAUDE.md)
|
||||
- Before committing, run the formatter on modified files.
|
||||
|
||||
+5
-184
@@ -6,197 +6,16 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Automatically save the Convex deployment URL as an EAS environment variable when connecting a Convex project. ([#3685](https://github.com/expo/eas-cli/pull/3685) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Update the minimum Expo version required for iOS precompiled modules. ([#3677](https://github.com/expo/eas-cli/pull/3677) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] recommend that users read the docs after connecting Convex ([#3683](https://github.com/expo/eas-cli/pull/3683) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [18.11.0](https://github.com/expo/eas-cli/releases/tag/v18.11.0) - 2026-05-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--simulator` to `eas build:dev` and `eas build:run` to select which iOS simulator to install and run builds on. ([#3637](https://github.com/expo/eas-cli/pull/3637) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix Convex team invite output after skipped or unnecessary invitations. ([#3672](https://github.com/expo/eas-cli/pull/3672) by [@fiberjw](https://github.com/fiberjw))
|
||||
- Make directories marked as read-only writable when creating a tar, to prevent the cross-filesystem issues when extracting the tar. ([#3489](https://github.com/expo/eas-cli/pull/3489) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
## [18.10.0](https://github.com/expo/eas-cli/releases/tag/v18.10.0) - 2026-05-04
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas integrations:convex` commands to manage Convex integrations for EAS projects. ([#3575](https://github.com/expo/eas-cli/pull/3575) by [@fiberjw](https://github.com/fiberjw))
|
||||
- [eas-cli] New command `observe:logs` for custom events. ([#3638](https://github.com/expo/eas-cli/pull/3638) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Create portable project archives on all platforms to normalize cross-platform tar metadata and permissions. ([#3234](https://github.com/expo/eas-cli/pull/3234) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Remove hardcoded `builderEnvironment.image` override in `eas build:resign`. ([#3661](https://github.com/expo/eas-cli/pull/3661) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [eas-cli] Fix `eas update --json` intermittently failing with JSON parse errors during "Computing project fingerprints" by passing `silent: true` to `@expo/fingerprint` to suppress subprocess stdout pollution. ([#3659](https://github.com/expo/eas-cli/pull/3659) by [@Mookiies](https://github.com/Mookiies))
|
||||
|
||||
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
|
||||
|
||||
## [18.9.0](https://github.com/expo/eas-cli/releases/tag/v18.9.0) - 2026-04-30
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas integrations:asc` commands to manage App Store Connect integrations for EAS projects. ([#3558](https://github.com/expo/eas-cli/pull/3558) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Allow `eas build:download` to accept a build ID. ([#3655](https://github.com/expo/eas-cli/pull/3655) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add `--id` alias for `--build-id` flag in `eas build:download`. ([#3656](https://github.com/expo/eas-cli/pull/3656) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [steps] Fix workflow job context interpolation when a string contains multiple `${{ }}` expressions on the same line (for example a changelog built from two context values). ([#3644](https://github.com/expo/eas-cli/pull/3644) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [18.8.1](https://github.com/expo/eas-cli/releases/tag/v18.8.1) - 2026-04-23
|
||||
|
||||
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Support custom params in `observe:events` results. ([#3620](https://github.com/expo/eas-cli/pull/3620) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Add `eas/deploy` function for EAS Hosting web deployments. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/export` function for Expo web exports. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas update:insights <groupId>` command to display launch, crash, unique-user, and payload-size metrics for an update group. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `--insights` flag to `eas update:view <groupId>` to append the same metrics below the existing output. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `eas channel:insights --channel <name> --runtime-version <version>` command to display adoption and crash metrics for a channel + runtime version. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix `metadata:push` not deleting video previews from App Store Connect when removed from config. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Warn and skip unknown preview types in `metadata:push` with a helpful suggestion when the `APP_` screenshot prefix is mistakenly used. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Delete all existing previews in a preview set before uploading to avoid Apple's "Too many app previews" error. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add existing capability identifiers. ([#3615](https://github.com/expo/eas-cli/pull/3615) by [@jakex7](https://github.com/jakex7))
|
||||
|
||||
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
|
||||
|
||||
## [18.6.0](https://github.com/expo/eas-cli/releases/tag/v18.6.0) - 2026-04-10
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add missing Apple metadata attributes for age ratings and content descriptions. ([#3584](https://github.com/expo/eas-cli/pull/3584) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Add App Clip metadata support to `metadata:push` and `metadata:pull` (default experience action, per-locale subtitle and header image, App Store review invocation URLs). ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [build-tools] Add `working_directory` input to `eas/build` function group for custom builds. ([#3582](https://github.com/expo/eas-cli/pull/3582) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [steps] Coerce numeric env values to strings in workflow step configuration. ([#3583](https://github.com/expo/eas-cli/pull/3583) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools][eas-cli] Detect iOS Development provisioning profiles and set correct code signing identity instead of treating them as Ad Hoc. ([#3496](https://github.com/expo/eas-cli/pull/3496) by [@qwertey6](https://github.com/qwertey6))
|
||||
- [build-tools] Prevent detecting Yarn Modern as Classic based on lockfile ([#3572](https://github.com/expo/eas-cli/pull/3572) by [@kitten](https://github.com/kitten))
|
||||
- [build-tools] Early stop `eas/start_android_emulator` when Android emulator host setup is invalid. ([#3580](https://github.com/expo/eas-cli/pull/3580) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.18` to fix `metadata:push` failing on `ageRatingDeclarations` due to the removed `gamblingAndContests` attribute. ([#3588](https://github.com/expo/eas-cli/pull/3588) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.19` to fix image and video uploads via `metadata:push` getting stuck in `AWAITING_UPLOAD` state. The asset client was inheriting Bearer token injection from the App Store Connect API client, which caused S3 presigned URL uploads to be silently mishandled by Apple's CDN. Fixes screenshots, previews, and App Clip header image uploads. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] `metadata:pull` now preserves screenshot, video preview, and App Clip header image entries with placeholder paths when the asset is in an unrendered state, so users can recover broken records by replacing the file or removing the entry instead of having entries silently dropped from `store.config.json`. ([#3590](https://github.com/expo/eas-cli/pull/3590) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Surface hosting deployment's asset upload errors sooner ([#3600](https://github.com/expo/eas-cli/pull/3600) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [18.5.0](https://github.com/expo/eas-cli/releases/tag/v18.5.0) - 2026-04-02
|
||||
|
||||
### 🎉 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))
|
||||
- [eas-cli] Add `observe:metrics`, `observe:events`, and `observe:versions` commands. ([#3401](https://github.com/expo/eas-cli/pull/3401) by [@ubax](https://github.com/ubax)), [@douglowder](https://github.com/douglowder)
|
||||
|
||||
### 🐛 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
|
||||
|
||||
- Revert authorization code flow with PKCE for browser-based login. ([#3422](https://github.com/expo/eas-cli/pull/3422) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
|
||||
## [18.0.3](https://github.com/expo/eas-cli/releases/tag/v18.0.3) - 2026-02-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
|
||||
## [18.0.1](https://github.com/expo/eas-cli/releases/tag/v18.0.1) - 2026-02-12
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- Drop support for Node 18 and Node 19. Require Node 20+. ([#3343](https://github.com/expo/eas-cli/pull/3343) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--browser` flag to `eas login` for browser-based authentication. ([#3312](https://github.com/expo/eas-cli/pull/3312) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
|
||||
- Hide progress bar in build credits warning when usage reaches 100%. ([#3371](https://github.com/expo/eas-cli/pull/3371) by [@mackenco](https://github.com/mackenco))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- Hide progress bar in build credits warning when usage reaches 100%. ([#3371](https://github.com/expo/eas-cli/pull/3371) by [@mackenco](https://github.com/mackenco))
|
||||
- Fix `--non-interactive` mode prompting for git initialization. Now auto-initializes git with an initial commit when needed. Set `EAS_NO_VCS=1` to skip. ([#3387](https://github.com/expo/eas-cli/pull/3387) by [@evanbacon](https://github.com/evanbacon))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
@@ -206,6 +25,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- Add `--browser` flag to `eas login` for browser-based authentication. ([#3312](https://github.com/expo/eas-cli/pull/3312) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- List available options in non-interactive mode errors instead of generic "unable to select" messages, making CLI usable by agents and scripts. ([#3359](https://github.com/expo/eas-cli/pull/3359) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- Hidden beta version of new `eas account:usage` command for viewing account usage and billing estimates. ([#3334](https://github.com/expo/eas-cli/pull/3334) by [@douglowder](https://github.com/douglowder), [@EvanBacon](https://github.com/EvanBacon))
|
||||
- Add `--no-bytecode` and `--source-maps [mode]` flags to `eas update`. ([#3339](https://github.com/expo/eas-cli/pull/3339) by [@brentvatne](https://github.com/brentvatne))
|
||||
@@ -1789,6 +1609,7 @@ 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
|
||||
|
||||
|
||||
@@ -9,21 +9,25 @@ This is a **Lerna-based monorepo** containing the EAS CLI and all supporting bui
|
||||
### CLI & Configuration Packages
|
||||
|
||||
1. **[packages/eas-cli](./packages/eas-cli/CLAUDE.md)** - The main CLI tool (published as `eas-cli`)
|
||||
|
||||
- 95+ commands organized by domain (build, submit, update, channel, credentials, etc.)
|
||||
- Built on oclif (Open CLI Framework)
|
||||
- Entry point: `packages/eas-cli/bin/run`
|
||||
|
||||
2. **[packages/eas-json](./packages/eas-json/CLAUDE.md)** - EAS configuration parser (published as `@expo/eas-json`)
|
||||
|
||||
- Validates `eas.json` configuration files using Joi schemas
|
||||
- Manages build, submit, and deploy profiles
|
||||
- Supports JSON5 via golden-fleece
|
||||
|
||||
3. **[packages/eas-build-cache-provider](./packages/eas-build-cache-provider/CLAUDE.md)** - Build cache plugin (published as `eas-build-cache-provider`)
|
||||
|
||||
- Optimizes build caching for Expo CLI
|
||||
|
||||
### Build Execution Packages
|
||||
|
||||
4. **[packages/eas-build-job](./packages/eas-build-job)** - Build job definitions (published as `@expo/eas-build-job`)
|
||||
|
||||
- Defines all data structures for build operations (Job, BuildPhase, BuildMode, Platform, Workflow)
|
||||
- Provides type definitions and validation schemas (Zod, Joi)
|
||||
- Contains BuildPhase enum that defines the traditional build pipeline stages
|
||||
@@ -31,6 +35,7 @@ This is a **Lerna-based monorepo** containing the EAS CLI and all supporting bui
|
||||
- **Foundation that all other build packages depend on**
|
||||
|
||||
5. **[packages/build-tools](./packages/build-tools)** - Build execution engine (published as `@expo/build-tools`)
|
||||
|
||||
- Orchestrates all build operations through `BuildContext<T extends Job>`
|
||||
- Contains platform-specific builders: `androidBuilder()`, `iosBuilder()`, `runCustomBuildAsync()`
|
||||
- Manages build phases, artifact uploading, caching, credentials
|
||||
@@ -38,6 +43,7 @@ This is a **Lerna-based monorepo** containing the EAS CLI and all supporting bui
|
||||
- Integrates with GraphQL API
|
||||
|
||||
6. **[packages/steps](./packages/steps)** - Custom build workflow engine (published as `@expo/steps`)
|
||||
|
||||
- Framework for defining and executing custom build steps
|
||||
- Key abstractions:
|
||||
- `BuildWorkflow`: Orchestrates sequential step execution
|
||||
@@ -49,6 +55,7 @@ This is a **Lerna-based monorepo** containing the EAS CLI and all supporting bui
|
||||
- Parses build configs from YAML/JSON
|
||||
|
||||
7. **[packages/local-build-plugin](./packages/local-build-plugin)** - Local build execution (published as `eas-cli-local-build-plugin`)
|
||||
|
||||
- Allows running EAS builds locally on developer machines
|
||||
- Entry point: `packages/local-build-plugin/src/main.ts`
|
||||
- Sets `EAS_BUILD_RUNNER=local-build-plugin` environment variable
|
||||
@@ -62,6 +69,7 @@ This is a **Lerna-based monorepo** containing the EAS CLI and all supporting bui
|
||||
### Supporting Packages
|
||||
|
||||
9. **[packages/logger](./packages/logger)** - Bunyan-based structured logging (published as `@expo/logger`)
|
||||
|
||||
- Used by all build packages
|
||||
|
||||
10. **[packages/downloader](./packages/downloader)** - HTTP file downloading with retry logic (published as `@expo/downloader`)
|
||||
@@ -98,8 +106,8 @@ yarn start-allow-unused
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
yarn run -T lerna run test # Run all tests across packages
|
||||
yarn run -T lerna run test -- --watch # Watch mode across packages (heavier)
|
||||
yarn lerna run test # Run all tests across packages
|
||||
yarn lerna run test -- --watch # Watch mode across packages (heavier)
|
||||
#
|
||||
# If you use `yarn test`, remember to pass args through two separators:
|
||||
# yarn test -- -- --watch
|
||||
@@ -116,8 +124,7 @@ cd packages/steps && yarn test
|
||||
|
||||
```bash
|
||||
yarn typecheck # TypeScript type check all packages
|
||||
yarn lint # Oxlint across all packages
|
||||
yarn fmt:check # Oxfmt format check
|
||||
yarn lint # ESLint across all packages
|
||||
```
|
||||
|
||||
### Running EAS CLI Locally
|
||||
@@ -296,14 +303,11 @@ export EXPO_DEBUG=1 # Enable debug logging
|
||||
|
||||
### Validation
|
||||
|
||||
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:
|
||||
All changes should be validated with TypeScript and the linter before committing:
|
||||
|
||||
```bash
|
||||
yarn fmt # Apply Oxfmt formatting
|
||||
yarn typecheck # Validate TypeScript types
|
||||
yarn lint # Run Oxlint
|
||||
yarn fmt:check # Run Oxfmt format check
|
||||
yarn lint # Run ESLint
|
||||
```
|
||||
|
||||
### Logging
|
||||
@@ -351,5 +355,5 @@ See `LICENSE` (MIT) and `LICENSE-BUSL` (BUSL-1.1) for details.
|
||||
## Important Notes
|
||||
|
||||
- **Node Version**: Requires Node.js (managed via Mise)
|
||||
- **Package Manager**: Uses Yarn 4.12.0
|
||||
- **Package Manager**: Uses Yarn 1.22.21
|
||||
- **Compilation Target**: CommonJS with Node resolution
|
||||
+1
-1
@@ -69,7 +69,7 @@ See [`CLAUDE.md`](./CLAUDE.md) for how to set up your environment when making ch
|
||||
|
||||
## Testing
|
||||
|
||||
From the repository root, run `yarn run -T lerna run test`. For a single package, run `yarn test` in that package directory.
|
||||
From the repository root, run `yarn lerna run test`. For a single package, run `yarn test` in that package directory.
|
||||
|
||||
## Releasing
|
||||
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "18.11.0",
|
||||
"version": "17.0.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
[env]
|
||||
_.path = "./tools/bin"
|
||||
MISE_NODE_COREPACK = "true"
|
||||
|
||||
[tools]
|
||||
node = "20.19.4"
|
||||
yarn = "1.22.22"
|
||||
+11
-12
@@ -6,33 +6,32 @@
|
||||
],
|
||||
"scripts": {
|
||||
"build": "lerna run build",
|
||||
"build:tarballs:linux": "yarn build-for-size-check",
|
||||
"build-for-size-check": "yarn build && yarn workspace eas-cli pretarball-ci && CLI_SIZE_CHECK=1 yarn workspace eas-cli oclif pack:tarballs --no-xz --targets linux-x64",
|
||||
"typecheck": "lerna run typecheck",
|
||||
"start": "lerna run watch --stream --parallel",
|
||||
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
|
||||
"watch": "yarn start",
|
||||
"eas": "packages/eas-cli/bin/run",
|
||||
"lint": "oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json .",
|
||||
"fmt": "oxfmt .",
|
||||
"fmt:check": "oxfmt --check .",
|
||||
"lint": "eslint .",
|
||||
"lint-changelog": "./scripts/bin/run lint-changelog",
|
||||
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
|
||||
"release": "lerna run rebuild && ./scripts/bin/run release",
|
||||
"test": "lerna run test",
|
||||
"clean": "lerna run clean && rimraf node_modules coverage"
|
||||
"clean": "lerna run clean && rm -rf node_modules coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/node": "20.14.8",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-config-universe": "13.0.0",
|
||||
"eslint-plugin-async-protect": "3.1.0",
|
||||
"eslint-plugin-node": "11.1.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
"lerna": "9.0.3",
|
||||
"oclif": "^4.22.85",
|
||||
"oxfmt": "0.28.0",
|
||||
"oxlint": "1.43.0",
|
||||
"oxlint-tsgolint": "0.11.1",
|
||||
"rimraf": "3.0.2",
|
||||
"oclif": "^3.9.0",
|
||||
"prettier": "3.1.1",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"packageManager": "[email protected]"
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
{
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"jsPlugins": [
|
||||
{
|
||||
"name": "async-protect",
|
||||
"specifier": "eslint-plugin-async-protect"
|
||||
}
|
||||
],
|
||||
"extends": "../../.eslintrc.js",
|
||||
"rules": {
|
||||
"async-protect/async-suffix": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
import baseConfig from '../../../jest/jest.shared.config';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
import baseConfig from '../../../jest/jest.shared.config';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.11.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/build-tools"
|
||||
},
|
||||
"version": "17.0.0",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"resources",
|
||||
"templates",
|
||||
"bin"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
"prewatch": "yarn gql",
|
||||
@@ -24,36 +21,37 @@
|
||||
"prebuild": "yarn gql",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"prepack": "rm -rf dist && 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": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
|
||||
"clean": "rm -rf 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"
|
||||
},
|
||||
"author": "Expo <[email protected]>",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.8.0",
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/downloader": "17.0.0",
|
||||
"@expo/eas-build-job": "17.0.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/logger": "17.0.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.9.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@expo/steps": "17.0.0",
|
||||
"@expo/template-file": "17.0.0",
|
||||
"@expo/turtle-spawn": "17.0.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",
|
||||
@@ -61,7 +59,6 @@
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-forge": "^1.3.1",
|
||||
"node-stream-zip": "1.15.0",
|
||||
"nullthrows": "^1.1.1",
|
||||
"plist": "^3.1.0",
|
||||
"promise-limit": "^2.7.0",
|
||||
@@ -75,7 +72,7 @@
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/repack-app": "~0.4.1",
|
||||
"@expo/repack-app": "~0.2.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
@@ -91,7 +88,6 @@
|
||||
"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,7 +19,3 @@ export enum LoggerLevel {
|
||||
ERROR = 'error',
|
||||
FATAL = 'fatal',
|
||||
}
|
||||
|
||||
export enum PipeMode {
|
||||
COMBINED = 'combined',
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { fs } from 'memfs';
|
||||
|
||||
const fsRealpath = fs.realpath;
|
||||
(fsRealpath as any).native = fsRealpath;
|
||||
|
||||
const fsRm = (path: string, options: object): Promise<void> => {
|
||||
return fs.promises.rm(path, options);
|
||||
};
|
||||
|
||||
module.exports = { ...fs.promises, rm: fsRm };
|
||||
module.exports = { ...fs.promises, realpath: fsRealpath, rm: fsRm };
|
||||
@@ -1,14 +1,9 @@
|
||||
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', () => {
|
||||
@@ -39,122 +34,4 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
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), LC_ALL: 'C.UTF-8' },
|
||||
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx) },
|
||||
});
|
||||
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
|
||||
adjustOOMScore(spawnPromise, logger);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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,14 +26,8 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
},
|
||||
'/fake/path'
|
||||
);
|
||||
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]',
|
||||
});
|
||||
expect(err.errorCode).toBe('NPM_CORRUPTED_PACKAGE');
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
});
|
||||
|
||||
it('detects log for invalid bundler and reports it to user', async () => {
|
||||
@@ -51,11 +45,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).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();
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
|
||||
});
|
||||
|
||||
it('does not detect errors if they show up in different build phase', async () => {
|
||||
@@ -73,10 +63,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
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).toBeUndefined();
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
});
|
||||
|
||||
it('detects npm cache error if cache is enabled', async () => {
|
||||
@@ -95,11 +82,8 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
},
|
||||
'/fake/path'
|
||||
);
|
||||
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');
|
||||
expect(err.errorCode).toBe('NPM_CACHE_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
});
|
||||
|
||||
it('does not detect npm cache error if cache is disabled', async () => {
|
||||
@@ -119,10 +103,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
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).toBeUndefined();
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
});
|
||||
|
||||
it('detects xcode line error', async () => {
|
||||
@@ -145,7 +126,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/path/to/'
|
||||
);
|
||||
expect(err.errorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
expect(err.userFacingErrorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
|
||||
});
|
||||
|
||||
it('detects minimum deployment target error correctly', async () => {
|
||||
@@ -163,7 +144,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
|
||||
});
|
||||
|
||||
it('detects provisioning profile mismatch error correctly', async () => {
|
||||
@@ -181,7 +162,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
|
||||
});
|
||||
|
||||
it('detects generic "Run Fastlane" error for resign correctly', async () => {
|
||||
@@ -197,7 +178,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
|
||||
});
|
||||
|
||||
it('detects build error in "Run Fastlane" phase correctly', async () => {
|
||||
@@ -213,7 +194,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
|
||||
});
|
||||
|
||||
it('detects provisioning profile mismatch error correctly', async () => {
|
||||
@@ -259,8 +240,8 @@ note`;
|
||||
'/path/to'
|
||||
);
|
||||
expect(err.errorCode).toBe('XCODE_BUILD_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
expect(err.message)
|
||||
expect(err.userFacingErrorCode).toBe('XCODE_BUILD_ERROR');
|
||||
expect(err.userFacingMessage)
|
||||
.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
|
||||
@@ -282,9 +263,9 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
|
||||
'/fake/path'
|
||||
);
|
||||
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
|
||||
expect(err.trackingCode).toBe('MAVEN_CACHE_ERROR');
|
||||
expect(err.message).toBe(
|
||||
expect(err.errorCode).toBe('MAVEN_CACHE_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
|
||||
expect(err.userFacingMessage).toBe(
|
||||
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
|
||||
);
|
||||
});
|
||||
@@ -304,8 +285,8 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
|
||||
);
|
||||
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
expect(err.message).toBe(
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
|
||||
expect(err.userFacingMessage).toBe(
|
||||
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,8 +6,7 @@ import { ErrorContext, ErrorHandler } from './errors.types';
|
||||
export class TrackedBuildError extends Error {
|
||||
constructor(
|
||||
public errorCode: string,
|
||||
public message: string,
|
||||
public metadata?: Record<string, unknown>
|
||||
public message: string
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
@@ -155,9 +154,7 @@ 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]}`, {
|
||||
packageName: match[1],
|
||||
}),
|
||||
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`),
|
||||
})),
|
||||
...[BuildPhase.INSTALL_DEPENDENCIES, BuildPhase.PREBUILD].map(phase => ({
|
||||
phase,
|
||||
|
||||
@@ -67,26 +67,40 @@ export async function resolveBuildPhaseErrorAsync(
|
||||
logLines: string[],
|
||||
errorContext: ErrorContext,
|
||||
buildLogsDirectory: string
|
||||
): Promise<errors.ExpoError> {
|
||||
): Promise<errors.BuildError> {
|
||||
const { phase } = errorContext;
|
||||
if (error instanceof errors.ExpoError) {
|
||||
error.buildPhase ??= phase;
|
||||
if (error instanceof errors.BuildError) {
|
||||
return error;
|
||||
}
|
||||
const xcodeBuildLogs = await maybeReadXcodeBuildLogs(phase, buildLogsDirectory);
|
||||
const userError =
|
||||
resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
|
||||
new errors.UnknownError(errorContext.phase);
|
||||
const userFacingError =
|
||||
error instanceof errors.UserFacingError
|
||||
? error
|
||||
: resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
|
||||
new errors.UnknownError(errorContext.phase);
|
||||
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
|
||||
|
||||
const trackingCode =
|
||||
buildError && buildError.errorCode !== userError.errorCode ? buildError.errorCode : undefined;
|
||||
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;
|
||||
|
||||
return new errors.UserError(userError.errorCode, userError.message, {
|
||||
trackingCode,
|
||||
docsUrl: userError.docsUrl,
|
||||
return new errors.BuildError(message, {
|
||||
errorCode,
|
||||
userFacingErrorCode: userFacingError.errorCode,
|
||||
userFacingMessage: userFacingError.message,
|
||||
docsUrl: userFacingError.docsUrl,
|
||||
innerError: error,
|
||||
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 UserError = errors.UserError;
|
||||
import UserFacingError = errors.UserFacingError;
|
||||
|
||||
export const userErrorHandlers: ErrorHandler<UserError>[] = [
|
||||
export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
{
|
||||
platform: Platform.IOS,
|
||||
phase: BuildPhase.INSTALL_PODS,
|
||||
@@ -12,12 +12,12 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
|
||||
// example log:
|
||||
// [!] `React` requires CocoaPods version `>= 1.10.1`, which is not satisfied by your current version, `1.10.0`.
|
||||
createError: () =>
|
||||
new UserError(
|
||||
new UserFacingError(
|
||||
'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"
|
||||
- switching to an image that supports that version under key "image"`,
|
||||
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
|
||||
'https://docs.expo.dev/build-reference/eas-json/'
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -27,10 +27,10 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
|
||||
// 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 UserError(
|
||||
new UserFacingError(
|
||||
'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/' }
|
||||
'https://docs.expo.dev/build-reference/eas-json/'
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -40,7 +40,7 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
|
||||
// > 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 UserError(
|
||||
new UserFacingError(
|
||||
'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<UserError>[] = [
|
||||
// > 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 UserError(
|
||||
new UserFacingError(
|
||||
'EAS_BUILD_INVALID_KEYSTORE_ALIAS_ERROR',
|
||||
'The alias specified for this keystore does not exist. Make sure you specified the correct value.'
|
||||
),
|
||||
@@ -67,10 +67,10 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
|
||||
// [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 UserError(
|
||||
new UserFacingError(
|
||||
'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' }
|
||||
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables'
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -82,10 +82,10 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
|
||||
regexp:
|
||||
/File google-services\.json is missing\. The Google Services Plugin cannot function without it/,
|
||||
createError: () =>
|
||||
new UserError(
|
||||
new UserFacingError(
|
||||
'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' }
|
||||
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables'
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -95,10 +95,10 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
|
||||
// [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 UserError(
|
||||
new UserFacingError(
|
||||
'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' }
|
||||
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables'
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -113,7 +113,7 @@ export const userErrorHandlers: ErrorHandler<UserError>[] = [
|
||||
regexp:
|
||||
/Specs satisfying the `(.*)` dependency were found, but they required a higher minimum deployment target/,
|
||||
createError: (_, { job }) => {
|
||||
return new UserError(
|
||||
return new UserFacingError(
|
||||
'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 UserError(
|
||||
return new UserFacingError(
|
||||
'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 UserError(
|
||||
return new UserFacingError(
|
||||
'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 UserError(
|
||||
return new UserFacingError(
|
||||
'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 UserError(
|
||||
return new UserFacingError(
|
||||
'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,23 +230,17 @@ 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 UserError(
|
||||
? new UserFacingError(
|
||||
'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.`,
|
||||
{
|
||||
docsUrl:
|
||||
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
|
||||
}
|
||||
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations'
|
||||
)
|
||||
: new UserError(
|
||||
: new UserFacingError(
|
||||
'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`,
|
||||
{
|
||||
docsUrl:
|
||||
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
|
||||
}
|
||||
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations'
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -254,7 +248,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
|
||||
phase: BuildPhase.RUN_GRADLEW,
|
||||
regexp: /.*/,
|
||||
createError: () =>
|
||||
new UserError(
|
||||
new UserFacingError(
|
||||
errors.ErrorCode.UNKNOWN_GRADLE_ERROR,
|
||||
'Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.'
|
||||
),
|
||||
@@ -265,7 +259,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 UserError(
|
||||
new UserFacingError(
|
||||
'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 +270,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
|
||||
mode: BuildMode.RESIGN,
|
||||
regexp: /.*/,
|
||||
createError: () =>
|
||||
new UserError(
|
||||
new UserFacingError(
|
||||
errors.ErrorCode.UNKNOWN_FASTLANE_RESIGN_ERROR,
|
||||
`The "Run fastlane" step failed with an unknown error.`
|
||||
),
|
||||
@@ -297,7 +291,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 UserError(
|
||||
new UserFacingError(
|
||||
'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 +303,7 @@ To resolve this issue, downgrade to an older Xcode version using the "image" fie
|
||||
phase: BuildPhase.RUN_FASTLANE,
|
||||
regexp: /.*/,
|
||||
createError: () =>
|
||||
new UserError(
|
||||
new UserFacingError(
|
||||
errors.ErrorCode.UNKNOWN_FASTLANE_ERROR,
|
||||
`The "Run fastlane" step failed with an unknown error. Refer to "Xcode Logs" below for additional, more detailed logs.`
|
||||
),
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
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,12 +1,10 @@
|
||||
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';
|
||||
|
||||
@@ -76,60 +74,4 @@ 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,21 +10,13 @@ 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,
|
||||
restoreGradleCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveGradleCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import {
|
||||
injectConfigureVersionGradleConfig,
|
||||
injectCredentialsGradleConfig,
|
||||
warnIfLegacyEasBuildGradleExists,
|
||||
} from '../steps/utils/android/gradleConfig';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
@@ -85,12 +77,6 @@ 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 () => {
|
||||
@@ -103,7 +89,7 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
appConfig: await ctx.appConfig,
|
||||
appConfig: ctx.appConfig,
|
||||
platform: ctx.job.platform,
|
||||
workflow: ctx.job.type,
|
||||
env: ctx.env,
|
||||
@@ -115,22 +101,10 @@ 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 injectCredentialsGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
await configureBuildGradle(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -206,12 +180,6 @@ 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,7 +94,6 @@ 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;
|
||||
|
||||
@@ -16,7 +16,6 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
|
||||
import { installPods } from '../ios/pod';
|
||||
import { downloadApplicationArchiveAsync } from '../ios/resign';
|
||||
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
|
||||
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
@@ -52,7 +51,6 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
const evictUsedBefore = new Date();
|
||||
const credentialsManager = new CredentialsManager(ctx);
|
||||
const workingDirectory = ctx.getReactNativeProjectDirectory();
|
||||
let fastlaneResult: { derivedDataPath: string; workspacePath: string } | undefined;
|
||||
try {
|
||||
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
|
||||
return await credentialsManager.prepare();
|
||||
@@ -105,7 +103,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
return await resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync({
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
logger: ctx.logger,
|
||||
appConfig: await ctx.appConfig,
|
||||
appConfig: ctx.appConfig,
|
||||
platform: ctx.job.platform,
|
||||
workflow: ctx.job.type,
|
||||
env: ctx.env,
|
||||
@@ -148,10 +146,10 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
|
||||
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
|
||||
const scheme = resolveScheme(ctx);
|
||||
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
|
||||
return await runFastlaneGym(ctx, {
|
||||
await runFastlaneGym(ctx, {
|
||||
credentials,
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
@@ -173,18 +171,6 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.env.EXPERIMENTAL_EAS_XCACTIVITYLOG === '1') {
|
||||
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
|
||||
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
logger: ctx.logger,
|
||||
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
|
||||
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
|
||||
});
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { BuildJob } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
|
||||
import { resolveEasCommandPrefixAndEnvAsync } from '../../utils/easCli';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/easCli', () => ({
|
||||
...jest.requireActual('../../utils/easCli'),
|
||||
resolveEasCommandPrefixAndEnvAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('easBuildInternal', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
|
||||
cmd: 'npx',
|
||||
args: ['-y', 'eas-cli@latest'],
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('calls resolveEasCommandPrefixAndEnvAsync in runEasBuildInternalAsync', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const job = {
|
||||
platform: 'android',
|
||||
secrets: { robotAccessToken: 'token' },
|
||||
} as unknown as BuildJob;
|
||||
|
||||
await expect(
|
||||
runEasBuildInternalAsync({
|
||||
job,
|
||||
logger,
|
||||
env: {},
|
||||
cwd: '/tmp/project',
|
||||
})
|
||||
).rejects.toThrow('build profile is missing in a build from git-based integration.');
|
||||
|
||||
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls resolveEasCommandPrefixAndEnvAsync in resolveEnvFromBuildProfileAsync', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
const ctx = {
|
||||
logger,
|
||||
env: {},
|
||||
job: { platform: 'ios' },
|
||||
} as any;
|
||||
|
||||
await expect(resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' })).rejects.toThrow(
|
||||
'build profile is missing in a build from git-based integration.'
|
||||
);
|
||||
|
||||
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ export async function eagerBundleAsync({
|
||||
export function shouldUseEagerBundle(metadata: Metadata | null | undefined): boolean {
|
||||
return Boolean(
|
||||
!metadata?.developmentClient &&
|
||||
metadata?.sdkVersion &&
|
||||
semver.satisfies(metadata?.sdkVersion, '>=52')
|
||||
metadata?.sdkVersion &&
|
||||
semver.satisfies(metadata?.sdkVersion, '>=52')
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildJob,
|
||||
EasCliNpmTags,
|
||||
Env,
|
||||
Metadata,
|
||||
sanitizeBuildJob,
|
||||
sanitizeMetadata,
|
||||
} from '@expo/eas-build-job';
|
||||
import { PipeMode, bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -7,7 +14,7 @@ import Joi from 'joi';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { resolveEasCommandPrefixAndEnvAsync } from '../utils/easCli';
|
||||
import { isAtLeastNpm7Async } from '../utils/packageManager';
|
||||
|
||||
const EasBuildInternalResultSchema = Joi.object<{ job: object; metadata: object }>({
|
||||
job: Joi.object().unknown(),
|
||||
@@ -42,42 +49,38 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
|
||||
autoSubmitArgs.push('--auto-submit');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawn(
|
||||
cmd,
|
||||
[
|
||||
...args,
|
||||
'build:internal',
|
||||
'--platform',
|
||||
job.platform,
|
||||
'--profile',
|
||||
buildProfile,
|
||||
...autoSubmitArgs,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...env,
|
||||
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
|
||||
.robotAccessToken,
|
||||
...extraEnv,
|
||||
EAS_PROJECT_ROOT: projectRootOverride,
|
||||
},
|
||||
logger,
|
||||
// This prevents printing stdout with job secrets and credentials to logs.
|
||||
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
|
||||
}
|
||||
);
|
||||
const result = await spawn(
|
||||
cmd,
|
||||
[
|
||||
...args,
|
||||
'build:internal',
|
||||
'--platform',
|
||||
job.platform,
|
||||
'--profile',
|
||||
buildProfile,
|
||||
...autoSubmitArgs,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...env,
|
||||
EXPO_TOKEN: nullthrows(job.secrets, 'Secrets must be defined for non-custom builds')
|
||||
.robotAccessToken,
|
||||
...extraEnv,
|
||||
EAS_PROJECT_ROOT: projectRootOverride,
|
||||
},
|
||||
logger,
|
||||
// This prevents printing stdout with job secrets and credentials to logs.
|
||||
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
|
||||
}
|
||||
);
|
||||
|
||||
const stdout = result.stdout.toString();
|
||||
const parsed = JSON.parse(stdout);
|
||||
return validateEasBuildInternalResult({
|
||||
result: parsed,
|
||||
oldJob: job,
|
||||
});
|
||||
} catch (err: any) {
|
||||
throw new Error('Failed to run eas build:internal');
|
||||
}
|
||||
const stdout = result.stdout.toString();
|
||||
const parsed = JSON.parse(stdout);
|
||||
return validateEasBuildInternalResult({
|
||||
result: parsed,
|
||||
oldJob: job,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
|
||||
@@ -118,6 +121,33 @@ export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
|
||||
return env;
|
||||
}
|
||||
|
||||
async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
|
||||
cmd: string;
|
||||
args: string[];
|
||||
extraEnv: Env;
|
||||
}> {
|
||||
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
|
||||
if (process.env.ENVIRONMENT === 'development') {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
} else if (process.env.ENVIRONMENT === 'staging') {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateEasBuildInternalResult<TJob extends BuildJob>({
|
||||
oldJob,
|
||||
result,
|
||||
|
||||
@@ -24,8 +24,7 @@ export async function installDependenciesAsync({
|
||||
let args: string[];
|
||||
switch (packageManager) {
|
||||
case PackageManager.NPM: {
|
||||
args = useFrozenLockfile ? ['ci'] : ['install'];
|
||||
args.push('--include=dev');
|
||||
args = useFrozenLockfile ? ['ci', '--include=dev'] : ['install'];
|
||||
break;
|
||||
}
|
||||
case PackageManager.PNPM: {
|
||||
@@ -45,12 +44,7 @@ export async function installDependenciesAsync({
|
||||
];
|
||||
}
|
||||
} else {
|
||||
args = [
|
||||
'install',
|
||||
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
|
||||
'--production',
|
||||
'false',
|
||||
];
|
||||
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import {
|
||||
BuildJob,
|
||||
BuildPhase,
|
||||
BuildTrigger,
|
||||
Ios,
|
||||
Job,
|
||||
Platform,
|
||||
UserError,
|
||||
} from '@expo/eas-build-job';
|
||||
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 spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import nullthrows from 'nullthrows';
|
||||
@@ -26,7 +20,7 @@ import {
|
||||
shouldUseFrozenLockfile,
|
||||
} from '../utils/packageManager';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
import { readAndLogPackageJson, readEasJsonContents } from '../utils/project';
|
||||
import { readEasJsonContents, readPackageJson } from '../utils/project';
|
||||
import { retryAsync } from '../utils/retry';
|
||||
|
||||
const MAX_EXPO_DOCTOR_TIMEOUT_MS = 30 * 1000;
|
||||
@@ -82,7 +76,10 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
|
||||
const packageJson = await ctx.runBuildPhase(BuildPhase.READ_PACKAGE_JSON, async () => {
|
||||
return readAndLogPackageJson(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
ctx.logger.info('Using package.json:');
|
||||
const packageJson = readPackageJson(ctx.getReactNativeProjectDirectory());
|
||||
ctx.logger.info(JSON.stringify(packageJson, null, 2));
|
||||
return packageJson;
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
|
||||
@@ -110,7 +107,7 @@ export async function setupAsync<TJob extends BuildJob>(ctx: BuildContext<TJob>)
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.READ_APP_CONFIG, async () => {
|
||||
const appConfig = await ctx.appConfig;
|
||||
const appConfig = ctx.appConfig;
|
||||
ctx.logger.info('Using app configuration:');
|
||||
ctx.logger.info(JSON.stringify(appConfig, null, 2));
|
||||
await validateAppConfigAsync(ctx, appConfig);
|
||||
@@ -118,13 +115,12 @@ 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 () => {
|
||||
const appConfig = await ctx.appConfig;
|
||||
if (!appConfig.ios?.bundleIdentifier && ctx.job.platform === Platform.IOS) {
|
||||
if (!ctx.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 (!appConfig.android?.package && ctx.job.platform === Platform.ANDROID) {
|
||||
if (!ctx.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.'
|
||||
);
|
||||
@@ -285,7 +281,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 UserError(
|
||||
throw new UserFacingError(
|
||||
'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,7 +67,6 @@ export interface BuildContextOptions {
|
||||
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
|
||||
skipNativeBuild?: boolean;
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
}
|
||||
|
||||
export class SkipNativeBuildError extends Error {}
|
||||
@@ -83,7 +82,6 @@ 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;
|
||||
@@ -96,7 +94,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
private buildPhase?: BuildPhase;
|
||||
private buildPhaseSkipped = false;
|
||||
private buildPhaseHasWarnings = false;
|
||||
private _appConfig?: Promise<ExpoConfig>;
|
||||
private _appConfig?: ExpoConfig;
|
||||
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
|
||||
public readonly graphqlClient: Client;
|
||||
|
||||
@@ -112,7 +110,6 @@ 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);
|
||||
@@ -173,18 +170,16 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
return path.join(this.workingdir, 'env');
|
||||
}
|
||||
public get packageManager(): PackageManager {
|
||||
return resolvePackageManager(this.getReactNativeProjectDirectory(), {
|
||||
env: this.env,
|
||||
});
|
||||
return resolvePackageManager(this.getReactNativeProjectDirectory());
|
||||
}
|
||||
public get appConfig(): Promise<ExpoConfig> {
|
||||
public get appConfig(): ExpoConfig {
|
||||
if (!this._appConfig) {
|
||||
this._appConfig = readAppConfig({
|
||||
projectDir: this.getReactNativeProjectDirectory(),
|
||||
env: this.env,
|
||||
logger: this.logger,
|
||||
sdkVersion: this.metadata?.sdkVersion,
|
||||
}).then(config => config.exp);
|
||||
}).exp;
|
||||
}
|
||||
return this._appConfig;
|
||||
}
|
||||
@@ -289,7 +284,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
private async handleBuildPhaseErrorAsync(
|
||||
err: any,
|
||||
buildPhase: BuildPhase
|
||||
): Promise<errors.ExpoError> {
|
||||
): Promise<errors.BuildError> {
|
||||
const buildError = await resolveBuildPhaseErrorAsync(
|
||||
err,
|
||||
this.logBuffer.getPhaseLogs(buildPhase),
|
||||
@@ -304,7 +299,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.message}`);
|
||||
this.logger.error(`Error: ${buildError.userFacingMessage}`);
|
||||
}
|
||||
return buildError;
|
||||
}
|
||||
|
||||
@@ -9,13 +9,11 @@ import {
|
||||
StaticJobInterpolationContext,
|
||||
} from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, ExternalBuildContextProvider, StepMetric } from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import { BuildRuntimePlatform, ExternalBuildContextProvider } from '@expo/steps';
|
||||
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,
|
||||
@@ -55,14 +53,11 @@ 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;
|
||||
@@ -70,7 +65,6 @@ 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();
|
||||
@@ -78,7 +72,6 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.runtimeApi = {
|
||||
uploadArtifact: (...args) => buildCtx['uploadArtifact'](...args),
|
||||
};
|
||||
this.expoApiV2BaseUrl = buildCtx.expoApiV2BaseUrl;
|
||||
this.startTime = new Date();
|
||||
}
|
||||
|
||||
@@ -146,26 +139,4 @@ 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,7 +2,6 @@ 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';
|
||||
@@ -11,17 +10,12 @@ 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>
|
||||
ctx: BuildContext<Generic.Job>,
|
||||
{ expoApiV2BaseUrl }: { expoApiV2BaseUrl: string }
|
||||
): 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 () => {
|
||||
@@ -66,15 +60,22 @@ export async function runGenericJobAsync(
|
||||
const runResult = await asyncResult(workflow.executeAsync());
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.COMPLETE_JOB, async () => {
|
||||
const results = await Promise.allSettled([
|
||||
uploadJobOutputsToWwwAsync(globalContext, {
|
||||
logger: ctx.logger,
|
||||
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,
|
||||
}),
|
||||
customBuildCtx.drainPendingMetricUploads(),
|
||||
]);
|
||||
if (results[0].status === 'rejected') {
|
||||
throw results[0].reason;
|
||||
stepMetrics: globalContext.stepMetrics,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -22,10 +22,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /Users/expo/Library/Keychains
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
derived_data_path("./build")
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
|
||||
output_directory("/tmp/output")
|
||||
"
|
||||
`;
|
||||
@@ -49,10 +45,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
derived_data_path("./build")
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
|
||||
output_directory("/tmp/output")
|
||||
"
|
||||
`;
|
||||
@@ -78,10 +70,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
derived_data_path("./build")
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
|
||||
output_directory("/tmp/output")
|
||||
"
|
||||
`;
|
||||
@@ -109,10 +97,6 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
derived_data_path("./build")
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
|
||||
output_directory("/tmp/output")
|
||||
"
|
||||
`;
|
||||
@@ -133,9 +117,6 @@ destination("generic/platform=iOS Simulator")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -153,9 +134,6 @@ destination("platform=iOS Simulator,name=iPhone 15")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -175,8 +153,5 @@ destination("generic/platform=tvOS Simulator")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
@@ -165,52 +165,6 @@ describe(configureXcodeProject, () => {
|
||||
'Info.plist application target'
|
||||
);
|
||||
});
|
||||
it('configures credentials with Apple Development identity for development profile', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'ios/testapp.xcodeproj/project.pbxproj': originalFs.readFileSync(
|
||||
path.join(__dirname, 'fixtures/simple-project.pbxproj'),
|
||||
'utf-8'
|
||||
),
|
||||
'ios/testapp/AppDelegate.m': 'placeholder',
|
||||
},
|
||||
'/app'
|
||||
);
|
||||
const options = {
|
||||
credentials: {
|
||||
keychainPath: 'fake/path',
|
||||
targetProvisioningProfiles: {
|
||||
testapp: {
|
||||
path: 'fake/path.mobileprovision',
|
||||
target: 'testapp',
|
||||
bundleIdentifier: 'abc',
|
||||
teamId: 'ABCDEFGH',
|
||||
uuid: 'abc',
|
||||
name: 'profile name',
|
||||
developerCertificate: Buffer.from('test'),
|
||||
certificateCommonName: 'Apple Development: Test User',
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
},
|
||||
},
|
||||
distributionType: DistributionType.DEVELOPMENT,
|
||||
teamId: 'ABCDEFGH',
|
||||
applicationTargetProvisioningProfile: {} as ProvisioningProfile<Ios.Job>,
|
||||
},
|
||||
buildConfiguration: 'Release',
|
||||
};
|
||||
const ctx = {
|
||||
getReactNativeProjectDirectory: () => '/app',
|
||||
logger: { info: jest.fn() },
|
||||
job: {},
|
||||
};
|
||||
await configureXcodeProject(ctx as any, options);
|
||||
const pbxproj = vol.readFileSync(
|
||||
'/app/ios/testapp.xcodeproj/project.pbxproj',
|
||||
'utf-8'
|
||||
) as string;
|
||||
expect(pbxproj).toContain('CODE_SIGN_IDENTITY = "Apple Development: Test User"');
|
||||
expect(pbxproj).not.toContain('"iPhone Distribution"');
|
||||
});
|
||||
it('configures credentials and versions for multi target project', async () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { Credentials } from '../credentials/manager';
|
||||
import { DistributionType } from '../credentials/provisioningProfile';
|
||||
import { runFastlaneGym } from '../fastlane';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../tvos', () => ({
|
||||
isTVOS: jest.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
const WORKING_DIR = '/workingdir';
|
||||
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
|
||||
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
|
||||
|
||||
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
|
||||
const job: Ios.Job = { ...createTestIosJob(), simulator };
|
||||
return new BuildContext(job, {
|
||||
workingdir: WORKING_DIR,
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
const ARCHIVE_CREDENTIALS: Credentials = {
|
||||
keychainPath: '/Users/expo/Library/Keychains/login.keychain',
|
||||
distributionType: DistributionType.APP_STORE,
|
||||
teamId: 'TEAM123',
|
||||
applicationTargetProvisioningProfile: {} as any,
|
||||
targetProvisioningProfiles: {
|
||||
'com.example.app': {
|
||||
bundleIdentifier: 'com.example.app',
|
||||
uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
path: '/path/to/profile1.mobileprovision',
|
||||
target: 'com.example.app',
|
||||
teamId: 'TEAM123',
|
||||
name: 'Main App Profile',
|
||||
developerCertificate: Buffer.from('cert'),
|
||||
certificateCommonName: 'iPhone Distribution',
|
||||
distributionType: DistributionType.APP_STORE,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function readDerivedDataPathFromGymfile(gymfileContent: string, workspacePath: string): string {
|
||||
const match = gymfileContent.match(/derived_data_path\("([^"]+)"\)/);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Gymfile did not contain derived_data_path("..."). Content:\n${gymfileContent}`
|
||||
);
|
||||
}
|
||||
return path.resolve(workspacePath, match[1]);
|
||||
}
|
||||
|
||||
describe(runFastlaneGym, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
vol.fromJSON({ [path.join(IOS_DIR, '.keep')]: '' });
|
||||
(spawn as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: 'archive',
|
||||
simulator: false,
|
||||
credentials: ARCHIVE_CREDENTIALS,
|
||||
buildConfiguration: 'Release',
|
||||
},
|
||||
{
|
||||
name: 'simulator',
|
||||
simulator: true,
|
||||
credentials: null,
|
||||
buildConfiguration: 'Debug',
|
||||
},
|
||||
])('$name build', ({ simulator, credentials, buildConfiguration }) => {
|
||||
it('returns paths that match the generated Gymfile template', async () => {
|
||||
const ctx = makeIosBuildContext({ simulator });
|
||||
|
||||
const result = await runFastlaneGym(ctx, {
|
||||
scheme: 'App',
|
||||
buildConfiguration,
|
||||
credentials,
|
||||
entitlements: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
derivedDataPath: path.join(IOS_DIR, 'build'),
|
||||
workspacePath: IOS_DIR,
|
||||
});
|
||||
|
||||
const gymfileContent = vol.readFileSync(path.join(IOS_DIR, 'Gymfile'), 'utf8') as string;
|
||||
expect(readDerivedDataPathFromGymfile(gymfileContent, result.workspacePath)).toBe(
|
||||
result.derivedDataPath
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('still returns EAS-convention paths when a pre-existing ios/Gymfile is present', async () => {
|
||||
vol.fromJSON({
|
||||
[path.join(IOS_DIR, 'Gymfile')]: '# user-supplied Gymfile\n',
|
||||
});
|
||||
|
||||
const ctx = makeIosBuildContext({ simulator: false });
|
||||
|
||||
const result = await runFastlaneGym(ctx, {
|
||||
scheme: 'App',
|
||||
credentials: ARCHIVE_CREDENTIALS,
|
||||
entitlements: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
derivedDataPath: path.join(IOS_DIR, 'build'),
|
||||
workspacePath: IOS_DIR,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,8 +58,6 @@ describe('gymfile', () => {
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Release',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -96,8 +94,6 @@ describe('gymfile', () => {
|
||||
credentials: mockCredentials,
|
||||
scheme: 'TestScheme',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: false,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -135,8 +131,6 @@ describe('gymfile', () => {
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Release',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
entitlements: {
|
||||
@@ -198,8 +192,6 @@ describe('gymfile', () => {
|
||||
credentials: mockCredentials,
|
||||
scheme: 'MyApp',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -218,7 +210,6 @@ describe('gymfile', () => {
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Debug',
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
simulatorDestination: 'generic/platform=iOS Simulator',
|
||||
@@ -235,7 +226,6 @@ describe('gymfile', () => {
|
||||
outputFile,
|
||||
scheme: 'TestApp',
|
||||
derivedDataPath: '/tmp/derived',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: false,
|
||||
logsDirectory: '/tmp/logs',
|
||||
simulatorDestination: 'platform=iOS Simulator,name=iPhone 15',
|
||||
@@ -253,7 +243,6 @@ describe('gymfile', () => {
|
||||
scheme: 'MyTVApp',
|
||||
buildConfiguration: 'Debug',
|
||||
derivedDataPath: '/tmp/derived',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
simulatorDestination: 'generic/platform=tvOS Simulator',
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { installPods } from '../pod';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const WORKING_DIR = '/workingdir';
|
||||
const PROJECT_DIR = path.join(WORKING_DIR, 'build');
|
||||
const IOS_DIR = path.join(PROJECT_DIR, 'ios');
|
||||
|
||||
function makeIosBuildContext({
|
||||
expoVersion,
|
||||
cocoapodsCacheUrl,
|
||||
usePrecompiledModules = true,
|
||||
}: {
|
||||
expoVersion?: string;
|
||||
cocoapodsCacheUrl?: string;
|
||||
usePrecompiledModules?: boolean;
|
||||
}): BuildContext<Ios.Job> {
|
||||
vol.fromJSON({
|
||||
[path.join(IOS_DIR, '.keep')]: '',
|
||||
...(expoVersion
|
||||
? {
|
||||
[path.join(PROJECT_DIR, 'node_modules/expo/package.json')]: JSON.stringify({
|
||||
version: expoVersion,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const job: Ios.Job = {
|
||||
...createTestIosJob(),
|
||||
builderEnvironment: {
|
||||
env: {
|
||||
...(usePrecompiledModules ? { EAS_USE_PRECOMPILED_MODULES: '1' } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return new BuildContext(job, {
|
||||
workingdir: WORKING_DIR,
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
...(cocoapodsCacheUrl ? { EAS_BUILD_COCOAPODS_CACHE_URL: cocoapodsCacheUrl } : {}),
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe(installPods, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
(spawn as jest.Mock).mockImplementation(command => {
|
||||
if (command === 'node') {
|
||||
return Promise.resolve({
|
||||
stdout: Buffer.from(path.join(PROJECT_DIR, 'node_modules/expo/package.json')),
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '999.0.0' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'node',
|
||||
['--print', "require.resolve('expo/package.json')"],
|
||||
expect.objectContaining({
|
||||
cwd: PROJECT_DIR,
|
||||
env: expect.objectContaining({
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
}),
|
||||
stdio: 'pipe',
|
||||
})
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: IOS_DIR,
|
||||
env: expect.objectContaining({
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL:
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules/',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
|
||||
);
|
||||
});
|
||||
|
||||
it('proxies precompiled modules through CocoaPods cache when enabled', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
expoVersion: '999.0.0',
|
||||
cocoapodsCacheUrl: 'https://cocoapods-cache.expo.test/',
|
||||
});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
expect.objectContaining({
|
||||
cwd: IOS_DIR,
|
||||
env: expect.objectContaining({
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL:
|
||||
'https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=999.0.0; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nEXPO_PRECOMPILED_MODULES_BASE_URL=https://cocoapods-cache.expo.test/storage.googleapis.com/eas-build-precompiled-modules/\nPrecompiled modules pod install environment is configured.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/^Detected expo=55\.0\.17; not enabling precompiled modules use because precompiled modules require expo>=/
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars when Expo version is invalid', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: 'invalid-version' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars when Expo version detection fails', async () => {
|
||||
const ctx = makeIosBuildContext({});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
|
||||
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars without the builder flag', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
expoVersion: '999.0.0',
|
||||
usePrecompiledModules: false,
|
||||
});
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(
|
||||
(spawn as jest.Mock).mock.calls[0][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
|
||||
).toBeUndefined();
|
||||
expect(ctx.logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import uniq from 'lodash/uniq';
|
||||
import path from 'path';
|
||||
|
||||
import { Credentials } from './credentials/manager';
|
||||
import { DistributionType } from './credentials/provisioningProfile';
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
async function configureXcodeProject(
|
||||
@@ -57,9 +56,6 @@ async function configureCredentialsAsync(
|
||||
profileName: profile.name,
|
||||
appleTeamId: profile.teamId,
|
||||
buildConfiguration,
|
||||
...(credentials.distributionType === DistributionType.DEVELOPMENT && {
|
||||
codeSignIdentity: profile.certificateCommonName,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { provisioningProfile } from './fixtures';
|
||||
import { BuildContext } from '../../../context';
|
||||
@@ -74,45 +73,5 @@ 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@ export interface ProvisioningProfileData {
|
||||
export enum DistributionType {
|
||||
AD_HOC = 'ad-hoc',
|
||||
APP_STORE = 'app-store',
|
||||
DEVELOPMENT = 'development',
|
||||
ENTERPRISE = 'enterprise',
|
||||
}
|
||||
|
||||
@@ -44,7 +43,6 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
|
||||
private readonly profilePath: string;
|
||||
private profileData?: ProvisioningProfileData;
|
||||
private developerCertificates: Buffer[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly ctx: BuildContext<TJob>,
|
||||
@@ -79,12 +77,11 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
}
|
||||
|
||||
public verifyCertificate(fingerprint: string): void {
|
||||
const devCertFingerprints = this.getAllDerCertFingerprints();
|
||||
if (!devCertFingerprints.includes(fingerprint)) {
|
||||
const devCertFingerprint = this.genDerCertFingerprint();
|
||||
if (devCertFingerprint !== fingerprint) {
|
||||
throw new errors.CredentialsDistCertMismatchError(
|
||||
`Provisioning profile and distribution certificate don't match.\n` +
|
||||
`Profile's certificate fingerprints = [${devCertFingerprints.join(', ')}], ` +
|
||||
`distribution certificate fingerprint = ${fingerprint}`
|
||||
`Provisioning profile and distribution certificate don't match.
|
||||
Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -117,10 +114,6 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
] 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,
|
||||
@@ -128,7 +121,7 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
teamId: (plistData.TeamIdentifier as string[])[0],
|
||||
uuid: plistData.UUID as string,
|
||||
name: plistData.Name as string,
|
||||
developerCertificate: this.developerCertificates[0],
|
||||
developerCertificate: Buffer.from((plistData.DeveloperCertificates as string[])[0], 'base64'),
|
||||
certificateCommonName: this.certificateCommonName,
|
||||
distributionType: this.resolveDistributionType(plistData),
|
||||
};
|
||||
@@ -138,19 +131,17 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
if (plistData.ProvisionsAllDevices) {
|
||||
return DistributionType.ENTERPRISE;
|
||||
} else if (plistData.ProvisionedDevices) {
|
||||
const entitlements = plistData.Entitlements as plist.PlistObject | undefined;
|
||||
if (entitlements?.['get-task-allow']) {
|
||||
return DistributionType.DEVELOPMENT;
|
||||
}
|
||||
return DistributionType.AD_HOC;
|
||||
} else {
|
||||
return DistributionType.APP_STORE;
|
||||
}
|
||||
}
|
||||
|
||||
private getAllDerCertFingerprints(): string[] {
|
||||
return this.developerCertificates.map(cert =>
|
||||
crypto.createHash('sha1').update(new Uint8Array(cert)).digest('hex').toUpperCase()
|
||||
);
|
||||
private genDerCertFingerprint(): string {
|
||||
return crypto
|
||||
.createHash('sha1')
|
||||
.update(new Uint8Array(this.data.developerCertificate))
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { bunyan } from '@expo/logger';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import nullthrows from 'nullthrows';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import type { Credentials } from './credentials/manager';
|
||||
@@ -29,13 +28,7 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
entitlements: object | null;
|
||||
extraEnv?: Env;
|
||||
}
|
||||
): Promise<{
|
||||
derivedDataPath: string;
|
||||
workspacePath: string;
|
||||
}> {
|
||||
const workspacePath = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
|
||||
const derivedDataPath = path.join(workspacePath, 'build');
|
||||
|
||||
): Promise<void> {
|
||||
await ensureGymfileExists(ctx, {
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
@@ -50,15 +43,13 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
|
||||
try {
|
||||
await runFastlane(['gym'], {
|
||||
cwd: workspacePath,
|
||||
cwd: path.join(ctx.getReactNativeProjectDirectory(), 'ios'),
|
||||
logger: ctx.logger,
|
||||
env: { ...ctx.env, ...extraEnv },
|
||||
});
|
||||
} finally {
|
||||
await buildLogger.flush();
|
||||
}
|
||||
|
||||
return { derivedDataPath, workspacePath };
|
||||
}
|
||||
|
||||
export async function runFastlaneResign<TJob extends Ios.Job>(
|
||||
@@ -131,8 +122,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
|
||||
}
|
||||
|
||||
ctx.logger.info('Creating Gymfile');
|
||||
const resultBundlePath = path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`);
|
||||
|
||||
if (ctx.job.simulator) {
|
||||
const isTV = await isTVOS(ctx);
|
||||
const simulatorDestination = `generic/platform=${isTV ? 'tvOS' : 'iOS'} Simulator`;
|
||||
@@ -142,7 +131,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
|
||||
scheme,
|
||||
buildConfiguration: buildConfiguration ?? 'release',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath,
|
||||
clean: false,
|
||||
logsDirectory,
|
||||
simulatorDestination,
|
||||
@@ -154,8 +142,6 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
outputDirectory: './build',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath,
|
||||
clean: false,
|
||||
logsDirectory,
|
||||
entitlements: entitlements ?? undefined,
|
||||
|
||||
@@ -11,8 +11,6 @@ interface ArchiveBuildOptions {
|
||||
scheme: string;
|
||||
buildConfiguration?: string;
|
||||
outputDirectory: string;
|
||||
derivedDataPath: string;
|
||||
resultBundlePath: string;
|
||||
clean: boolean;
|
||||
logsDirectory: string;
|
||||
entitlements?: object;
|
||||
@@ -23,7 +21,6 @@ interface SimulatorBuildOptions {
|
||||
scheme: string;
|
||||
buildConfiguration?: string;
|
||||
derivedDataPath: string;
|
||||
resultBundlePath: string;
|
||||
clean: boolean;
|
||||
logsDirectory: string;
|
||||
simulatorDestination: string;
|
||||
@@ -37,8 +34,6 @@ export async function createGymfileForArchiveBuild({
|
||||
buildConfiguration,
|
||||
entitlements,
|
||||
outputDirectory,
|
||||
derivedDataPath,
|
||||
resultBundlePath,
|
||||
logsDirectory,
|
||||
}: ArchiveBuildOptions): Promise<void> {
|
||||
const PROFILES: { BUNDLE_ID: string; UUID: string }[] = [];
|
||||
@@ -64,8 +59,6 @@ export async function createGymfileForArchiveBuild({
|
||||
SCHEME: scheme,
|
||||
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
|
||||
OUTPUT_DIRECTORY: outputDirectory,
|
||||
DERIVED_DATA_PATH: derivedDataPath,
|
||||
RESULT_BUNDLE_PATH: resultBundlePath,
|
||||
EXPORT_METHOD: credentials.distributionType,
|
||||
CLEAN: String(clean),
|
||||
LOGS_DIRECTORY: logsDirectory,
|
||||
@@ -81,7 +74,6 @@ export async function createGymfileForSimulatorBuild({
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
derivedDataPath,
|
||||
resultBundlePath,
|
||||
logsDirectory,
|
||||
simulatorDestination,
|
||||
}: SimulatorBuildOptions): Promise<void> {
|
||||
@@ -94,7 +86,6 @@ export async function createGymfileForSimulatorBuild({
|
||||
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
|
||||
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
|
||||
DERIVED_DATA_PATH: derivedDataPath,
|
||||
RESULT_BUNDLE_PATH: resultBundlePath,
|
||||
CLEAN: String(clean),
|
||||
LOGS_DIRECTORY: logsDirectory,
|
||||
},
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { Env, Ios } from '@expo/eas-build-job';
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.21';
|
||||
const PRECOMPILED_MODULES_BASE_URL =
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules/';
|
||||
|
||||
export async function installPods<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>,
|
||||
{ infoCallbackFn }: SpawnOptions
|
||||
@@ -18,7 +12,6 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
|
||||
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
|
||||
const cocoapodsDeploymentFlag = ctx.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : [];
|
||||
const precompiledModulesEnv = await resolvePrecompiledModulesPodInstallEnvAsync(ctx);
|
||||
|
||||
return {
|
||||
spawnPromise: spawn('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], {
|
||||
@@ -27,7 +20,6 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
env: {
|
||||
...ctx.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
...precompiledModulesEnv,
|
||||
},
|
||||
lineTransformer: (line?: string) => {
|
||||
if (
|
||||
@@ -43,76 +35,3 @@ export async function installPods<TJob extends Ios.Job>(
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>
|
||||
): Promise<Env> {
|
||||
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
|
||||
return {};
|
||||
}
|
||||
|
||||
let expoPackageVersion: string;
|
||||
try {
|
||||
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
|
||||
} catch (err) {
|
||||
ctx.logger.info(
|
||||
{ err },
|
||||
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const validExpoPackageVersion = semver.valid(expoPackageVersion);
|
||||
if (!validExpoPackageVersion) {
|
||||
ctx.logger.info(
|
||||
`Detected expo=${expoPackageVersion}; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
if (semver.lt(validExpoPackageVersion, MIN_PRECOMPILED_MODULES_EXPO_VERSION)) {
|
||||
ctx.logger.info(
|
||||
`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
const env: Env = {
|
||||
EXPO_USE_PRECOMPILED_MODULES: '1',
|
||||
EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(ctx),
|
||||
};
|
||||
|
||||
ctx.logger.info(
|
||||
`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(
|
||||
env
|
||||
)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join('\n')}\nPrecompiled modules pod install environment is configured.`
|
||||
);
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
async function getInstalledExpoPackageVersionAsync<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>
|
||||
): Promise<string> {
|
||||
const { stdout } = await spawn('node', ['--print', "require.resolve('expo/package.json')"], {
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
env: ctx.env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const expoPackageJsonPath = stdout.toString().trim();
|
||||
return (await fs.readJson(expoPackageJsonPath)).version;
|
||||
}
|
||||
|
||||
function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
|
||||
if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
|
||||
return PRECOMPILED_MODULES_BASE_URL;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
|
||||
return PRECOMPILED_MODULES_BASE_URL.replace(
|
||||
`${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
`${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { getEasFunctionGroups } from '../easFunctionGroups';
|
||||
|
||||
describe(getEasFunctionGroups, () => {
|
||||
it('includes eas/maestro_test for non-build jobs', () => {
|
||||
const ctx = {
|
||||
hasBuildJob: () => false,
|
||||
job: {},
|
||||
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
|
||||
|
||||
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
|
||||
functionGroup.getFullId()
|
||||
);
|
||||
expect(functionGroupIds).toEqual(['eas/maestro_test']);
|
||||
});
|
||||
|
||||
it('includes eas/build for build jobs', () => {
|
||||
const ctx = {
|
||||
hasBuildJob: () => true,
|
||||
job: {},
|
||||
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
|
||||
|
||||
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
|
||||
functionGroup.getFullId()
|
||||
);
|
||||
expect(functionGroupIds).toEqual(expect.arrayContaining(['eas/build', 'eas/maestro_test']));
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,6 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
|
||||
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
|
||||
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
|
||||
import { createDownloadBuildFunction } from './functions/downloadBuild';
|
||||
import { createEasDeployBuildFunction } from './functions/deploy';
|
||||
import { createEasExportBuildFunction } from './functions/export';
|
||||
import { eagerBundleBuildFunction } from './functions/eagerBundle';
|
||||
import { createFindAndUploadBuildArtifactsBuildFunction } from './functions/findAndUploadBuildArtifacts';
|
||||
import { generateGymfileFromTemplateFunction } from './functions/generateGymfileFromTemplate';
|
||||
@@ -19,12 +17,9 @@ 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 { createReadAppConfigBuildFunction } from './functions/readAppConfig';
|
||||
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
|
||||
import { createReadPackageJsonBuildFunction } from './functions/readPackageJson';
|
||||
import { createRepackBuildFunction } from './functions/repack';
|
||||
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
|
||||
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
|
||||
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
|
||||
import {
|
||||
@@ -32,14 +27,11 @@ import {
|
||||
createRestoreBuildCacheFunction,
|
||||
} from './functions/restoreBuildCache';
|
||||
import { createRestoreCacheFunction } from './functions/restoreCache';
|
||||
import { parseXcactivitylogFunction } from './functions/parseXcactivitylog';
|
||||
import { runFastlaneFunction } from './functions/runFastlane';
|
||||
import { runGradleFunction } from './functions/runGradle';
|
||||
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
|
||||
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
|
||||
import { createSaveCacheFunction } from './functions/saveCache';
|
||||
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
@@ -54,14 +46,9 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createDownloadArtifactFunction(),
|
||||
createUploadArtifactBuildFunction(ctx),
|
||||
createSetUpNpmrcBuildFunction(),
|
||||
createReadPackageJsonBuildFunction(),
|
||||
createReadAppConfigBuildFunction(),
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
createEasDeployBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
createRestoreCacheFunction(),
|
||||
createRestoreBuildCacheFunction(),
|
||||
@@ -78,8 +65,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
configureIosVersionFunction(),
|
||||
generateGymfileFromTemplateFunction(),
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
@@ -93,8 +78,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createSubmissionEntityFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(),
|
||||
createInternalEasMaestroTestFunction(ctx),
|
||||
];
|
||||
|
||||
if (ctx.hasBuildJob()) {
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { createEasBuildBuildFunctionGroup } from '../build';
|
||||
|
||||
function createMockBuildToolsContext(
|
||||
overrides: Partial<{
|
||||
platform: Platform;
|
||||
simulator: boolean;
|
||||
buildCredentials: Record<string, unknown>;
|
||||
}> = {}
|
||||
): CustomBuildContext<BuildJob> {
|
||||
return {
|
||||
job: {
|
||||
platform: overrides.platform ?? Platform.ANDROID,
|
||||
simulator: overrides.simulator ?? false,
|
||||
secrets: overrides.buildCredentials
|
||||
? { buildCredentials: overrides.buildCredentials }
|
||||
: undefined,
|
||||
},
|
||||
} as unknown as CustomBuildContext<BuildJob>;
|
||||
}
|
||||
|
||||
describe(createEasBuildBuildFunctionGroup, () => {
|
||||
describe('working_directory input', () => {
|
||||
it('does not set working directory on steps when not provided (Android)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
|
||||
for (const step of steps) {
|
||||
// Without working_directory, only installPods would have a relative dir (iOS only).
|
||||
// On Android, no step should have a relative working directory.
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({ platform: Platform.ANDROID });
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (iOS simulator)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
|
||||
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (iOS with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
buildCredentials: { test: {} },
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBeDefined();
|
||||
expect(step.ctx.relativeWorkingDirectory).toContain('my-app');
|
||||
}
|
||||
});
|
||||
|
||||
it('wires configure_ios_credentials target_names into configure_ios_version', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
buildCredentials: { test: {} },
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const configureIosVersionStep = steps.find(s => s.displayName === 'Configure iOS version');
|
||||
const targetNamesInput = configureIosVersionStep?.inputs?.find(
|
||||
input => input.id === 'target_names'
|
||||
);
|
||||
|
||||
expect(configureIosVersionStep).toBeDefined();
|
||||
expect(targetNamesInput?.rawValue).toBe(
|
||||
'${{ steps.configure_ios_credentials.outputs.target_names }}'
|
||||
);
|
||||
});
|
||||
|
||||
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
|
||||
expect(installPodsStep).toBeDefined();
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('my-app/ios');
|
||||
});
|
||||
|
||||
it('uses ./ios for installPods when no working_directory provided (iOS)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
simulator: true,
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx);
|
||||
|
||||
const installPodsStep = steps.find(s => s.displayName === 'Install Pods');
|
||||
expect(installPodsStep).toBeDefined();
|
||||
expect(installPodsStep!.ctx.relativeWorkingDirectory).toBe('./ios');
|
||||
});
|
||||
|
||||
it('sets working directory on all steps except checkout (Android with credentials)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.ANDROID,
|
||||
buildCredentials: { test: {} },
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const checkoutStep = steps.find(s => s.displayName === 'Checkout');
|
||||
expect(checkoutStep).toBeDefined();
|
||||
expect(checkoutStep!.ctx.relativeWorkingDirectory).toBeUndefined();
|
||||
|
||||
const nonCheckoutSteps = steps.filter(s => s.displayName !== 'Checkout');
|
||||
expect(nonCheckoutSteps.length).toBeGreaterThan(0);
|
||||
for (const step of nonCheckoutSteps) {
|
||||
expect(step.ctx.relativeWorkingDirectory).toBe('./my-app');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('throws for generic jobs (no platform)', () => {
|
||||
const buildToolsContext = {
|
||||
job: { platform: undefined },
|
||||
} as unknown as CustomBuildContext<BuildJob>;
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
expect(() => functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx)).toThrow(
|
||||
'Build function group is not supported in generic jobs.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,5 @@
|
||||
import { BuildJob, Platform } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunctionGroup,
|
||||
BuildStep,
|
||||
BuildStepGlobalContext,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import path from 'path';
|
||||
import { BuildFunctionGroup, BuildStep, BuildStepGlobalContext } from '@expo/steps';
|
||||
|
||||
import { shouldUseEagerBundle } from '../../common/eagerBundle';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -37,7 +30,6 @@ import { createSetUpNpmrcBuildFunction } from '../functions/useNpmToken';
|
||||
interface HelperFunctionsInput {
|
||||
globalCtx: BuildStepGlobalContext;
|
||||
buildToolsContext: CustomBuildContext<BuildJob>;
|
||||
workingDirectory?: string;
|
||||
}
|
||||
|
||||
export function createEasBuildBuildFunctionGroup(
|
||||
@@ -46,30 +38,17 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return new BuildFunctionGroup({
|
||||
namespace: 'eas',
|
||||
id: 'build',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'working_directory',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
createBuildStepsFromFunctionGroupCall: (globalCtx, { inputs }) => {
|
||||
const workingDirectory = inputs.working_directory?.getValue({
|
||||
interpolationContext: globalCtx.getInterpolationContext(),
|
||||
}) as string | undefined;
|
||||
|
||||
createBuildStepsFromFunctionGroupCall: globalCtx => {
|
||||
if (buildToolsContext.job.platform === Platform.IOS) {
|
||||
if (buildToolsContext.job.simulator) {
|
||||
return createStepsForIosSimulatorBuild({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
} else {
|
||||
return createStepsForIosBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
}
|
||||
} else if (buildToolsContext.job.platform === Platform.ANDROID) {
|
||||
@@ -77,13 +56,11 @@ export function createEasBuildBuildFunctionGroup(
|
||||
return createStepsForAndroidBuildWithoutCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
} else {
|
||||
return createStepsForAndroidBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -96,19 +73,16 @@ export function createEasBuildBuildFunctionGroup(
|
||||
function createStepsForIosSimulatorBuild({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
workingDirectory: './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -117,7 +91,6 @@ function createStepsForIosSimulatorBuild({
|
||||
});
|
||||
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_fastlane',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -125,26 +98,18 @@ function createStepsForIosSimulatorBuild({
|
||||
});
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
globalCtx
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -152,35 +117,29 @@ function createStepsForIosSimulatorBuild({
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForIosBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const resolveAppleTeamIdFromCredentials =
|
||||
resolveAppleTeamIdFromCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'resolve_apple_team_id_from_credentials',
|
||||
workingDirectory,
|
||||
});
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const prebuildStep = createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
apple_team_id: '${ steps.resolve_apple_team_id_from_credentials.apple_team_id }',
|
||||
},
|
||||
@@ -188,18 +147,16 @@ function createStepsForIosBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
}
|
||||
);
|
||||
const installPods = createInstallPodsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory: workingDirectory ? path.join(workingDirectory, './ios') : './ios',
|
||||
workingDirectory: './ios',
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -209,7 +166,6 @@ function createStepsForIosBuildWithCredentials({
|
||||
const generateGymfile = generateGymfileFromTemplateFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
credentials: '${ eas.job.secrets.buildCredentials }',
|
||||
},
|
||||
@@ -217,7 +173,6 @@ function createStepsForIosBuildWithCredentials({
|
||||
);
|
||||
const runFastlane = runFastlaneFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_fastlane',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -226,7 +181,6 @@ function createStepsForIosBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
@@ -234,15 +188,10 @@ function createStepsForIosBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
globalCtx
|
||||
),
|
||||
resolveAppleTeamIdFromCredentials,
|
||||
prebuildStep,
|
||||
@@ -250,20 +199,11 @@ function createStepsForIosBuildWithCredentials({
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'configure_ios_credentials',
|
||||
workingDirectory,
|
||||
}),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
|
||||
},
|
||||
}),
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -275,29 +215,24 @@ function createStepsForIosBuildWithCredentials({
|
||||
runFastlane,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForAndroidBuildWithoutCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -307,7 +242,6 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -315,7 +249,6 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
);
|
||||
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_gradle',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -324,7 +257,6 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -332,26 +264,18 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
globalCtx
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
configureEASUpdate,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -362,29 +286,24 @@ function createStepsForAndroidBuildWithoutCredentials({
|
||||
runGradle,
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
];
|
||||
}
|
||||
|
||||
function createStepsForAndroidBuildWithCredentials({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
workingDirectory,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
workingDirectory,
|
||||
});
|
||||
const configureEASUpdate =
|
||||
configureEASUpdateIfInstalledFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
throw_if_not_configured: false,
|
||||
resolved_eas_update_runtime_version:
|
||||
@@ -394,7 +313,6 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const restoreCache = createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -402,7 +320,6 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
);
|
||||
const runGradle = runGradleFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'run_gradle',
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -411,7 +328,6 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
const saveCache = createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
platform: Platform.ANDROID,
|
||||
},
|
||||
@@ -419,33 +335,21 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
);
|
||||
return [
|
||||
createCheckoutBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createInstallNodeModulesBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
createResolveBuildConfigBuildFunction(buildToolsContext).createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{ workingDirectory }
|
||||
globalCtx
|
||||
),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
configureEASUpdate,
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
injectAndroidCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureAndroidVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
runGradle,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
eagerBundleBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
resolved_eas_update_runtime_version:
|
||||
'${ steps.calculate_eas_update_runtime_version.resolved_eas_update_runtime_version }',
|
||||
@@ -455,10 +359,8 @@ function createStepsForAndroidBuildWithCredentials({
|
||||
: []),
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx, { workingDirectory }),
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
saveCache,
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
}),
|
||||
createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
];
|
||||
}
|
||||
@@ -51,6 +51,7 @@ 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
|
||||
@@ -96,6 +97,7 @@ 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
|
||||
@@ -132,6 +134,7 @@ export function createEasMaestroTestFunctionGroup(
|
||||
steps.push(
|
||||
new BuildStep(globalCtx, {
|
||||
id: BuildStep.getNewId(),
|
||||
name: 'maestro_test',
|
||||
ifCondition: '${ always() }',
|
||||
displayName: `maestro test ${flowPath}`,
|
||||
command: `maestro test ${flowPath}`,
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createTestIosJob } from '../../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { configureCredentialsAsync } from '../../utils/ios/configure';
|
||||
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
|
||||
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
|
||||
import { configureIosCredentialsFunction } from '../configureIosCredentials';
|
||||
|
||||
jest.mock('../../utils/ios/configure');
|
||||
jest.mock('../../utils/ios/credentials/manager');
|
||||
|
||||
function createCredentials({
|
||||
targetNames = ['app', 'share-extension'],
|
||||
}: {
|
||||
targetNames?: string[];
|
||||
} = {}): Credentials {
|
||||
return {
|
||||
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
|
||||
keychainPath: '/tmp/keychain',
|
||||
targetProvisioningProfiles: Object.fromEntries(
|
||||
targetNames.map(targetName => [
|
||||
targetName,
|
||||
{
|
||||
path: `/tmp/${targetName}.mobileprovision`,
|
||||
target: targetName,
|
||||
bundleIdentifier: `com.example.${targetName}`,
|
||||
teamId: 'TEAMID123',
|
||||
uuid: `${targetName}-uuid`,
|
||||
name: `${targetName} profile`,
|
||||
distributionType: DistributionType.AD_HOC,
|
||||
developerCertificate: Buffer.from('certificate'),
|
||||
certificateCommonName: `${targetName} cert`,
|
||||
},
|
||||
])
|
||||
),
|
||||
distributionType: DistributionType.AD_HOC,
|
||||
teamId: 'TEAMID123',
|
||||
};
|
||||
}
|
||||
|
||||
function createRawCredentials() {
|
||||
return {
|
||||
app: {
|
||||
provisioningProfileBase64: 'cHJvZmlsZQ==',
|
||||
distributionCertificate: {
|
||||
dataBase64: 'Y2VydA==',
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(configureIosCredentialsFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(configureCredentialsAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('exports target_names from prepared credentials', async () => {
|
||||
jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
|
||||
|
||||
const buildStep = configureIosCredentialsFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { job: createTestIosJob() },
|
||||
}),
|
||||
{
|
||||
callInputs: {
|
||||
credentials: createRawCredentials(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(configureCredentialsAsync).toHaveBeenCalled();
|
||||
expect(buildStep.outputById.target_names.value).toBe(JSON.stringify(['app', 'widget']));
|
||||
});
|
||||
});
|
||||
@@ -1,286 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { BuildWorkflow } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createTestIosJob } from '../../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { updateVersionsAsync } from '../../utils/ios/configure';
|
||||
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
|
||||
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
|
||||
import { configureIosCredentialsFunction } from '../configureIosCredentials';
|
||||
import { configureIosVersionFunction } from '../configureIosVersion';
|
||||
|
||||
jest.mock('../../utils/ios/configure');
|
||||
jest.mock('../../utils/ios/credentials/manager');
|
||||
|
||||
function createCredentials({
|
||||
targetNames = ['app', 'share-extension'],
|
||||
}: {
|
||||
targetNames?: string[];
|
||||
} = {}): Credentials {
|
||||
return {
|
||||
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
|
||||
keychainPath: '/tmp/keychain',
|
||||
targetProvisioningProfiles: Object.fromEntries(
|
||||
targetNames.map(targetName => [
|
||||
targetName,
|
||||
{
|
||||
path: `/tmp/${targetName}.mobileprovision`,
|
||||
target: targetName,
|
||||
bundleIdentifier: `com.example.${targetName}`,
|
||||
teamId: 'TEAMID123',
|
||||
uuid: `${targetName}-uuid`,
|
||||
name: `${targetName} profile`,
|
||||
distributionType: DistributionType.AD_HOC,
|
||||
developerCertificate: Buffer.from('certificate'),
|
||||
certificateCommonName: `${targetName} cert`,
|
||||
},
|
||||
])
|
||||
),
|
||||
distributionType: DistributionType.AD_HOC,
|
||||
teamId: 'TEAMID123',
|
||||
};
|
||||
}
|
||||
|
||||
function createRawCredentials() {
|
||||
return {
|
||||
app: {
|
||||
provisioningProfileBase64: 'cHJvZmlsZQ==',
|
||||
distributionCertificate: {
|
||||
dataBase64: 'Y2VydA==',
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createBuildStep({
|
||||
callInputs = {},
|
||||
job = createTestIosJob(),
|
||||
}: {
|
||||
callInputs?: Record<string, unknown>;
|
||||
job?: ReturnType<typeof createTestIosJob>;
|
||||
} = {}) {
|
||||
return configureIosVersionFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { job },
|
||||
}),
|
||||
{ callInputs }
|
||||
);
|
||||
}
|
||||
|
||||
describe(configureIosVersionFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(updateVersionsAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('uses target_names without an explicit credentials input', async () => {
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: ['app', 'widget'],
|
||||
build_number: '42',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: '42',
|
||||
appVersion: undefined,
|
||||
},
|
||||
{
|
||||
targetNames: ['app', 'widget'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('uses credentials only to preserve the existing fallback path', async () => {
|
||||
jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
credentials: createRawCredentials(),
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(IosCredentialsManager.prototype.prepare).toHaveBeenCalledTimes(1);
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: ['app', 'widget'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the implicit credentials default fallback from the job secrets', async () => {
|
||||
const prepareSpy = jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
|
||||
const buildStep = createBuildStep({
|
||||
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
|
||||
callInputs: {
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(prepareSpy).toHaveBeenCalledTimes(1);
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: ['app', 'widget'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers target_names when both target_names and credentials are provided', async () => {
|
||||
const prepareSpy = jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['credentials-target'] }));
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: ['input-target'],
|
||||
credentials: createRawCredentials(),
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(prepareSpy).not.toHaveBeenCalled();
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: ['input-target'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('parses target_names exported by configure_ios_credentials through workflow interpolation', async () => {
|
||||
const prepareSpy = jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: {
|
||||
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
|
||||
},
|
||||
});
|
||||
const configureIosCredentialsStep =
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'configure_ios_credentials',
|
||||
});
|
||||
const configureIosVersionStep = configureIosVersionFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
callInputs: {
|
||||
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await new BuildWorkflow(globalCtx, {
|
||||
buildSteps: [configureIosCredentialsStep, configureIosVersionStep],
|
||||
buildFunctions: {},
|
||||
}).executeAsync();
|
||||
|
||||
expect(prepareSpy).toHaveBeenCalledTimes(1);
|
||||
expect(configureIosCredentialsStep.outputById.target_names.value).toBe(
|
||||
JSON.stringify(['app', 'widget'])
|
||||
);
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
configureIosVersionStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: ['app', 'widget'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an empty target_names array without crashing', async () => {
|
||||
const prepareSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: [],
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
|
||||
expect(prepareSpy).not.toHaveBeenCalled();
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: [],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid target_names', async () => {
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: ['app', 123],
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
const promise = buildStep.executeAsync();
|
||||
await expect(promise).rejects.toBeInstanceOf(UserError);
|
||||
await expect(promise).rejects.toThrow('"target_names" input must be an array of strings.');
|
||||
});
|
||||
|
||||
it('uses the corrected App version error text', async () => {
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: ['app'],
|
||||
app_version: 'invalid-semver',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow(
|
||||
'App version provided by the "app_version" input is not a valid semver version: invalid-semver'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,290 +0,0 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { runEasCliCommand } from '../../../utils/easCli';
|
||||
import { createEasDeployBuildFunction } from '../deploy';
|
||||
|
||||
jest.mock('../../../utils/easCli', () => ({
|
||||
runEasCliCommand: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockDeployStdout = JSON.stringify({
|
||||
url: 'https://example.dev',
|
||||
production: { url: 'https://example.prod' },
|
||||
aliases: [{ url: 'https://example.alias' }],
|
||||
identifier: 'abc',
|
||||
dashboardUrl: 'https://expo.dev/dashboard',
|
||||
});
|
||||
|
||||
describe(createEasDeployBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('passes deploy flags from function inputs and sets deploy_json output', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(mockDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
alias: 'preview-link',
|
||||
prod: true,
|
||||
source_maps: false,
|
||||
},
|
||||
env: { HOME: '/tmp/home' },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Running deploy command: eas deploy --non-interactive --json --export-dir dist --alias preview-link --prod'
|
||||
);
|
||||
expect(runEasCliCommand).toHaveBeenCalledWith({
|
||||
args: [
|
||||
'deploy',
|
||||
'--non-interactive',
|
||||
'--json',
|
||||
'--export-dir',
|
||||
'dist',
|
||||
'--alias',
|
||||
'preview-link',
|
||||
'--prod',
|
||||
],
|
||||
options: expect.objectContaining({
|
||||
cwd: buildStep.ctx.workingDirectory,
|
||||
env: expect.objectContaining({ HOME: '/tmp/home' }),
|
||||
logger: buildStep.ctx.logger,
|
||||
}),
|
||||
});
|
||||
expect(buildStep.outputById.deploy_json.value).toBe(mockDeployStdout);
|
||||
expect(buildStep.outputById.deploy_url.value).toBe('https://example.prod');
|
||||
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev');
|
||||
expect(buildStep.outputById.deploy_identifier.value).toBe('abc');
|
||||
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard');
|
||||
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias');
|
||||
});
|
||||
|
||||
it('forwards workflow step env to the eas deploy process', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(mockDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { source_maps: true },
|
||||
env: { HOME: '/tmp/home', EXAMPLE_WORKFLOW_ENV: 'from-workflow' },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(runEasCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: expect.arrayContaining(['--source-maps']),
|
||||
})
|
||||
);
|
||||
expect(runEasCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
HOME: '/tmp/home',
|
||||
EXAMPLE_WORKFLOW_ENV: 'from-workflow',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an actionable error message when deploy command fails', async () => {
|
||||
jest.mocked(runEasCliCommand).mockRejectedValue(new Error('deploy failed'));
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
|
||||
let errorMessage = '';
|
||||
try {
|
||||
await buildStep.executeAsync();
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
expect(errorMessage).toContain('Deploy command failed.');
|
||||
expect(errorMessage).toContain('This can happen when deploy inputs are invalid');
|
||||
expect(errorMessage).toContain(
|
||||
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.'
|
||||
);
|
||||
expect(errorMessage).toContain('Original error: deploy failed');
|
||||
});
|
||||
|
||||
it('passes custom export_dir to eas deploy', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(mockDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { export_dir: 'web-build' },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(runEasCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: expect.arrayContaining(['--export-dir', 'web-build']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not pass --source-maps when source_maps is false', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(mockDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { source_maps: false },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const { args } = jest.mocked(runEasCliCommand).mock.calls[0][0];
|
||||
expect(args).not.toContain('--source-maps');
|
||||
});
|
||||
|
||||
it('sets deploy_json and skips optional outputs when stdout is not valid JSON', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from('not-json'),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.deploy_json.value).toBe('not-json');
|
||||
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.anything() }),
|
||||
expect.stringContaining('Failed to parse')
|
||||
);
|
||||
expect(buildStep.outputById.deploy_url.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets outputs from partial deploy JSON and leaves missing fields undefined', async () => {
|
||||
const partialDeployStdout = JSON.stringify({
|
||||
url: 'https://example.dev-only',
|
||||
dashboardUrl: 'https://expo.dev/dashboard-only',
|
||||
});
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(partialDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.deploy_json.value).toBe(partialDeployStdout);
|
||||
expect(buildStep.outputById.deploy_url.value).toBe('https://example.dev-only');
|
||||
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev-only');
|
||||
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard-only');
|
||||
expect(buildStep.outputById.deploy_identifier.value).toBeUndefined();
|
||||
expect(buildStep.outputById.deploy_alias_url.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets deploy_url from aliases[0].url when production is absent but aliases exist', async () => {
|
||||
const stdout = JSON.stringify({
|
||||
url: 'https://example.deployment',
|
||||
aliases: [{ url: 'https://example.alias-first' }],
|
||||
identifier: 'partial-alias',
|
||||
});
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.deploy_url.value).toBe('https://example.alias-first');
|
||||
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.deployment');
|
||||
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias-first');
|
||||
expect(buildStep.outputById.deploy_identifier.value).toBe('partial-alias');
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
jest.mock('../../../utils/project', () => ({
|
||||
runExpoCliCommand: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/packageManager', () => ({
|
||||
...jest.requireActual('../../../utils/packageManager'),
|
||||
resolvePackageManager: jest.fn(),
|
||||
}));
|
||||
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { PackageManager, resolvePackageManager } from '../../../utils/packageManager';
|
||||
import { runExpoCliCommand } from '../../../utils/project';
|
||||
import { createEasExportBuildFunction } from '../export';
|
||||
|
||||
describe(createEasExportBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.NPM);
|
||||
jest.mocked(runExpoCliCommand).mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
it('runs expo export via runExpoCliCommand with default output_dir and platform web', async () => {
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
env: { HOME: '/tmp/home' },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Running export command: expo export --output-dir dist --platform web'
|
||||
);
|
||||
expect(runExpoCliCommand).toHaveBeenCalledWith({
|
||||
packageManager: PackageManager.NPM,
|
||||
args: ['export', '--output-dir', 'dist', '--platform', 'web'],
|
||||
options: {
|
||||
cwd: buildStep.ctx.workingDirectory,
|
||||
env: expect.objectContaining({ HOME: '/tmp/home' }),
|
||||
logger: buildStep.ctx.logger,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
});
|
||||
expect(buildStep.outputById.export_dir.value).toBe('dist');
|
||||
});
|
||||
|
||||
it('passes optional flags matching expo export CLI', async () => {
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
output_dir: 'web-out',
|
||||
dev: true,
|
||||
minify: false,
|
||||
dump_assetmap: true,
|
||||
ssg: false,
|
||||
api_only: true,
|
||||
platform: 'web',
|
||||
},
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(runExpoCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: [
|
||||
'export',
|
||||
'--output-dir',
|
||||
'web-out',
|
||||
'--platform',
|
||||
'web',
|
||||
'--dev',
|
||||
'--no-minify',
|
||||
'--dump-assetmap',
|
||||
'--no-ssg',
|
||||
'--api-only',
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(buildStep.outputById.export_dir.value).toBe('web-out');
|
||||
});
|
||||
|
||||
it('uses resolved package manager (e.g. yarn)', async () => {
|
||||
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.YARN);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(runExpoCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ packageManager: PackageManager.YARN })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when export command fails', async () => {
|
||||
jest.mocked(runExpoCliCommand).mockRejectedValue(new Error('export failed'));
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow('Export command failed: export failed');
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -1,302 +0,0 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { buildFlowNameToPathMap, readFlowName, walkDir } from '../maestroFlowDiscovery';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
|
||||
describe(readFlowName, () => {
|
||||
beforeEach(() => vol.reset());
|
||||
|
||||
it('returns top-level `name` when present', async () => {
|
||||
vol.fromJSON({
|
||||
'/p/login.yml': `appId: com.example\nname: Login Flow\n---\n- launchApp\n`,
|
||||
});
|
||||
expect(await readFlowName('/p/login.yml')).toBe('Login Flow');
|
||||
});
|
||||
|
||||
it('falls back to basename without extension when name absent', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `appId: com.example\n---\n- launchApp\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when name is empty string', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `name: ""\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when name is non-string', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `name: 42\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when YAML is malformed', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': `appId: [unclosed\n` });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when file is empty', async () => {
|
||||
vol.fromJSON({ '/p/login.yml': '' });
|
||||
expect(await readFlowName('/p/login.yml')).toBe('login');
|
||||
});
|
||||
|
||||
it('falls back when file does not exist', async () => {
|
||||
expect(await readFlowName('/p/missing.yml')).toBe('missing');
|
||||
});
|
||||
|
||||
it('only parses the first YAML document (commands document errors are ignored)', async () => {
|
||||
vol.fromJSON({
|
||||
'/p/login.yml': `name: Login\n---\n- this is: { malformed yaml\n`,
|
||||
});
|
||||
expect(await readFlowName('/p/login.yml')).toBe('Login');
|
||||
});
|
||||
|
||||
it('handles .yaml extension', async () => {
|
||||
vol.fromJSON({ '/p/checkout.yaml': `name: Checkout\n` });
|
||||
expect(await readFlowName('/p/checkout.yaml')).toBe('Checkout');
|
||||
});
|
||||
|
||||
it('handles a leading `---` document marker', async () => {
|
||||
vol.fromJSON({
|
||||
'/p/login.yml': `---\nappId: com.example\nname: Login\n---\n- launchApp\n`,
|
||||
});
|
||||
expect(await readFlowName('/p/login.yml')).toBe('Login');
|
||||
});
|
||||
});
|
||||
|
||||
describe(walkDir, () => {
|
||||
beforeEach(() => vol.reset());
|
||||
|
||||
it('collects all .yml/.yaml files recursively, sorted', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/p/flows/a.yml': '',
|
||||
'/p/flows/b.yaml': '',
|
||||
'/p/flows/sub/c.yml': '',
|
||||
'/p/flows/sub/d.YAML': '',
|
||||
'/p/flows/readme.md': '',
|
||||
});
|
||||
const out: string[] = [];
|
||||
await walkDir('/p/flows', new Set(), out, logger);
|
||||
expect(out.sort()).toEqual([
|
||||
'/p/flows/a.yml',
|
||||
'/p/flows/b.yaml',
|
||||
'/p/flows/sub/c.yml',
|
||||
'/p/flows/sub/d.YAML',
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips files named config.yaml (case-insensitive)', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/p/flows/config.yaml': '',
|
||||
'/p/flows/CONFIG.yaml': '',
|
||||
'/p/flows/login.yml': '',
|
||||
});
|
||||
const out: string[] = [];
|
||||
await walkDir('/p/flows', new Set(), out, logger);
|
||||
expect(out.sort()).toEqual(['/p/flows/login.yml']);
|
||||
});
|
||||
|
||||
it('returns silently when realpath fails (e.g. missing dir)', async () => {
|
||||
const logger = createMockLogger();
|
||||
const out: string[] = [];
|
||||
await walkDir('/p/missing', new Set(), out, logger);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildFlowNameToPathMap, () => {
|
||||
beforeEach(() => vol.reset());
|
||||
|
||||
it('builds a map from a single file input (basename name)', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/flows/login.yml': 'appId: x\n' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['flows/login.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['login', 'flows/login.yml']]));
|
||||
});
|
||||
|
||||
it('uses top-level name when present', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/flows/a.yml': 'name: Login Flow\n' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['flows/a.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['Login Flow', 'flows/a.yml']]));
|
||||
});
|
||||
|
||||
it('walks a directory input and adds each flow', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/login.yml': 'name: Login\n',
|
||||
'/proj/.maestro/checkout.yml': '',
|
||||
'/proj/.maestro/sub/profile.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(
|
||||
new Map([
|
||||
['Login', '.maestro/login.yml'],
|
||||
['checkout', '.maestro/checkout.yml'],
|
||||
['profile', '.maestro/sub/profile.yml'],
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('merges file + directory inputs', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/a.yml': '',
|
||||
'/proj/extra/b.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro', 'extra/b.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(
|
||||
new Map([
|
||||
['a', '.maestro/a.yml'],
|
||||
['b', 'extra/b.yml'],
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('skips non-existent inputs with warn', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/flows/a.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['flows/missing.yml', 'flows/a.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['a', 'flows/a.yml']]));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('flows/missing.yml'));
|
||||
});
|
||||
|
||||
it('skips workspace config.yaml inside directory inputs', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/config.yaml': 'flows:\n - "*.yml"\n',
|
||||
'/proj/.maestro/login.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
|
||||
});
|
||||
|
||||
it('does NOT skip config.yaml when passed as an explicit file input', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/.maestro/config.yaml': 'name: Workspace Defaults\nflows:\n - "*.yml"\n',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro/config.yaml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['Workspace Defaults', '.maestro/config.yaml']]));
|
||||
});
|
||||
|
||||
it('returns null when two files share top-level name', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/a.yml': 'name: Login\n',
|
||||
'/proj/b.yml': 'name: Login\n',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['a.yml', 'b.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('"Login"'));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('a.yml'));
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('b.yml'));
|
||||
});
|
||||
|
||||
it('returns null when two files share basename across directories', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/login/index.yml': '',
|
||||
'/proj/checkout/index.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['login', 'checkout'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when name collides with another file basename', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
'/proj/login.yml': 'name: Other\n',
|
||||
'/proj/Other.yml': '',
|
||||
});
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['login.yml', 'Other.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
});
|
||||
|
||||
it('dedupes the same physical file passed via overlapping inputs', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/.maestro/login.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['.maestro', '.maestro/login.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['login', '.maestro/login.yml']]));
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses absolute fallback when input file lives outside projectRoot', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/elsewhere/a.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['/elsewhere/a.yml'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['a', '/elsewhere/a.yml']]));
|
||||
});
|
||||
|
||||
it('absolute directory input under projectRoot yields project-relative children', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({ '/proj/.maestro/a.yml': '' });
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: ['/proj/.maestro'],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toEqual(new Map([['a', '.maestro/a.yml']]));
|
||||
});
|
||||
|
||||
it('returns null and warns on unexpected helper failure', async () => {
|
||||
const logger = createMockLogger();
|
||||
const map = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: [Symbol('bad') as unknown as string],
|
||||
projectRoot: '/proj',
|
||||
logger,
|
||||
});
|
||||
expect(map).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('buildFlowNameToPathMap failed unexpectedly')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,890 +0,0 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseMaestroResults, () => {
|
||||
it('parses JUnit results and enriches with name→path map', async () => {
|
||||
vol.fromJSON({
|
||||
'/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'),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults(
|
||||
'/junit',
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
);
|
||||
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('uses flow name as fallback path when nameToPath is null', 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 parseMaestroResults('/junit', null);
|
||||
expect(results[0].path).toBe('home');
|
||||
expect(results[0].retryCount).toBe(0);
|
||||
});
|
||||
|
||||
it('uses flow name as fallback path when name not in map', 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 parseMaestroResults('/junit', new Map());
|
||||
expect(results[0].path).toBe('home');
|
||||
});
|
||||
|
||||
it('returns empty array when no JUnit files found', async () => {
|
||||
vol.fromJSON({ '/junit/.gitkeep': '' });
|
||||
|
||||
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
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'),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', new Map([['home', '.maestro/home.yml']]));
|
||||
expect(results[0].tags).toEqual(['e2e', 'smoke']);
|
||||
expect(results[0].properties).toEqual({ env: 'staging' });
|
||||
});
|
||||
|
||||
it('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'),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', new Map([['login', '.maestro/login.yml']]));
|
||||
|
||||
// Should return 2 results — one per attempt; retryCount from filename
|
||||
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'),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults(
|
||||
'/junit-reports',
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
);
|
||||
|
||||
// 4 results: 2 flows × 2 attempts
|
||||
expect(results).toHaveLength(4);
|
||||
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({
|
||||
'/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'),
|
||||
'/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'),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults(
|
||||
'/junit-reports',
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
);
|
||||
|
||||
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('handles reuse_devices=false (separate junit_report_directory, per-flow files)', async () => {
|
||||
vol.fromJSON({
|
||||
'/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'),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults(
|
||||
'/tmp/maestro-reports-abc123',
|
||||
new Map([
|
||||
['home', '.maestro/home.yml'],
|
||||
['login', '.maestro/login.yml'],
|
||||
])
|
||||
);
|
||||
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' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFailedFlowsFromJUnit', () => {
|
||||
it('returns the subset of input flow paths whose testcases failed', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites>
|
||||
<testsuite>
|
||||
<testcase name="Login" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Search" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Checkout" time="1.0"><failure>something</failure></testcase>
|
||||
</testsuite>
|
||||
</testsuites>`,
|
||||
});
|
||||
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
|
||||
nameToPath: new Map([
|
||||
['Login', 'flows/login.yaml'],
|
||||
['Search', 'flows/search.yaml'],
|
||||
['Checkout', 'flows/checkout.yaml'],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result).toEqual(['flows/checkout.yaml']);
|
||||
});
|
||||
|
||||
it('returns null when a failing testcase has no entry in nameToPath', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="Login" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Unknown" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/attempt-0.xml',
|
||||
nameToPath: new Map([['Login', 'flows/login.yaml']]),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when two failing testcases share the same name (collision)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="Duplicate" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="Duplicate" time="1.0"><failure>y</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
|
||||
nameToPath: new Map([['Duplicate', 'flows/a.yaml']]),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when a failing testcase shares a name with any other testcase (pass or fail)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="Shared" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Shared" time="2.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/android-maestro-junit-attempt-0.xml',
|
||||
nameToPath: new Map([['Shared', 'flows/a.yaml']]),
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when junit file does not exist', async () => {
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/missing.xml',
|
||||
nameToPath: new Map([['A', 'flows/a.yaml']]),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when junit file is malformed', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/bad.xml': 'this is not xml',
|
||||
});
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/bad.xml',
|
||||
nameToPath: new Map([['A', 'flows/a.yaml']]),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
|
||||
// fast-xml-parser can produce a partial parse from truncated XML — without
|
||||
// strict validation, smart retry would only retry the visible failures and
|
||||
// silently skip flows that were cut off.
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="B"`, // intentionally truncated mid-tag
|
||||
});
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/attempt-0.xml',
|
||||
nameToPath: new Map([
|
||||
['A', 'flows/a.yaml'],
|
||||
['B', 'flows/b.yaml'],
|
||||
]),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when junit XML has unclosed tags (partial parse risk)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite>`, // missing </testsuites>
|
||||
});
|
||||
const result = await parseFailedFlowsFromJUnit({
|
||||
junitFile: '/tmp/junit-reports/attempt-0.xml',
|
||||
nameToPath: new Map([['A', 'flows/a.yaml']]),
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeJUnitReports', () => {
|
||||
it('identity-copies a single attempt (preserves suite-level metadata)', async () => {
|
||||
// Single-attempt runs should land in final_report_path with the same
|
||||
// suite-level attributes (tests/failures/time) and non-testcase children
|
||||
// (e.g. <system-out>) that the legacy bash `cp` upload preserved. The
|
||||
// rebuild path used for multi-attempt merges drops these, so the single
|
||||
// case must short-circuit to a byte-equivalent copy.
|
||||
const input = `<?xml version="1.0"?>
|
||||
<testsuites>
|
||||
<testsuite name="Maestro Flows" tests="2" failures="0" time="3.0" device="Pixel 7">
|
||||
<testcase name="A" status="SUCCESS" time="1.0" />
|
||||
<testcase name="B" status="SUCCESS" time="2.0" />
|
||||
<system-out>boot complete</system-out>
|
||||
</testsuite>
|
||||
</testsuites>`;
|
||||
vol.fromJSON({
|
||||
'/tmp/junit-reports/android-maestro-junit-attempt-0.xml': input,
|
||||
});
|
||||
|
||||
await mergeJUnitReports({
|
||||
sourceDir: '/tmp/junit-reports',
|
||||
outputPath: '/tmp/final.xml',
|
||||
});
|
||||
|
||||
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
|
||||
expect(out).toBe(input);
|
||||
});
|
||||
|
||||
it('keeps the latest attempt per flow name across multiple files', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" status="SUCCESS" time="1.0" />
|
||||
<testcase name="B" time="2.0"><failure>bad</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/tmp/r/android-maestro-junit-attempt-1.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="B" status="SUCCESS" time="3.0" />
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
|
||||
|
||||
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
|
||||
const parsed = new XMLParser({ ignoreAttributes: false }).parse(out);
|
||||
const testcases = parsed.testsuites.testsuite.testcase;
|
||||
const a = testcases.find((t: any) => t['@_name'] === 'A');
|
||||
const b = testcases.find((t: any) => t['@_name'] === 'B');
|
||||
expect(a['@_status']).toBe('SUCCESS'); // from attempt 0
|
||||
expect(b['@_status']).toBe('SUCCESS'); // from attempt 1 (latest)
|
||||
expect(b.failure).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws when source directory contains no *.xml files', async () => {
|
||||
// No per-attempt JUnit files were ever written (maestro crashed before
|
||||
// producing output). Phase 3 in runMaestroTests treats this as a SystemError
|
||||
// — without a throw here, mergeJUnitReports would silently write an empty
|
||||
// <testsuite> and the caller would upload a misleading empty report.
|
||||
vol.fromJSON({
|
||||
'/tmp/r/.gitkeep': '',
|
||||
});
|
||||
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow(/no \*\.xml files/);
|
||||
});
|
||||
|
||||
it('throws on invalid XML so caller can fall back to copyLatestAttemptXml', async () => {
|
||||
// Without a throw, mergeJUnitReports would silently emit an empty merged
|
||||
// document and the copy-latest fallback (which only triggers on throw)
|
||||
// would never run.
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': 'not xml at all',
|
||||
});
|
||||
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow(/invalid XML/);
|
||||
});
|
||||
|
||||
it('throws when every input has <testsuites> but no <testcase> elements', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite></testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow(/no parseable testcases/);
|
||||
});
|
||||
|
||||
it('throws when any input XML is malformed (even if others parse)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
|
||||
'/tmp/r/attempt-1.xml': 'garbage not xml',
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when an input XML is truncated mid-tag (would otherwise partial-parse)', async () => {
|
||||
// fast-xml-parser is lenient and can return partial results from truncated
|
||||
// XML. Without strict validation, mergeJUnitReports would emit a merged
|
||||
// report missing the cut-off flows and Phase 3's copy-latest fallback
|
||||
// would never run. Validation must reject the file.
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" status="SUCCESS" time="1.0"/>
|
||||
<testcase name="B"`, // truncated mid-tag
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when an input XML has unclosed tags (would otherwise partial-parse)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" status="SUCCESS" time="1.0"/>
|
||||
</testsuite>`, // missing </testsuites>
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('throws when any input XML parses but has no testcases (even if others have testcases)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?><testsuites><testsuite><testcase name="A" status="SUCCESS" time="1.0"/></testsuite></testsuites>`,
|
||||
'/tmp/r/attempt-1.xml': `<?xml version="1.0"?><testsuites></testsuites>`,
|
||||
});
|
||||
await expect(
|
||||
mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('preserves duplicate names within the same attempt', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="Dup" status="SUCCESS" time="1.0" />
|
||||
<testcase name="Dup" status="SUCCESS" time="2.0" />
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
|
||||
|
||||
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
|
||||
const parsed = new XMLParser({ ignoreAttributes: false, isArray: n => n === 'testcase' }).parse(
|
||||
out
|
||||
);
|
||||
expect(parsed.testsuites.testsuite.testcase).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyLatestAttemptXml', () => {
|
||||
it('picks the highest attempt number', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': '<a/>',
|
||||
'/tmp/r/android-maestro-junit-attempt-1.xml': '<b/>',
|
||||
'/tmp/r/android-maestro-junit-attempt-2.xml': '<c/>',
|
||||
});
|
||||
await copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' });
|
||||
expect(await fs.readFile('/tmp/out.xml', 'utf-8')).toBe('<c/>');
|
||||
});
|
||||
|
||||
it('throws when source has no xml files', async () => {
|
||||
vol.fromJSON({ '/tmp/r/.keep': '' });
|
||||
await expect(
|
||||
copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,642 +0,0 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import * as discovery from '../maestroFlowDiscovery';
|
||||
import * as parser from '../maestroResultParser';
|
||||
import { createMaestroTestsBuildFunction } from '../maestroTests';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
...jest.requireActual('../../../utils/retry'),
|
||||
sleepAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
const SPAWN_SUCCESS = {
|
||||
status: 0,
|
||||
pid: 1,
|
||||
output: [''],
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
signal: null,
|
||||
} as any;
|
||||
|
||||
const rejectExit1 = (): Error => {
|
||||
const e: any = new Error('exit 1');
|
||||
e.status = 1;
|
||||
return e;
|
||||
};
|
||||
|
||||
function createStep(
|
||||
callInputs?: Record<string, unknown>,
|
||||
options: { env?: Record<string, string | undefined> } = {}
|
||||
): ReturnType<
|
||||
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
|
||||
> {
|
||||
const logger = createMockLogger();
|
||||
const fn = createMaestroTestsBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
|
||||
}
|
||||
|
||||
describe('createMaestroTestsBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
// Default-stub the merge helper so tests that don't care about it don't
|
||||
// hit the real disk. Individual tests override this.
|
||||
jest.restoreAllMocks();
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
});
|
||||
|
||||
it('exports a factory that returns a BuildFunction instance', () => {
|
||||
expect(createMaestroTestsBuildFunction()).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets all outputs before running any flows', async () => {
|
||||
// Make spawn reject so the step still throws; we're locking in that outputs
|
||||
// are set BEFORE the spawn call (downstream `upload_artifact` fails if
|
||||
// `final_report_path` is empty).
|
||||
mockedSpawn.mockRejectedValue(new Error('stop'));
|
||||
|
||||
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow();
|
||||
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toMatch(
|
||||
/\.maestro\/tests\/junit-reports$/
|
||||
);
|
||||
expect(step.getOutputValueByName('tests_directory')).toMatch(/\.maestro\/tests$/);
|
||||
expect(step.getOutputValueByName('final_report_path')).toMatch(
|
||||
/\.maestro\/tests\/android-maestro-junit\.xml$/
|
||||
);
|
||||
});
|
||||
|
||||
it('succeeds when maestro exits 0 on first attempt', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
const [cmd, args] = mockedSpawn.mock.calls[0];
|
||||
expect(cmd).toBe('maestro');
|
||||
expect(args).toEqual(expect.arrayContaining(['test', 'flows/a.yaml', 'flows/b.yaml']));
|
||||
expect(args).toEqual(expect.arrayContaining(['--format=JUNIT']));
|
||||
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
|
||||
});
|
||||
|
||||
it('throws SystemError when spawn fails with ENOENT (binary missing)', async () => {
|
||||
mockedSpawn.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws SystemError for unknown-shape spawn rejections', async () => {
|
||||
mockedSpawn.mockRejectedValue(new Error('mystery'));
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws SystemError on signal-only spawn rejections (OOM / SIGTERM)', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('killed'), {
|
||||
status: null,
|
||||
signal: 'SIGKILL',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('treats reject-with-status as a retryable test failure', async () => {
|
||||
const rejectWithExit = (status: number): Error =>
|
||||
Object.assign(new Error(`maestro exited with non-zero code: ${status}`), {
|
||||
status,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
});
|
||||
mockedSpawn.mockRejectedValueOnce(rejectWithExit(1)).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
// Default `retries` is 0, so even though the first attempt rejects with
|
||||
// `status: 1`, no retry occurs and the post-loop check throws UserError.
|
||||
// Spawn ran once.
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries only the failed flows on the next attempt (junit mode)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Attempt 0 saw all 3
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
// Attempt 1 saw only the failed one
|
||||
const a1Args = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1Args).toContain('flows/b.yaml');
|
||||
expect(a1Args).not.toContain('flows/a.yaml');
|
||||
expect(a1Args).not.toContain('flows/c.yaml');
|
||||
});
|
||||
|
||||
it('retries all flows when parseFailedFlowsFromJUnit returns null', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(null);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Both attempts saw all 3 flows
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
expect(spawnMock.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('always retries all flows when output_format is not junit', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(spawnMock.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('runs all original flows on every retry when smart_retry=false', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromJUnit')
|
||||
.mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
smart_retry: false,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Both attempts must see both flows in the original input order. Tail-slice
|
||||
// equality locks order, not just membership — guards against any future
|
||||
// failed-first reshuffling of the dumb-retry list.
|
||||
const a0 = mockedSpawn.mock.calls[0][1]!;
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a0.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
|
||||
expect(a1.slice(-2)).toEqual(['flows/a.yaml', 'flows/b.yaml']);
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('subsets to failing flows on retry when smart_retry=true (default behaviour)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
smart_retry: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1).toContain('flows/b.yaml');
|
||||
expect(a1).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('falls back to all-flows retry when smart_retry=true but output_format!=junit', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'html',
|
||||
smart_retry: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to all-flows retry when nameToPath is null (duplicate flow names)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(discovery, 'buildFlowNameToPathMap').mockResolvedValue(null);
|
||||
const parseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
smart_retry: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls[1][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults smart_retry to true when omitted from inputs', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1).toContain('flows/b.yaml');
|
||||
expect(a1).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('writes merged junit on success', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mergeSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceDir: expect.stringContaining('junit-reports'),
|
||||
outputPath: expect.stringContaining('android-maestro-junit.xml'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to copyLatestAttemptXml when mergeJUnitReports throws a data error', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
|
||||
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(copySpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows merge and copy-latest failures when maestro succeeded', async () => {
|
||||
// Throwing on copy failure would mask the real reason for early-failing
|
||||
// maestro runs (bad YAML writes no *.xml) and would also wrap a user-side
|
||||
// failure as SystemError, which cancels the build's billing.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockRejectedValue(new Error('bad xml'));
|
||||
jest
|
||||
.spyOn(parser, 'copyLatestAttemptXml')
|
||||
.mockRejectedValue(Object.assign(new Error('no space'), { code: 'ENOSPC' }));
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces ERR_MAESTRO_TESTS_FAILED (not copy error) when maestro fails and copy fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('exit 1'), {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
jest.spyOn(parser, 'copyLatestAttemptXml').mockRejectedValue(new Error('No *.xml files found'));
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries are exhausted with a non-zero exit', async () => {
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error('exit 1'), {
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
output: ['', ''],
|
||||
pid: 1,
|
||||
})
|
||||
);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/a.yaml']);
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 2,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError early on empty flow_path', async () => {
|
||||
const step = createStep({ flow_path: [], platform: 'android' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
|
||||
// whole-directory upload picks it up.
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
|
||||
expect(outputArg).not.toMatch(/junit-reports/);
|
||||
});
|
||||
|
||||
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'HTML',
|
||||
platform: 'ios',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/ios-maestro-html\.html$/);
|
||||
});
|
||||
|
||||
it('does not include --output when output_format is undefined', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: '',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
expect(args.some(a => a.startsWith('--output'))).toBe(false);
|
||||
expect(args).not.toContain('--output');
|
||||
expect(args).not.toContain('--output=undefined');
|
||||
expect(args).not.toContain('--output=null');
|
||||
expect(args).not.toContain('--output=');
|
||||
});
|
||||
|
||||
it('accepts output_format casing variations (e.g. JUNIT) case-insensitively', async () => {
|
||||
// output_format.toLowerCase() must propagate end-to-end: final_report_path
|
||||
// populated, per-attempt XMLs written into junit-reports/, merge invoked.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const mergeSpy = jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'JUNIT',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Phase 1: final_report_path populated
|
||||
expect(step.getOutputValueByName('final_report_path')).toMatch(
|
||||
/\.maestro\/tests\/android-maestro-junit\.xml$/
|
||||
);
|
||||
// Retry loop: --output written into junit-reports/ (not legacy path)
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/junit-reports\/android-maestro-junit-attempt-0\.xml$/);
|
||||
// Phase 3: merge invoked
|
||||
expect(mergeSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sourceDir: expect.stringContaining('junit-reports'),
|
||||
outputPath: expect.stringContaining('android-maestro-junit.xml'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes include_tags, exclude_tags, and shards through to maestro argv', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
shards: 3,
|
||||
include_tags: 'smoke,critical',
|
||||
exclude_tags: 'slow',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
expect(args).toContain('--shard-split=3');
|
||||
expect(args).toContain('--include-tags=smoke,critical');
|
||||
expect(args).toContain('--exclude-tags=slow');
|
||||
});
|
||||
|
||||
it('passes cwd and env to spawn so relative flow paths resolve correctly', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
|
||||
cwd?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
expect(spawnOptions).toBeDefined();
|
||||
expect(typeof spawnOptions.cwd).toBe('string');
|
||||
expect(spawnOptions.cwd!.length).toBeGreaterThan(0);
|
||||
expect(spawnOptions.env).toBeDefined();
|
||||
expect(spawnOptions.env!.HOME).toBe('/home/expo');
|
||||
});
|
||||
|
||||
it('exports MAESTRO_TESTS_DIR to maestro so flows can reference ${MAESTRO_TESTS_DIR}', async () => {
|
||||
// Public docs (EAS workflows pre-packaged-jobs) instruct users to write
|
||||
// screenshots/recordings into ${MAESTRO_TESTS_DIR}. The step must export
|
||||
// it in the spawn env.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
expect(spawnOptions.env!.MAESTRO_TESTS_DIR).toBe('/home/expo/.maestro/tests');
|
||||
});
|
||||
|
||||
it('uses env.HOME (not os.homedir()) for maestro tests output paths', async () => {
|
||||
// Even when os.homedir() differs, the step should derive paths from env.HOME
|
||||
// so maestro (which receives env) and the step (which inspects those paths)
|
||||
// agree on where reports land.
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
},
|
||||
{ env: { HOME: '/custom/home' } }
|
||||
);
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('tests_directory')).toBe('/custom/home/.maestro/tests');
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toBe(
|
||||
'/custom/home/.maestro/tests/junit-reports'
|
||||
);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
'/custom/home/.maestro/tests/android-maestro-junit.xml'
|
||||
);
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(
|
||||
/^--output=\/custom\/home\/\.maestro\/tests\/junit-reports\/android-maestro-junit-attempt-0\.xml$/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws SystemError when env.HOME is unset', async () => {
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
},
|
||||
{ env: {} }
|
||||
);
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('throws UserError on non-string entries in flow_path', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 2 as unknown as string],
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries is negative', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: -1,
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when retries is a non-integer number', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 1.5,
|
||||
platform: 'android',
|
||||
});
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is zero', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 0 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is negative', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: -1 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws UserError when shards is a non-integer', async () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 1.5 });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('accepts shards: undefined (optional input)', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
|
||||
await step.executeAsync(); // should succeed, not throw
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { parseAndReportXcactivitylog } from '../../utils/ios/xcactivitylog';
|
||||
import { parseXcactivitylogFunction } from '../parseXcactivitylog';
|
||||
|
||||
jest.mock('../../utils/ios/xcactivitylog');
|
||||
|
||||
describe(parseXcactivitylogFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('passes proxyBaseUrl from env into parseAndReportXcactivitylog', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
globalContext.updateEnv({ EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com' });
|
||||
|
||||
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
proxyBaseUrl: 'https://cache.example.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes xclogparserVersion as undefined when the input is not provided', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
xclogparserVersion: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves derived_data_path and workspace_path inputs against stepCtx.workingDirectory', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {
|
||||
derived_data_path: 'my-app/ios/Build',
|
||||
workspace_path: 'my-app/ios',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
derivedDataPath: expect.stringMatching(/\/my-app\/ios\/Build$/),
|
||||
workspacePath: expect.stringMatching(/\/my-app\/ios$/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts absolute paths for derived_data_path and workspace_path', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {
|
||||
derived_data_path: '/abs/derived',
|
||||
workspace_path: '/abs/workspace',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
derivedDataPath: '/abs/derived',
|
||||
workspacePath: '/abs/workspace',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { readAppConfig } from '../../../utils/appConfig';
|
||||
import { createReadAppConfigBuildFunction } from '../readAppConfig';
|
||||
|
||||
jest.mock('../../../utils/appConfig');
|
||||
|
||||
describe(createReadAppConfigBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('reads and logs app configuration', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({
|
||||
logger,
|
||||
staticContextContent: { metadata: { sdkVersion: '54.0.0' } },
|
||||
});
|
||||
globalContext.updateEnv({ EXPO_PUBLIC_TEST: '1' });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest
|
||||
.mocked(readAppConfig)
|
||||
.mockResolvedValue({ exp: { name: 'my-app', slug: 'my-app-slug', version: '1.2.3' } } as any);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(readAppConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectDir: globalContext.defaultWorkingDirectory,
|
||||
env: expect.objectContaining({ EXPO_PUBLIC_TEST: '1' }),
|
||||
logger: stepLogger,
|
||||
sdkVersion: '54.0.0',
|
||||
})
|
||||
);
|
||||
expect(stepLogger.info).toHaveBeenCalledWith('Using app configuration:');
|
||||
expect(stepLogger.info).toHaveBeenCalledWith(
|
||||
JSON.stringify({ name: 'my-app', slug: 'my-app-slug', version: '1.2.3' }, null, 2)
|
||||
);
|
||||
const firstOutput = buildStep.outputById.app_config.value;
|
||||
expect(firstOutput).toBeDefined();
|
||||
expect(JSON.parse(firstOutput!)).toEqual({
|
||||
name: 'my-app',
|
||||
slug: 'my-app-slug',
|
||||
version: '1.2.3',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not throw if reading app config fails', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAppConfig).mockRejectedValue(new Error('failed to read app config'));
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('exports app fields as returned by app config', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
const buildStep =
|
||||
createReadAppConfigBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest
|
||||
.mocked(readAppConfig)
|
||||
.mockResolvedValue({ exp: { name: 'my-app', slug: 123, version: undefined } } as any);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const secondOutput = buildStep.outputById.app_config.value;
|
||||
expect(secondOutput).toBeDefined();
|
||||
expect(JSON.parse(secondOutput!)).toEqual({
|
||||
name: 'my-app',
|
||||
slug: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createReadIpaInfoBuildFunction, readIpaInfoAsync } from '../readIpaInfo';
|
||||
|
||||
const NO_INFO_PLIST_IPA_BASE64 =
|
||||
'UEsDBAoAAAAAAPRcUFwAAAAAAAAAAAAAAAAIABwAUGF5bG9hZC9VVAkAA+zzkmns85JpdXgLAAEE9QEAAAQUAAAAUEsDBAoAAAAAAPRcUFwAAAAAAAAAAAAAAAAUABwAUGF5bG9hZC9UZXN0QXBwLmFwcC9VVAkAA+zzkmns85JpdXgLAAEE9QEAAAQUAAAAUEsDBAoAAAAAAPRcUFwgMDo2BgAAAAYAAAAeABwAUGF5bG9hZC9UZXN0QXBwLmFwcC9SZWFkbWUudHh0VVQJAAPs85Jp7POSaXV4CwABBPUBAAAEFAAAAGhlbGxvClBLAQIeAwoAAAAAAPRcUFwAAAAAAAAAAAAAAAAIABgAAAAAAAAAEADtQQAAAABQYXlsb2FkL1VUBQAD7POSaXV4CwABBPUBAAAEFAAAAFBLAQIeAwoAAAAAAPRcUFwAAAAAAAAAAAAAAAAUABgAAAAAAAAAEADtQUIAAABQYXlsb2FkL1Rlc3RBcHAuYXBwL1VUBQAD7POSaXV4CwABBPUBAAAEFAAAAFBLAQIeAwoAAAAAAPRcUFwgMDo2BgAAAAYAAAAeABgAAAAAAAEAAACkgZAAAABQYXlsb2FkL1Rlc3RBcHAuYXBwL1JlYWRtZS50eHRVVAUAA+zzkml1eAsAAQT1AQAABBQAAABQSwUGAAAAAAMAAwAMAQAA7gAAAAAA';
|
||||
const MISSING_BUNDLE_VERSION_IPA_BASE64 =
|
||||
'UEsDBAoAAAAAAPRcUFwAAAAAAAAAAAAAAAAIABwAUGF5bG9hZC9VVAkAA+zzkmns85JpdXgLAAEE9QEAAAQUAAAAUEsDBAoAAAAAAPRcUFwAAAAAAAAAAAAAAAAUABwAUGF5bG9hZC9UZXN0QXBwLmFwcC9VVAkAA+zzkmns85JpdXgLAAEE9QEAAAQUAAAAUEsDBBQAAAAIAPRcUFz9BfqM2wAAAEQBAAAeABwAUGF5bG9hZC9UZXN0QXBwLmFwcC9JbmZvLnBsaXN0VVQJAAPs85Jp7POSaXV4CwABBPUBAAAEFAAAAGWOUUvDMBSF3/srYt6b6/RFJMtw7QaFooV2go+jybZgm4Tkard/b7IKor7ec+73Hb46jwP5VD5oa5Z0wW4pUaa3Upvjku66bf5AVyLjN+VL0b01G+IGHZA0u3VdFYTmAE/ODQqg7ErS1FXbkcgA2DxTQk+I7hFgmia2Ty3W2zEVAzTeOuXxUkdYHh+YREmjZqb/mhOvUvcoMkL4u7qIYrv+MHJQlVQG9UErzyHdUx7Qx90iapg678ekRBUwyjl8Z38x7cl6fJ2F7bXyD7dgd+z+B8Bh3sPhulZkX1BLAQIeAwoAAAAAAPRcUFwAAAAAAAAAAAAAAAAIABgAAAAAAAAAEADtQQAAAABQYXlsb2FkL1VUBQAD7POSaXV4CwABBPUBAAAEFAAAAFBLAQIeAwoAAAAAAPRcUFwAAAAAAAAAAAAAAAAUABgAAAAAAAAAEADtQUIAAABQYXlsb2FkL1Rlc3RBcHAuYXBwL1VUBQAD7POSaXV4CwABBPUBAAAEFAAAAFBLAQIeAxQAAAAIAPRcUFz9BfqM2wAAAEQBAAAeABgAAAAAAAEAAACkgZAAAABQYXlsb2FkL1Rlc3RBcHAuYXBwL0luZm8ucGxpc3RVVAUAA+zzkml1eAsAAQT1AQAABBQAAABQSwUGAAAAAAMAAwAMAQAAwwEAAAAA';
|
||||
const IPA_FIXTURE_SOURCE_PATH = path.join(__dirname, 'fixtures/SmallestAppExample.ipa');
|
||||
|
||||
async function writeIpaFileAsync(filePath: string, base64Data: string): Promise<void> {
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.promises.writeFile(filePath, base64Data, { encoding: 'base64' });
|
||||
}
|
||||
|
||||
describe(readIpaInfoAsync, () => {
|
||||
it('reads bundle identifier and versions from fixture ipa', async () => {
|
||||
await expect(readIpaInfoAsync(IPA_FIXTURE_SOURCE_PATH)).resolves.toEqual({
|
||||
bundleIdentifier: 'dev.expo.SmallestAppExample',
|
||||
bundleShortVersion: '1.0',
|
||||
bundleVersion: '1',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when Info.plist cannot be found in IPA', async () => {
|
||||
const ipaPath = '/tmp/no-info.ipa';
|
||||
await writeIpaFileAsync(ipaPath, NO_INFO_PLIST_IPA_BASE64);
|
||||
|
||||
await expect(readIpaInfoAsync(ipaPath)).rejects.toThrow(
|
||||
`Failed to read IPA info: Could not find Info.plist in ${ipaPath}`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when required plist fields are missing', async () => {
|
||||
const ipaPath = '/tmp/missing-bundle-version.ipa';
|
||||
await writeIpaFileAsync(ipaPath, MISSING_BUNDLE_VERSION_IPA_BASE64);
|
||||
|
||||
await expect(readIpaInfoAsync(ipaPath)).rejects.toThrow(
|
||||
/Failed to read IPA info: .*CFBundleVersion/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createReadIpaInfoBuildFunction, () => {
|
||||
it('sets build step outputs', async () => {
|
||||
const readIpaInfo = createReadIpaInfoBuildFunction();
|
||||
const globalContext = createGlobalContextMock({
|
||||
staticContextContent: {
|
||||
job: {},
|
||||
},
|
||||
});
|
||||
|
||||
const buildStep = readIpaInfo.createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {
|
||||
ipa_path: IPA_FIXTURE_SOURCE_PATH,
|
||||
},
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.bundle_identifier.value).toBe('dev.expo.SmallestAppExample');
|
||||
expect(buildStep.outputById.bundle_short_version.value).toBe('1.0');
|
||||
expect(buildStep.outputById.bundle_version.value).toBe('1');
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { readAndLogPackageJson } from '../../../utils/project';
|
||||
import { createReadPackageJsonBuildFunction } from '../readPackageJson';
|
||||
|
||||
jest.mock('../../../utils/project');
|
||||
|
||||
describe(createReadPackageJsonBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('logs package.json contents', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({ logger });
|
||||
const buildStep =
|
||||
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAndLogPackageJson).mockReturnValue({ name: 'app', version: '1.0.0' });
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(readAndLogPackageJson).toHaveBeenCalledWith(
|
||||
stepLogger,
|
||||
globalContext.defaultWorkingDirectory
|
||||
);
|
||||
expect(buildStep.outputById.package_json.value).toBe(
|
||||
JSON.stringify({ name: 'app', version: '1.0.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw if reading package.json fails', async () => {
|
||||
const stepLogger = createMockLogger();
|
||||
const logger = createMockLogger();
|
||||
logger.child = jest.fn().mockReturnValue(stepLogger);
|
||||
const globalContext = createGlobalContextMock({ logger });
|
||||
const buildStep =
|
||||
createReadPackageJsonBuildFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
jest.mocked(readAndLogPackageJson).mockImplementation(() => {
|
||||
throw new Error('failed to read package.json');
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
expect(stepLogger.error).toHaveBeenCalledWith({
|
||||
err: expect.any(Error),
|
||||
});
|
||||
expect(buildStep.outputById.package_json.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,316 +0,0 @@
|
||||
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,
|
||||
'/root/project/.maestro/home.yml': '',
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await createStep({
|
||||
callInputs: {
|
||||
flow_path: ['.maestro/home.yml'],
|
||||
},
|
||||
}).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);
|
||||
});
|
||||
});
|
||||
@@ -1,274 +0,0 @@
|
||||
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();
|
||||
});
|
||||
|
||||
it('fails early on Linux when emulator host support check fails', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('accel check failed') as any);
|
||||
|
||||
const step = createStep();
|
||||
await expect(step.executeAsync()).rejects.toThrow(/nested virtualization/i);
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(false);
|
||||
});
|
||||
|
||||
it('continues startup on Linux when emulator host support check succeeds', async () => {
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/android/home/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
|
||||
});
|
||||
|
||||
it('skips emulator host support check when opt out env var is enabled', async () => {
|
||||
const step = createStep(undefined, {
|
||||
EAS_NO_EMULATOR_HOST_SUPPORT_CHECK: '1',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command.includes('/emulator/emulator'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(mockedSpawn.mock.calls.some(([command]) => command === 'sdkmanager')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to empty SDK path when Android SDK path env vars are missing', async () => {
|
||||
await createStep(undefined, {
|
||||
ANDROID_HOME: undefined,
|
||||
ANDROID_SDK_ROOT: undefined,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('/emulator/emulator', ['-accel-check'], {
|
||||
env: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('uses ANDROID_SDK_ROOT when ANDROID_HOME is missing', async () => {
|
||||
await createStep(undefined, {
|
||||
ANDROID_HOME: undefined,
|
||||
ANDROID_SDK_ROOT: '/android/sdk/root',
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'/android/sdk/root/emulator/emulator',
|
||||
['-accel-check'],
|
||||
{
|
||||
env: expect.any(Object),
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
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,20 +34,18 @@ export function calculateEASUpdateRuntimeVersionFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { env, inputs, outputs }) => {
|
||||
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 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 platform =
|
||||
(inputs.platform.value as Platform) ?? stepCtx.global.staticContext.job.platform;
|
||||
|
||||
@@ -42,20 +42,18 @@ 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 = (
|
||||
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 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 channelInput = inputs.channel.value as string | undefined;
|
||||
const runtimeVersionInput = inputs.runtime_version.value as string | undefined;
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import assert from 'assert';
|
||||
|
||||
import { configureCredentialsAsync } from '../utils/ios/configure';
|
||||
@@ -31,14 +26,9 @@ export function configureIosCredentialsFunction(): BuildFunction {
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'target_names',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, outputs }) => {
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
|
||||
fn: async (stepCtx, { inputs }) => {
|
||||
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
|
||||
stripUnknown: true,
|
||||
convert: true,
|
||||
abortEarly: false,
|
||||
@@ -60,7 +50,6 @@ export function configureIosCredentialsFunction(): BuildFunction {
|
||||
inputs.build_configuration.value as string | undefined
|
||||
),
|
||||
});
|
||||
outputs.target_names.set(JSON.stringify(Object.keys(credentials.targetProvisioningProfiles)));
|
||||
|
||||
stepCtx.logger.info('Successfully configured iOS credentials');
|
||||
},
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Ios, UserError } from '@expo/eas-build-job';
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import assert from 'assert';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { updateVersionsAsync } from '../utils/ios/configure';
|
||||
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
|
||||
@@ -18,15 +17,10 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'credentials',
|
||||
required: false,
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
defaultValue: '${ eas.job.secrets.buildCredentials }',
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'target_names',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_configuration',
|
||||
required: false,
|
||||
@@ -44,6 +38,19 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs }) => {
|
||||
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
|
||||
stripUnknown: true,
|
||||
convert: true,
|
||||
abortEarly: false,
|
||||
});
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const credentialsManager = new IosCredentialsManager(value);
|
||||
const credentials = await credentialsManager.prepare(stepCtx.logger);
|
||||
|
||||
assert(stepCtx.global.staticContext.job, 'Job is not defined');
|
||||
const job = stepCtx.global.staticContext.job as Ios.Job;
|
||||
|
||||
@@ -52,9 +59,8 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
const appVersion =
|
||||
(inputs.app_version.value as string | undefined) ?? job.version?.appVersion;
|
||||
if (appVersion && !semver.valid(appVersion)) {
|
||||
throw new UserError(
|
||||
'EAS_CONFIGURE_IOS_VERSION_INVALID_APP_VERSION',
|
||||
`App version provided by the "app_version" input is not a valid semver version: ${appVersion}`
|
||||
throw new Error(
|
||||
`App verrsion provided by the "app_version" input is not a valid semver version: ${appVersion}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,33 +71,6 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetNamesInput = inputs.target_names.value;
|
||||
let targetNames: string[];
|
||||
if (targetNamesInput !== undefined) {
|
||||
const parsed = z.array(z.string()).safeParse(targetNamesInput);
|
||||
if (!parsed.success) {
|
||||
throw new UserError(
|
||||
'EAS_CONFIGURE_IOS_VERSION_INVALID_TARGET_NAMES',
|
||||
'"target_names" input must be an array of strings.',
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
targetNames = parsed.data;
|
||||
} else {
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
|
||||
stripUnknown: true,
|
||||
convert: true,
|
||||
abortEarly: false,
|
||||
});
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const credentialsManager = new IosCredentialsManager(value);
|
||||
const credentials = await credentialsManager.prepare(stepCtx.logger);
|
||||
targetNames = Object.keys(credentials.targetProvisioningProfiles);
|
||||
}
|
||||
|
||||
stepCtx.logger.info('Setting iOS version...');
|
||||
if (buildNumber) {
|
||||
stepCtx.logger.info(`Build number: ${buildNumber}`);
|
||||
@@ -108,7 +87,7 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
appVersion,
|
||||
},
|
||||
{
|
||||
targetNames,
|
||||
targetNames: Object.keys(credentials.targetProvisioningProfiles),
|
||||
buildConfiguration: resolveBuildConfiguration(
|
||||
job,
|
||||
inputs.build_configuration.value as string | undefined
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { PipeMode, bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { runEasCliCommand } from '../../utils/easCli';
|
||||
|
||||
export function createEasDeployBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'deploy',
|
||||
name: 'Deploy',
|
||||
__metricsId: 'eas/deploy',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'alias',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'prod',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'source_maps',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
// Match eas/export default `output_dir`
|
||||
BuildStepInput.createProvider({
|
||||
id: 'export_dir',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
defaultValue: 'dist',
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_json',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_url',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_deployment_url',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_identifier',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_dashboard_url',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_alias_url',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs, env }) => {
|
||||
const alias = inputs.alias.value as string | undefined;
|
||||
const prod = inputs.prod.value as boolean | undefined;
|
||||
const sourceMaps = inputs.source_maps.value as boolean | undefined;
|
||||
const exportDir = inputs.export_dir.value as string;
|
||||
|
||||
const deployCommand = getDeployCommand({
|
||||
exportDir,
|
||||
alias,
|
||||
prod,
|
||||
sourceMaps,
|
||||
});
|
||||
stepsCtx.logger.info(`Running deploy command: eas ${deployCommand.join(' ')}`);
|
||||
|
||||
try {
|
||||
const result = await runEasCliCommand({
|
||||
args: deployCommand,
|
||||
options: {
|
||||
cwd: stepsCtx.workingDirectory,
|
||||
env,
|
||||
logger: stepsCtx.logger,
|
||||
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
|
||||
},
|
||||
});
|
||||
|
||||
const deployJson = result.stdout.toString();
|
||||
outputs.deploy_json.set(deployJson);
|
||||
const parsedDeploymentOutput = parseDeploymentOutput({
|
||||
deployJson,
|
||||
logger: stepsCtx.logger,
|
||||
});
|
||||
outputs.deploy_url.set(parsedDeploymentOutput.deploy_url);
|
||||
outputs.deploy_deployment_url.set(parsedDeploymentOutput.deploy_deployment_url);
|
||||
outputs.deploy_identifier.set(parsedDeploymentOutput.deploy_identifier);
|
||||
outputs.deploy_dashboard_url.set(parsedDeploymentOutput.deploy_dashboard_url);
|
||||
outputs.deploy_alias_url.set(parsedDeploymentOutput.deploy_alias_url);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'unknown error';
|
||||
throw new UserError(
|
||||
'EAS_DEPLOY_FAILED',
|
||||
'Deploy command failed.\n\n' +
|
||||
`This can happen when deploy inputs are invalid, the export directory "${exportDir}" is missing, or EAS CLI authentication/network checks fail.\n` +
|
||||
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.\n' +
|
||||
`Original error: ${errorMessage}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getDeployCommand({
|
||||
exportDir,
|
||||
alias,
|
||||
prod,
|
||||
sourceMaps,
|
||||
}: {
|
||||
exportDir: string;
|
||||
alias?: string;
|
||||
prod?: boolean;
|
||||
sourceMaps?: boolean;
|
||||
}): string[] {
|
||||
const deployCommand = ['deploy', '--non-interactive', '--json', '--export-dir', exportDir];
|
||||
if (alias) {
|
||||
deployCommand.push('--alias', alias);
|
||||
}
|
||||
if (prod) {
|
||||
deployCommand.push('--prod');
|
||||
}
|
||||
if (sourceMaps) {
|
||||
deployCommand.push('--source-maps');
|
||||
}
|
||||
return deployCommand;
|
||||
}
|
||||
|
||||
function parseDeploymentOutput({ deployJson, logger }: { deployJson: string; logger: bunyan }): {
|
||||
deploy_url?: string;
|
||||
deploy_deployment_url?: string;
|
||||
deploy_identifier?: string;
|
||||
deploy_dashboard_url?: string;
|
||||
deploy_alias_url?: string;
|
||||
} {
|
||||
try {
|
||||
const deployObject = JSON.parse(deployJson);
|
||||
return {
|
||||
deploy_url:
|
||||
deployObject.production?.url || deployObject.aliases?.[0]?.url || deployObject.url,
|
||||
deploy_deployment_url: deployObject.url,
|
||||
deploy_identifier: deployObject.identifier,
|
||||
deploy_dashboard_url: deployObject.dashboardUrl,
|
||||
deploy_alias_url: deployObject.aliases?.[0]?.url,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Failed to parse "eas deploy" JSON output. Some outputs expected from this step will be undefined.'
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
|
||||
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 UserError(
|
||||
throw new UserFacingError(
|
||||
'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 UserError(
|
||||
throw new UserFacingError(
|
||||
'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
|
||||
'No robot access token found in the job secrets.'
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
|
||||
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 UserError(
|
||||
throw new UserFacingError(
|
||||
'EAS_DOWNLOAD_BUILD_NO_MATCHING_FILES',
|
||||
`No ${extensions.map(ext => `.${ext}`).join(', ')} entries found in the archive.`
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ export function eagerBundleBuildFunction(): BuildFunction {
|
||||
throw new Error('Eager bundle is not supported for SDK version < 52');
|
||||
}
|
||||
|
||||
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
|
||||
const packageManager = resolvePackageManager(stepsCtx.workingDirectory);
|
||||
const resolvedEASUpdateRuntimeVersion = inputs.resolved_eas_update_runtime_version.value as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { resolvePackageManager } from '../../utils/packageManager';
|
||||
import { runExpoCliCommand } from '../../utils/project';
|
||||
|
||||
export function createEasExportBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'export',
|
||||
name: 'Export',
|
||||
__metricsId: 'eas/export',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'output_dir',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
defaultValue: 'dist',
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'dev',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'minify',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'dump_assetmap',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ssg',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'api_only',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'platform',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
defaultValue: 'web',
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'export_dir',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs, env }) => {
|
||||
const outputDir = inputs.output_dir.value as string;
|
||||
const dev = inputs.dev.value as boolean | undefined;
|
||||
const minify = inputs.minify.value as boolean | undefined;
|
||||
const dumpAssetmap = inputs.dump_assetmap.value as boolean | undefined;
|
||||
const ssg = inputs.ssg.value as boolean | undefined;
|
||||
const apiOnly = inputs.api_only.value as boolean | undefined;
|
||||
const platform = inputs.platform.value as string;
|
||||
|
||||
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
|
||||
const exportCommand = getExportCommand({
|
||||
outputDir,
|
||||
dev,
|
||||
minify,
|
||||
dumpAssetmap,
|
||||
ssg,
|
||||
apiOnly,
|
||||
platform,
|
||||
});
|
||||
stepsCtx.logger.info(`Running export command: expo ${exportCommand.join(' ')}`);
|
||||
|
||||
try {
|
||||
await runExpoCliCommand({
|
||||
packageManager,
|
||||
args: exportCommand,
|
||||
options: {
|
||||
cwd: stepsCtx.workingDirectory,
|
||||
env,
|
||||
logger: stepsCtx.logger,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
});
|
||||
outputs.export_dir.set(outputDir);
|
||||
} catch (error) {
|
||||
throw new UserError(
|
||||
'EXPO_EXPORT_FAILED',
|
||||
`Export command failed: ${error instanceof Error ? error.message : 'unknown error'}`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getExportCommand({
|
||||
outputDir,
|
||||
dev,
|
||||
minify,
|
||||
dumpAssetmap,
|
||||
ssg,
|
||||
apiOnly,
|
||||
platform,
|
||||
}: {
|
||||
outputDir: string;
|
||||
dev?: boolean;
|
||||
minify?: boolean;
|
||||
dumpAssetmap?: boolean;
|
||||
ssg?: boolean;
|
||||
apiOnly?: boolean;
|
||||
platform: string;
|
||||
}): string[] {
|
||||
const exportCommand = ['export', '--output-dir', outputDir, '--platform', platform];
|
||||
if (dev) {
|
||||
exportCommand.push('--dev');
|
||||
}
|
||||
if (minify === false) {
|
||||
exportCommand.push('--no-minify');
|
||||
}
|
||||
if (dumpAssetmap) {
|
||||
exportCommand.push('--dump-assetmap');
|
||||
}
|
||||
if (ssg === false) {
|
||||
exportCommand.push('--no-ssg');
|
||||
}
|
||||
if (apiOnly) {
|
||||
exportCommand.push('--api-only');
|
||||
}
|
||||
return exportCommand;
|
||||
}
|
||||
@@ -71,7 +71,7 @@ async function uploadApplicationArchivesAsync({
|
||||
}): Promise<void> {
|
||||
const applicationArchivePatternOrPath =
|
||||
ctx.job.platform === Platform.ANDROID
|
||||
? (ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}')
|
||||
? ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}'
|
||||
: resolveIosArtifactPath(ctx.job);
|
||||
const applicationArchives = await findArtifacts({
|
||||
rootDir: workingDirectory,
|
||||
|
||||
@@ -6,7 +6,6 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
|
||||
import { templateString } from '@expo/template-file';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
|
||||
@@ -37,10 +36,6 @@ const DEFAULT_CREDENTIALS_TEMPLATE = `
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("<%- LOGS_DIRECTORY %>")
|
||||
|
||||
derived_data_path("<%- DERIVED_DATA_PATH %>")
|
||||
result_bundle(true)
|
||||
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
|
||||
|
||||
output_directory("<%- OUTPUT_DIRECTORY %>")
|
||||
`;
|
||||
|
||||
@@ -60,9 +55,6 @@ const DEFAULT_SIMULATOR_TEMPLATE = `
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("<%- LOGS_DIRECTORY %>")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
|
||||
`;
|
||||
|
||||
export function generateGymfileFromTemplateFunction(): BuildFunction {
|
||||
@@ -189,7 +181,6 @@ export function generateGymfileFromTemplateFunction(): BuildFunction {
|
||||
ICLOUD_CONTAINER_ENVIRONMENT,
|
||||
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
|
||||
DERIVED_DATA_PATH: './build',
|
||||
RESULT_BUNDLE_PATH: path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`),
|
||||
...(PROFILES ? { PROFILES } : {}),
|
||||
...(credentials
|
||||
? {
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function installNodeModules(
|
||||
env: BuildStepEnv
|
||||
): Promise<void> {
|
||||
const { logger } = stepCtx;
|
||||
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
|
||||
const packageManager = resolvePackageManager(stepCtx.workingDirectory);
|
||||
const packagerRunDir = findPackagerRootDir(stepCtx.workingDirectory);
|
||||
|
||||
if (packagerRunDir !== stepCtx.workingDirectory) {
|
||||
|
||||
@@ -0,0 +1,621 @@
|
||||
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) };
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import yaml from 'yaml';
|
||||
|
||||
const YAML_EXT = /\.ya?ml$/i;
|
||||
const WORKSPACE_CONFIG_BASENAME = 'config.yaml';
|
||||
|
||||
export interface BuildFlowNameToPathMapArgs {
|
||||
inputFlowPaths: string[];
|
||||
projectRoot: string;
|
||||
logger: bunyan;
|
||||
}
|
||||
|
||||
interface FlowEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
realpath: string;
|
||||
}
|
||||
|
||||
export async function buildFlowNameToPathMap(
|
||||
args: BuildFlowNameToPathMapArgs
|
||||
): Promise<Map<string, string> | null> {
|
||||
try {
|
||||
const flows = await discoverFlows(args);
|
||||
return dedupAndDetectDuplicates(flows, args.logger);
|
||||
} catch (err: any) {
|
||||
args.logger.warn(`buildFlowNameToPathMap failed unexpectedly: ${err?.message ?? String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverFlows({
|
||||
inputFlowPaths,
|
||||
projectRoot,
|
||||
logger,
|
||||
}: BuildFlowNameToPathMapArgs): Promise<FlowEntry[]> {
|
||||
const realRootResult = await asyncResult(fs.realpath(projectRoot));
|
||||
const realRoot = realRootResult.ok ? realRootResult.value : projectRoot;
|
||||
const fileLists = await Promise.all(
|
||||
inputFlowPaths.map(async input => {
|
||||
const abs = path.resolve(projectRoot, input);
|
||||
const statResult = await asyncResult(fs.stat(abs));
|
||||
if (!statResult.ok) {
|
||||
logger.warn(`flow_path entry "${input}" not found, skipping`);
|
||||
return [];
|
||||
}
|
||||
const stat = statResult.value;
|
||||
if (stat.isFile() && YAML_EXT.test(abs)) {
|
||||
return [abs];
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
const out: string[] = [];
|
||||
await walkDir(abs, new Set(), out, logger);
|
||||
return out;
|
||||
}
|
||||
return [];
|
||||
})
|
||||
);
|
||||
return Promise.all(fileLists.flat().map(absFile => makeEntry(absFile, realRoot)));
|
||||
}
|
||||
|
||||
async function makeEntry(absFile: string, realRoot: string): Promise<FlowEntry> {
|
||||
const [name, realFileResult] = await Promise.all([
|
||||
readFlowName(absFile),
|
||||
asyncResult(fs.realpath(absFile)),
|
||||
]);
|
||||
const realFile = realFileResult.ok ? realFileResult.value : absFile;
|
||||
const rel = path.relative(realRoot, realFile);
|
||||
const value = rel.startsWith('..') || path.isAbsolute(rel) ? absFile : rel;
|
||||
return { name, path: value, realpath: realFile };
|
||||
}
|
||||
|
||||
function dedupAndDetectDuplicates(flows: FlowEntry[], logger: bunyan): Map<string, string> | null {
|
||||
const byKey = new Map<string, FlowEntry>();
|
||||
for (const f of flows) {
|
||||
if (!byKey.has(f.realpath)) {
|
||||
byKey.set(f.realpath, f);
|
||||
}
|
||||
}
|
||||
const unique = [...byKey.values()];
|
||||
|
||||
const nameToPath = new Map<string, string>();
|
||||
const duplicates = new Map<string, string[]>();
|
||||
for (const f of unique) {
|
||||
if (nameToPath.has(f.name)) {
|
||||
const existing = nameToPath.get(f.name)!;
|
||||
const list = duplicates.get(f.name) ?? [existing];
|
||||
list.push(f.path);
|
||||
duplicates.set(f.name, list);
|
||||
}
|
||||
nameToPath.set(f.name, f.path);
|
||||
}
|
||||
if (duplicates.size > 0) {
|
||||
for (const [name, paths] of duplicates) {
|
||||
logger.warn(`Duplicate Maestro flow name "${name}" across paths: ${paths.join(', ')}.`);
|
||||
}
|
||||
logger.warn(
|
||||
'Smart retry disabled for this run; will retry all flows on failure. ' +
|
||||
'Give each Maestro flow a unique name (file basename or top-level name) to enable smart retry.'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return nameToPath;
|
||||
}
|
||||
|
||||
export async function walkDir(
|
||||
dir: string,
|
||||
visited: Set<string>,
|
||||
out: string[],
|
||||
logger: bunyan
|
||||
): Promise<void> {
|
||||
// Cycle protection: dedup on realpath (fall back to the original path when
|
||||
// realpath fails so we still make progress on missing or restricted dirs).
|
||||
const realResult = await asyncResult(fs.realpath(dir));
|
||||
const real = realResult.ok ? realResult.value : dir;
|
||||
if (visited.has(real)) {
|
||||
return;
|
||||
}
|
||||
visited.add(real);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch (err: any) {
|
||||
logger.warn(`readdir failed for ${dir}: ${err?.message ?? String(err)}`);
|
||||
return;
|
||||
}
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
const tgtResult = await asyncResult(fs.stat(full));
|
||||
if (!tgtResult.ok) {
|
||||
continue;
|
||||
}
|
||||
const tgt = tgtResult.value;
|
||||
if (tgt.isDirectory()) {
|
||||
await walkDir(full, visited, out, logger);
|
||||
} else if (tgt.isFile() && isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
} else if (entry.isDirectory()) {
|
||||
await walkDir(full, visited, out, logger);
|
||||
} else if (entry.isFile()) {
|
||||
if (isYamlFile(entry.name) && !isWorkspaceConfig(entry.name)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function readFlowName(absFile: string): Promise<string> {
|
||||
const ext = path.extname(absFile);
|
||||
const fallback = path.basename(absFile, ext);
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(absFile, 'utf-8');
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let firstDoc;
|
||||
try {
|
||||
const docs = yaml.parseAllDocuments(content);
|
||||
firstDoc = docs[0];
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
if (!firstDoc || firstDoc.errors.length > 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = firstDoc.toJS();
|
||||
const name = parsed?.name;
|
||||
if (typeof name === 'string' && name.length > 0) {
|
||||
return name;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function isYamlFile(name: string): boolean {
|
||||
return YAML_EXT.test(name);
|
||||
}
|
||||
|
||||
function isWorkspaceConfig(name: string): boolean {
|
||||
return name.toLowerCase() === WORKSPACE_CONFIG_BASENAME;
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export interface MaestroFlowResult {
|
||||
name: string;
|
||||
path: string;
|
||||
status: 'passed' | 'failed';
|
||||
errorMessage: string | null;
|
||||
duration: number; // milliseconds
|
||||
retryCount: number;
|
||||
tags: string[];
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
|
||||
const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
try {
|
||||
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 {
|
||||
// Malformed XML — return whatever we collected before the parser bailed.
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function parseJUnitFile(filePath: string): Promise<JUnitTestCaseResult[]> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
return parseJUnitContent(content);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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 perFile = await Promise.all(
|
||||
xmlFiles.map(f => parseJUnitFile(path.join(junitDirectory, f)))
|
||||
);
|
||||
return perFile.flat();
|
||||
}
|
||||
|
||||
export async function parseMaestroResults(
|
||||
junitDirectory: string,
|
||||
nameToPath: Map<string, string> | null
|
||||
): Promise<MaestroFlowResult[]> {
|
||||
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 [];
|
||||
}
|
||||
|
||||
const results: MaestroFlowResult[] = [];
|
||||
|
||||
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 flowPath = nameToPath?.get(flowName) ?? flowName;
|
||||
// retryCount is derived from each entry's source filename (`attempt-N`);
|
||||
// a single entry from `report.xml` (no marker) collapses to attempt 0.
|
||||
const sorted = flowEntries
|
||||
.map(entry => {
|
||||
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
return { ...entry, attemptIndex };
|
||||
})
|
||||
.sort((a, b) => a.attemptIndex - b.attemptIndex);
|
||||
|
||||
for (const { result, attemptIndex } of sorted) {
|
||||
results.push({
|
||||
name: flowName,
|
||||
path: flowPath,
|
||||
status: result.status,
|
||||
errorMessage: result.errorMessage,
|
||||
duration: result.duration,
|
||||
retryCount: attemptIndex,
|
||||
tags: result.tags,
|
||||
properties: result.properties,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subset of `inputFlowPaths` whose testcases failed in the given
|
||||
* attempt's JUnit file, or `null` when the result cannot be trusted (caller
|
||||
* then falls back to dumb retry — re-run everything).
|
||||
*
|
||||
* Caller passes a precomputed `nameToPath` map built from the user's
|
||||
* `inputFlowPaths`. We translate failed testcase names to flow paths via
|
||||
* that map; unknown names → null.
|
||||
*/
|
||||
export async function parseFailedFlowsFromJUnit(args: {
|
||||
junitFile: string;
|
||||
nameToPath: Map<string, string>;
|
||||
}): Promise<string[] | null> {
|
||||
// fast-xml-parser is lenient — truncated XML can produce a partial parse
|
||||
// with some testcases dropped. Trusting that subset for smart retry would
|
||||
// skip retries for cut-off flows; reject any malformed XML and fall back
|
||||
// to dumb retry.
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(args.junitFile, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (XMLValidator.validate(content) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const testcases = parseJUnitContent(content);
|
||||
if (testcases.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failing = testcases.filter(tc => tc.status === 'failed');
|
||||
if (failing.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Two testcases with the same name (pass+fail or fail+fail) make it
|
||||
// impossible to map back to a single input flow_path. Signal "unknown".
|
||||
// Complementary to the map-build duplicate check (which catches the case
|
||||
// where tag filter hides duplication from JUnit).
|
||||
const allNameCounts = new Map<string, number>();
|
||||
for (const tc of testcases) {
|
||||
allNameCounts.set(tc.name, (allNameCounts.get(tc.name) ?? 0) + 1);
|
||||
}
|
||||
for (const count of allNameCounts.values()) {
|
||||
if (count > 1) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const matched: string[] = [];
|
||||
for (const tc of failing) {
|
||||
const p = args.nameToPath.get(tc.name);
|
||||
if (p === undefined) {
|
||||
return null;
|
||||
}
|
||||
matched.push(p);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads every *.xml in sourceDir (per-attempt JUnit files), picks the latest
|
||||
* attempt's <testcase> per unique flow name (latest determined from the
|
||||
* filename's `attempt-(\d+)` marker; files without the marker = attempt 0),
|
||||
* and writes a merged document to outputPath.
|
||||
*
|
||||
* Throws on empty/malformed/no-testcase input so the caller can fall back to
|
||||
* copyLatestAttemptXml — silently dropping bad attempts could keep stale
|
||||
* failure rows around and produce a misleading merged report.
|
||||
*/
|
||||
export async function mergeJUnitReports(args: {
|
||||
sourceDir: string;
|
||||
outputPath: string;
|
||||
}): Promise<void> {
|
||||
const entries = await fs.readdir(args.sourceDir);
|
||||
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
|
||||
|
||||
if (xmlFiles.length === 0) {
|
||||
throw new Error(`mergeJUnitReports: no *.xml files found in ${args.sourceDir}`);
|
||||
}
|
||||
|
||||
interface FileGroup {
|
||||
attemptIndex: number;
|
||||
filename: string;
|
||||
content: string;
|
||||
testcasesByName: Map<string, unknown[]>;
|
||||
}
|
||||
const contents = await Promise.all(
|
||||
xmlFiles.map(async f => ({
|
||||
filename: f,
|
||||
content: await fs.readFile(path.join(args.sourceDir, f), 'utf-8'),
|
||||
}))
|
||||
);
|
||||
const fileGroups: FileGroup[] = [];
|
||||
for (const { filename, content } of contents) {
|
||||
if (XMLValidator.validate(content) !== true) {
|
||||
throw new Error(`mergeJUnitReports: invalid XML in ${filename}`);
|
||||
}
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = xmlParser.parse(content);
|
||||
} catch (err) {
|
||||
throw new Error(`mergeJUnitReports: failed to parse ${filename}`, { cause: err });
|
||||
}
|
||||
const testsuites = parsed?.testsuites?.testsuite;
|
||||
if (!Array.isArray(testsuites)) {
|
||||
throw new Error(`mergeJUnitReports: no <testsuite> array in ${filename}`);
|
||||
}
|
||||
const match = filename.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
const testcasesByName = new Map<string, unknown[]>();
|
||||
for (const suite of testsuites) {
|
||||
const cases = suite?.testcase;
|
||||
if (!Array.isArray(cases)) {
|
||||
continue;
|
||||
}
|
||||
for (const tc of cases) {
|
||||
const name = tc?.['@_name'];
|
||||
if (typeof name !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const group = testcasesByName.get(name) ?? [];
|
||||
group.push(tc);
|
||||
testcasesByName.set(name, group);
|
||||
}
|
||||
}
|
||||
if (testcasesByName.size === 0) {
|
||||
throw new Error(`mergeJUnitReports: no parseable testcases in ${filename}`);
|
||||
}
|
||||
fileGroups.push({ attemptIndex, filename, content, testcasesByName });
|
||||
}
|
||||
|
||||
// Single attempt: copy the original XML so suite-level metadata (testsuite
|
||||
// attributes, <system-out>, etc.) survives. The rebuild path below would
|
||||
// collapse those to a single attribute-less <testsuite>.
|
||||
if (fileGroups.length === 1) {
|
||||
await fs.writeFile(args.outputPath, fileGroups[0].content);
|
||||
return;
|
||||
}
|
||||
|
||||
// For each unique name, pick the file with the highest attempt index that
|
||||
// contains it (ties broken by sorted filename — later wins). Preserve every
|
||||
// <testcase> element from the winning file for that name, so same-attempt
|
||||
// duplicates survive.
|
||||
const nameToWinningFile = new Map<string, FileGroup>();
|
||||
for (const group of fileGroups) {
|
||||
for (const name of group.testcasesByName.keys()) {
|
||||
const current = nameToWinningFile.get(name);
|
||||
if (!current || group.attemptIndex >= current.attemptIndex) {
|
||||
nameToWinningFile.set(name, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit in first-seen order (iteration over `fileGroups` yields stable order
|
||||
// matching the sorted filename list).
|
||||
const testcases: unknown[] = [];
|
||||
const emitted = new Set<string>();
|
||||
for (const group of fileGroups) {
|
||||
for (const [name, cases] of group.testcasesByName) {
|
||||
if (emitted.has(name)) {
|
||||
continue;
|
||||
}
|
||||
const winner = nameToWinningFile.get(name);
|
||||
if (winner === group) {
|
||||
for (const tc of cases) {
|
||||
testcases.push(tc);
|
||||
}
|
||||
emitted.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const builder = new XMLBuilder({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
format: true,
|
||||
suppressEmptyNode: true,
|
||||
});
|
||||
const xml = builder.build({
|
||||
testsuites: {
|
||||
testsuite: {
|
||||
testcase: testcases,
|
||||
},
|
||||
},
|
||||
});
|
||||
await fs.writeFile(args.outputPath, xml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
|
||||
* Used as a fallback when mergeJUnitReports fails due to data issues but the
|
||||
* step still needs to produce final_report_path.
|
||||
*
|
||||
* Throws if sourceDir contains no *.xml files or if the copy fails.
|
||||
*/
|
||||
export async function copyLatestAttemptXml(args: {
|
||||
sourceDir: string;
|
||||
outputPath: string;
|
||||
}): Promise<void> {
|
||||
const entries = await fs.readdir(args.sourceDir);
|
||||
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
|
||||
if (xmlFiles.length === 0) {
|
||||
throw new Error(`No *.xml files found in ${args.sourceDir}`);
|
||||
}
|
||||
|
||||
// Pick the file with the highest attempt index. Files without the marker are
|
||||
// treated as attempt 0. Ties are broken by sorted filename — later wins
|
||||
// (same rule as mergeJUnitReports).
|
||||
let winner = xmlFiles[0];
|
||||
let winnerAttempt = (() => {
|
||||
const m = winner.match(ATTEMPT_PATTERN);
|
||||
return m ? parseInt(m[1], 10) : 0;
|
||||
})();
|
||||
for (let i = 1; i < xmlFiles.length; i++) {
|
||||
const candidate = xmlFiles[i];
|
||||
const match = candidate.match(ATTEMPT_PATTERN);
|
||||
const attempt = match ? parseInt(match[1], 10) : 0;
|
||||
if (attempt >= winnerAttempt) {
|
||||
winner = candidate;
|
||||
winnerAttempt = attempt;
|
||||
}
|
||||
}
|
||||
|
||||
await fs.copyFile(path.join(args.sourceDir, winner), args.outputPath);
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowsFromJUnit,
|
||||
} from './maestroResultParser';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
const FlowPathSchema = z.array(z.string().min(1)).min(1);
|
||||
const RetriesSchema = z.number().int().min(0).default(0);
|
||||
const ShardsSchema = z.number().int().min(1).optional();
|
||||
|
||||
function parseInput<S extends z.ZodTypeAny>(
|
||||
schema: S,
|
||||
value: unknown,
|
||||
message: string
|
||||
): z.output<S> {
|
||||
const result = schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new UserError('ERR_MAESTRO_INVALID_INPUT', message, { cause: result.error });
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
// ENOENT is excluded — "input XML missing" is a data issue, not a storage
|
||||
// fault, so the post-loop merge should fall through to copy-latest instead
|
||||
// of throwing.
|
||||
function isFilesystemError(err: any): boolean {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const code = err.code;
|
||||
return (
|
||||
code === 'ENOSPC' || code === 'EACCES' || code === 'EROFS' || code === 'EIO' || code === 'EPERM'
|
||||
);
|
||||
}
|
||||
|
||||
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
|
||||
// other declared formats pass an explicit path so downstream upload steps
|
||||
// know where to find the result.
|
||||
function buildMaestroArgs(args: {
|
||||
flow_path: string[];
|
||||
outputPath: string | null;
|
||||
output_format: string | undefined;
|
||||
shards: number | undefined;
|
||||
include_tags: string | undefined;
|
||||
exclude_tags: string | undefined;
|
||||
}): string[] {
|
||||
const out: string[] = ['test'];
|
||||
if (args.output_format) {
|
||||
out.push(`--format=${args.output_format.toUpperCase()}`);
|
||||
}
|
||||
if (args.outputPath) {
|
||||
out.push(`--output=${args.outputPath}`);
|
||||
}
|
||||
if (args.shards !== undefined) {
|
||||
out.push(`--shard-split=${args.shards}`);
|
||||
}
|
||||
if (args.include_tags) {
|
||||
out.push(`--include-tags=${args.include_tags}`);
|
||||
}
|
||||
if (args.exclude_tags) {
|
||||
out.push(`--exclude-tags=${args.exclude_tags}`);
|
||||
}
|
||||
out.push(...args.flow_path);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'maestro_tests',
|
||||
name: 'Run Maestro Tests',
|
||||
__metricsId: 'eas/maestro_tests',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'flow_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'retries',
|
||||
required: false,
|
||||
defaultValue: 0,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'smart_retry',
|
||||
required: false,
|
||||
defaultValue: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'shards',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'include_tags',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'exclude_tags',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'output_format',
|
||||
required: false,
|
||||
defaultValue: 'junit',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'platform',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
|
||||
BuildStepOutput.createProvider({ id: 'final_report_path', required: false }),
|
||||
BuildStepOutput.createProvider({ id: 'tests_directory', required: true }),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, outputs, env, signal }) => {
|
||||
const { logger, global } = stepCtx;
|
||||
const platformInput = inputs.platform.value as string | undefined;
|
||||
const outputFormat = (inputs.output_format.value as string | undefined)?.toLowerCase();
|
||||
const includeTags = inputs.include_tags.value as string | undefined;
|
||||
const excludeTags = inputs.exclude_tags.value as string | undefined;
|
||||
|
||||
const platform: 'ios' | 'android' =
|
||||
platformInput === 'ios' || platformInput === 'android'
|
||||
? platformInput
|
||||
: global.runtimePlatform === BuildRuntimePlatform.DARWIN
|
||||
? 'ios'
|
||||
: 'android';
|
||||
|
||||
// Paths derive from env.HOME (not os.homedir()). Maestro is spawned with
|
||||
// this env and writes debug output under $HOME/.maestro/tests; the step
|
||||
// must read from the same place or stale files leak across runs.
|
||||
const home = env.HOME;
|
||||
if (!home) {
|
||||
throw new SystemError('HOME env var is not set');
|
||||
}
|
||||
const testsDirectory = path.join(home, '.maestro', 'tests');
|
||||
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
|
||||
const finalReportPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
|
||||
: undefined;
|
||||
|
||||
// Public docs (EAS workflows pre-packaged-jobs) document
|
||||
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
|
||||
// the uploaded dir.
|
||||
const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
|
||||
|
||||
// Outputs are published BEFORE any throw below so downstream
|
||||
// `if: always()` upload steps still see populated values when this
|
||||
// step fails early.
|
||||
outputs.tests_directory.set(testsDirectory);
|
||||
outputs.junit_report_directory.set(junitReportDirectory);
|
||||
if (finalReportPath !== undefined) {
|
||||
outputs.final_report_path.set(finalReportPath);
|
||||
}
|
||||
|
||||
const flowPaths = parseInput(
|
||||
FlowPathSchema,
|
||||
inputs.flow_path.value,
|
||||
'flow_path must be a non-empty array of non-empty strings.'
|
||||
);
|
||||
const retries = parseInput(
|
||||
RetriesSchema,
|
||||
inputs.retries.value,
|
||||
'retries must be a non-negative integer.'
|
||||
);
|
||||
const shards = parseInput(
|
||||
ShardsSchema,
|
||||
inputs.shards.value,
|
||||
'shards must be a positive integer.'
|
||||
);
|
||||
const smartRetry = inputs.smart_retry.value as boolean;
|
||||
|
||||
try {
|
||||
await fs.mkdir(junitReportDirectory, { recursive: true });
|
||||
} catch (err) {
|
||||
throw new SystemError('Failed to create JUnit report directory', { cause: err });
|
||||
}
|
||||
|
||||
// null → duplicate flow names; smart retry disabled, fall through to
|
||||
// dumb retry (re-run everything) on failure.
|
||||
const nameToPath = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
});
|
||||
|
||||
// Retry loop. spawn-async error shapes:
|
||||
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
|
||||
// numeric err.status → maestro exited non-zero → retry.
|
||||
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
||||
// downgraded to "tests failed".
|
||||
// Smart retry (junit mode): after a failed attempt, subset to the failing
|
||||
// flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
|
||||
// trusted; we then fall through to dumb retry (re-run everything).
|
||||
let flowsToRun: string[] = flowPaths;
|
||||
let lastAttemptExitCode: number | null = null;
|
||||
|
||||
const totalAttempts = retries + 1;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
const outputPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
||||
: outputFormat
|
||||
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
||||
: null;
|
||||
|
||||
const maestroArgs = buildMaestroArgs({
|
||||
flow_path: flowsToRun,
|
||||
outputPath,
|
||||
output_format: outputFormat,
|
||||
shards,
|
||||
include_tags: includeTags,
|
||||
exclude_tags: excludeTags,
|
||||
});
|
||||
logger.info(
|
||||
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
|
||||
);
|
||||
|
||||
try {
|
||||
await spawn('maestro', maestroArgs, {
|
||||
cwd: stepCtx.workingDirectory,
|
||||
env: spawnEnv,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
lastAttemptExitCode = 0;
|
||||
} catch (err: any) {
|
||||
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
|
||||
throw new SystemError('Failed to invoke maestro', { cause: err });
|
||||
}
|
||||
if (err && typeof err.status === 'number') {
|
||||
lastAttemptExitCode = err.status;
|
||||
} else {
|
||||
throw new SystemError('Unexpected spawn failure invoking maestro', { cause: err });
|
||||
}
|
||||
}
|
||||
|
||||
if (lastAttemptExitCode === 0 || attempt === retries) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (smartRetry && outputFormat === 'junit' && outputPath && nameToPath) {
|
||||
const failed = await parseFailedFlowsFromJUnit({
|
||||
junitFile: outputPath,
|
||||
nameToPath,
|
||||
});
|
||||
if (failed !== null && failed.length > 0) {
|
||||
flowsToRun = failed;
|
||||
logger.info(
|
||||
`Test failed; retrying ${failed.length} failed flow(s): ${failed.join(', ')}`
|
||||
);
|
||||
} else {
|
||||
flowsToRun = flowPaths;
|
||||
logger.info('Test failed; could not determine failed subset, retrying all flows');
|
||||
}
|
||||
} else {
|
||||
flowsToRun = flowPaths;
|
||||
logger.info('Test failed, retrying all flows');
|
||||
}
|
||||
|
||||
await sleepAsync(2000);
|
||||
}
|
||||
|
||||
// Smart merge first; on data errors (bad XML, missing input) fall back
|
||||
// to copy-latest so the caller still gets a single JUnit file.
|
||||
// Filesystem errors short-circuit straight to SystemError.
|
||||
if (finalReportPath !== undefined) {
|
||||
try {
|
||||
await mergeJUnitReports({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
});
|
||||
} catch (mergeErr: any) {
|
||||
if (isFilesystemError(mergeErr)) {
|
||||
throw new SystemError('Failed to write final_report_path', { cause: mergeErr });
|
||||
}
|
||||
logger.warn({ err: mergeErr }, 'Smart merge failed; falling back to copy-latest.');
|
||||
try {
|
||||
await copyLatestAttemptXml({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
});
|
||||
} catch (copyErr: any) {
|
||||
// Swallow: a copy failure here usually means maestro itself failed
|
||||
// early (bad YAML wrote no *.xml). Throwing SystemError would mask
|
||||
// the real reason and cancel billing for a user-side failure — let
|
||||
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
|
||||
logger.warn(
|
||||
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The retry loop exits via success (0), numeric status (retryable),
|
||||
// or throw (infra). A non-null non-zero status means the user's tests
|
||||
// failed every attempt.
|
||||
if (lastAttemptExitCode !== 0) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_TESTS_FAILED',
|
||||
`Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Loaded 100 of 611 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user