mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3984c9a938 | ||
|
|
a3c9e6727e | ||
|
|
e06a5babcf | ||
|
|
5f400d7766 | ||
|
|
4338aea702 | ||
|
|
ecf42e68f3 | ||
|
|
f23579fa32 | ||
|
|
afdc626fcb | ||
|
|
f9f4583a39 | ||
|
|
2d955440ca | ||
|
|
642bb6fa4d | ||
|
|
fb1f2d2add | ||
|
|
dbcca53f1d | ||
|
|
4a0dc9f426 | ||
|
|
146656c7f7 | ||
|
|
e37411040f | ||
|
|
b71226d90f | ||
|
|
9a9fe11f73 | ||
|
|
1b62b6feae | ||
|
|
ebb215db1a | ||
|
|
cdf49377cd | ||
|
|
8fb84e86bb | ||
|
|
d660e3602d | ||
|
|
5b9d47c02b | ||
|
|
61b601de88 | ||
|
|
c5867c8c07 | ||
|
|
f2d5c210a7 | ||
|
|
6e70f7ee4b | ||
|
|
1d36f4f636 | ||
|
|
f8deba78a5 | ||
|
|
df8ebc8f84 | ||
|
|
62ab329f39 | ||
|
|
198b796804 | ||
|
|
110055d943 | ||
|
|
06e2ba5bdb | ||
|
|
15690e7f6b | ||
|
|
134e74f50b | ||
|
|
4b3dcda99d | ||
|
|
0c32aa3a39 | ||
|
|
59ac8ae77a | ||
|
|
3e13df470e | ||
|
|
83877f6285 | ||
|
|
56018338e7 | ||
|
|
0fdf2dd44a | ||
|
|
0dcd6d465a | ||
|
|
d5da4f4167 | ||
|
|
d0b9281bb2 | ||
|
|
77e04a3bad | ||
|
|
92ff6a0da5 | ||
|
|
25d945b63f | ||
|
|
37165428d1 | ||
|
|
50b9be975e | ||
|
|
2655482f74 | ||
|
|
dc103acf06 | ||
|
|
b87bc5bf8f | ||
|
|
1604b7bc20 | ||
|
|
13a6356fca | ||
|
|
0823a9d675 | ||
|
|
772d4dd654 | ||
|
|
e8da383de4 | ||
|
|
d08f9cfdcb | ||
|
|
ac6569aa58 | ||
|
|
960ce7dbf5 | ||
|
|
029bbdd007 | ||
|
|
7061a6743a |
No files matched your search
@@ -1,9 +1,14 @@
|
||||
**/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
|
||||
@@ -1,51 +0,0 @@
|
||||
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,6 +12,8 @@ jobs:
|
||||
profile: system-test
|
||||
custom_job:
|
||||
name: Custom Job
|
||||
runs_on: linux-medium-nested-virtualization
|
||||
steps:
|
||||
- name: Echo
|
||||
run: echo "This is a custom job step"
|
||||
- uses: eas/start_android_emulator
|
||||
@@ -1,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,149 +22,21 @@ 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: [build-linux, build-mac, build-windows]
|
||||
needs: release
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
run: yarn install --immutable
|
||||
- name: Build
|
||||
run: yarn build
|
||||
- name: Publish packages to npm
|
||||
run: yarn lerna publish from-package --yes --no-verify-access
|
||||
run: yarn run -T lerna publish from-package --yes --no-verify-access
|
||||
- name: Add latest-eas-build-staging tag
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }}
|
||||
@@ -192,12 +64,12 @@ jobs:
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
run: yarn install --frozen-lockfile --check-files
|
||||
run: yarn install --immutable
|
||||
- name: Resolve version
|
||||
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
- name: Update changelog
|
||||
working-directory: ./scripts
|
||||
run: yarn --silent release-changelog $EAS_CLI_VERSION > /tmp/current-changelog
|
||||
run: yarn 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:
|
||||
@@ -224,8 +96,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: ""
|
||||
@@ -1,21 +0,0 @@
|
||||
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 --frozen-lockfile
|
||||
- run: yarn install --immutable
|
||||
- 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 --frozen-lockfile --check-files
|
||||
- run: yarn install --immutable
|
||||
- run: yarn typecheck
|
||||
+20
-11
@@ -22,11 +22,14 @@ jobs:
|
||||
name: Test with Node ${{ matrix.node }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- 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' }}
|
||||
- run: yarn typecheck
|
||||
- run: |
|
||||
git config --global user.email "[email protected]"
|
||||
@@ -38,31 +41,37 @@ jobs:
|
||||
- name: Run tests (affected)
|
||||
# PRs: only run tests for changed packages + dependents.
|
||||
if: ${{ !matrix.coverage && github.event_name == 'pull_request' }}
|
||||
run: yarn lerna run test --since ${{ github.event.pull_request.base.sha }} --include-dependents
|
||||
run: yarn run -T 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 lerna run test
|
||||
run: yarn run -T 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 lerna run test -- -- --coverage
|
||||
run: yarn run -T 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 --max-warnings=0
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn lint
|
||||
- run: yarn fmt:check
|
||||
- name: Install scripts dependencies
|
||||
if: ${{ matrix.coverage }}
|
||||
run: yarn install --immutable
|
||||
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 }}
|
||||
- run: yarn lint-changelog
|
||||
- if: ${{ matrix.coverage }}
|
||||
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 --frozen-lockfile --check-files
|
||||
yarn install --immutable
|
||||
yarn build
|
||||
if [[ "$INPUT_VERSION" == "" ]]; then
|
||||
echo "Releasing with default version"
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-mise
|
||||
|
||||
- run: yarn install --frozen-lockfile --check-files
|
||||
- run: yarn install --immutable
|
||||
|
||||
- run: yarn typecheck
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ dist
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
**/.yarn/install-state.gz
|
||||
*-debug.log
|
||||
*-error.log
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"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/build-tools/schema.graphql"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"bracketSameLine": true,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
Vendored
+1
-2
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"eslint.enable": true,
|
||||
"search.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/bower_components": true,
|
||||
@@ -7,4 +6,4 @@
|
||||
"**/src/**/build/**": false,
|
||||
"**/src/build/**": false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
defaultSemverRangePrefix: ''
|
||||
nodeLinker: node-modules
|
||||
@@ -3,4 +3,3 @@
|
||||
When working with this repository follow instructions from CLAUDE.md.
|
||||
|
||||
- [./CLAUDE.md](./CLAUDE.md)
|
||||
|
||||
+63
-4
@@ -6,16 +6,76 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
### 🎉 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))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [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
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
- 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))
|
||||
- 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))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- 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))
|
||||
- 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))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
@@ -25,7 +85,6 @@ 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))
|
||||
|
||||
@@ -9,25 +9,21 @@ 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
|
||||
@@ -35,7 +31,6 @@ 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
|
||||
@@ -43,7 +38,6 @@ 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
|
||||
@@ -55,7 +49,6 @@ 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
|
||||
@@ -69,7 +62,6 @@ 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`)
|
||||
@@ -106,8 +98,8 @@ yarn start-allow-unused
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
yarn lerna run test # Run all tests across packages
|
||||
yarn lerna run test -- --watch # Watch mode across packages (heavier)
|
||||
yarn run -T lerna run test # Run all tests across packages
|
||||
yarn run -T lerna run test -- --watch # Watch mode across packages (heavier)
|
||||
#
|
||||
# If you use `yarn test`, remember to pass args through two separators:
|
||||
# yarn test -- -- --watch
|
||||
@@ -124,7 +116,8 @@ cd packages/steps && yarn test
|
||||
|
||||
```bash
|
||||
yarn typecheck # TypeScript type check all packages
|
||||
yarn lint # ESLint across all packages
|
||||
yarn lint # Oxlint across all packages
|
||||
yarn fmt:check # Oxfmt format check
|
||||
```
|
||||
|
||||
### Running EAS CLI Locally
|
||||
@@ -303,11 +296,14 @@ export EXPO_DEBUG=1 # Enable debug logging
|
||||
|
||||
### Validation
|
||||
|
||||
All changes should be validated with TypeScript and the linter before committing:
|
||||
All changes should be validated before committing. At minimum run package-level tests (e.g. `jest-unit`)
|
||||
for touched code, then run type checks/linting and format the repo:
|
||||
|
||||
```bash
|
||||
yarn fmt # Apply Oxfmt formatting
|
||||
yarn typecheck # Validate TypeScript types
|
||||
yarn lint # Run ESLint
|
||||
yarn lint # Run Oxlint
|
||||
yarn fmt:check # Run Oxfmt format check
|
||||
```
|
||||
|
||||
### Logging
|
||||
@@ -355,5 +351,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 1.22.21
|
||||
- **Package Manager**: Uses Yarn 4.12.0
|
||||
- **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 lerna run test`. For a single package, run `yarn test` in that package directory.
|
||||
From the repository root, run `yarn run -T lerna run test`. For a single package, run `yarn test` in that package directory.
|
||||
|
||||
## Releasing
|
||||
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "17.0.0",
|
||||
"version": "18.3.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
[env]
|
||||
_.path = "./tools/bin"
|
||||
MISE_NODE_COREPACK = "true"
|
||||
|
||||
[tools]
|
||||
node = "20.19.4"
|
||||
yarn = "1.22.22"
|
||||
+10
-10
@@ -6,32 +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": "eslint .",
|
||||
"lint": "oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json .",
|
||||
"fmt": "oxfmt .",
|
||||
"fmt:check": "oxfmt --check .",
|
||||
"lint-changelog": "./scripts/bin/run lint-changelog",
|
||||
"release": "lerna run rebuild && ./scripts/bin/run release",
|
||||
"release": "lerna run rebuild --concurrency 1 && ./scripts/bin/run release",
|
||||
"test": "lerna run test",
|
||||
"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": "^3.9.0",
|
||||
"prettier": "3.1.1",
|
||||
"oclif": "^4.22.85",
|
||||
"oxfmt": "0.28.0",
|
||||
"oxlint": "1.43.0",
|
||||
"oxlint-tsgolint": "0.11.1",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
},
|
||||
"packageManager": "[email protected]"
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"extends": "../../.eslintrc.js",
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"jsPlugins": [
|
||||
{
|
||||
"name": "async-protect",
|
||||
"specifier": "eslint-plugin-async-protect"
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"async-protect/async-suffix": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
@@ -1,6 +1,5 @@
|
||||
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,6 +1,5 @@
|
||||
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,19 +1,22 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.2.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",
|
||||
@@ -21,7 +24,7 @@
|
||||
"prebuild": "yarn gql",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
"jest-unit-watch": "jest --config jest/unit-config.ts --watch",
|
||||
@@ -31,27 +34,26 @@
|
||||
"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": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/downloader": "17.0.0",
|
||||
"@expo/eas-build-job": "17.0.0",
|
||||
"@expo/downloader": "18.0.1",
|
||||
"@expo/eas-build-job": "18.2.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "17.0.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "17.0.0",
|
||||
"@expo/template-file": "17.0.0",
|
||||
"@expo/turtle-spawn": "17.0.0",
|
||||
"@expo/steps": "18.2.0",
|
||||
"@expo/template-file": "18.0.1",
|
||||
"@expo/turtle-spawn": "18.0.1",
|
||||
"@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",
|
||||
@@ -59,6 +61,7 @@
|
||||
"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",
|
||||
|
||||
@@ -19,3 +19,7 @@ export enum LoggerLevel {
|
||||
ERROR = 'error',
|
||||
FATAL = 'fatal',
|
||||
}
|
||||
|
||||
export enum PipeMode {
|
||||
COMBINED = 'combined',
|
||||
}
|
||||
@@ -26,8 +26,14 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
},
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('NPM_CORRUPTED_PACKAGE');
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.message).toBe(
|
||||
'Unknown error. See logs of the Install dependencies build phase for more information.'
|
||||
);
|
||||
expect(err.trackingCode).toBe('NPM_CORRUPTED_PACKAGE');
|
||||
expect(err.metadata).toEqual({
|
||||
packageName: '@typescript-eslint/[email protected]',
|
||||
});
|
||||
});
|
||||
|
||||
it('detects log for invalid bundler and reports it to user', async () => {
|
||||
@@ -45,7 +51,11 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNSUPPORTED_BUNDLER_VERSION_ERROR');
|
||||
expect(err.message).toBe(
|
||||
'Your project requires a different version of the Ruby "bundler" program than the version installed in this EAS Build environment. You can specify which version of "bundler" to install by specifying the version under "build"→[buildProfileName]→"ios"→"bundler" in eas.json.'
|
||||
);
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
expect(err.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not detect errors if they show up in different build phase', async () => {
|
||||
@@ -63,7 +73,10 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.message).toBe(
|
||||
'Unknown error. See logs of the Install dependencies build phase for more information.'
|
||||
);
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects npm cache error if cache is enabled', async () => {
|
||||
@@ -82,8 +95,11 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
},
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('NPM_CACHE_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.message).toBe(
|
||||
'Unknown error. See logs of the Install dependencies build phase for more information.'
|
||||
);
|
||||
expect(err.trackingCode).toBe('NPM_CACHE_ERROR');
|
||||
});
|
||||
|
||||
it('does not detect npm cache error if cache is disabled', async () => {
|
||||
@@ -103,7 +119,10 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.userFacingErrorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(err.message).toBe(
|
||||
'Unknown error. See logs of the Install dependencies build phase for more information.'
|
||||
);
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects xcode line error', async () => {
|
||||
@@ -126,7 +145,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/path/to/'
|
||||
);
|
||||
expect(err.errorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('XCODE_RESOURCE_BUNDLE_CODE_SIGNING_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects minimum deployment target error correctly', async () => {
|
||||
@@ -144,7 +163,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_HIGHER_MINIMUM_DEPLOYMENT_TARGET_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects provisioning profile mismatch error correctly', async () => {
|
||||
@@ -162,7 +181,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_RESIGN_PROVISIONING_PROFILE_MISMATCH_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects generic "Run Fastlane" error for resign correctly', async () => {
|
||||
@@ -178,7 +197,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_RESIGN_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects build error in "Run Fastlane" phase correctly', async () => {
|
||||
@@ -194,7 +213,7 @@ describe(resolveBuildPhaseErrorAsync, () => {
|
||||
'/fake/path'
|
||||
);
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_FASTLANE_ERROR');
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('detects provisioning profile mismatch error correctly', async () => {
|
||||
@@ -240,8 +259,8 @@ note`;
|
||||
'/path/to'
|
||||
);
|
||||
expect(err.errorCode).toBe('XCODE_BUILD_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('XCODE_BUILD_ERROR');
|
||||
expect(err.userFacingMessage)
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
expect(err.message)
|
||||
.toBe(`The "Run fastlane" step failed because of an error in the Xcode build process. We automatically detected following errors in your Xcode build logs:
|
||||
- The signature of “ProgrammaticAccessLibrary.xcframework” cannot be verified.
|
||||
- Some other error
|
||||
@@ -263,9 +282,9 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
|
||||
'/fake/path'
|
||||
);
|
||||
|
||||
expect(err.errorCode).toBe('MAVEN_CACHE_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
|
||||
expect(err.userFacingMessage).toBe(
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
|
||||
expect(err.trackingCode).toBe('MAVEN_CACHE_ERROR');
|
||||
expect(err.message).toBe(
|
||||
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
|
||||
);
|
||||
});
|
||||
@@ -285,8 +304,8 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
|
||||
);
|
||||
|
||||
expect(err.errorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
|
||||
expect(err.userFacingErrorCode).toBe('EAS_BUILD_UNKNOWN_GRADLE_ERROR');
|
||||
expect(err.userFacingMessage).toBe(
|
||||
expect(err.trackingCode).toBeUndefined();
|
||||
expect(err.message).toBe(
|
||||
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ import { ErrorContext, ErrorHandler } from './errors.types';
|
||||
export class TrackedBuildError extends Error {
|
||||
constructor(
|
||||
public errorCode: string,
|
||||
public message: string
|
||||
public message: string,
|
||||
public metadata?: Record<string, unknown>
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
@@ -154,7 +155,9 @@ export const buildErrorHandlers: ErrorHandler<TrackedBuildError>[] = [
|
||||
// [stderr] WARN tarball tarball data for @typescript-eslint/[email protected] (sha512-cozo/GbwixVR0sgfHItz3t1yXu521yn71Wj6PlYCFA3WPhy51CUPkifFKfBis91bDclGmAY45hhaAXVjdn4new==) seems to be corrupted. Trying again.
|
||||
regexp: /tarball tarball data for ([^ ]*) .* seems to be corrupted. Trying again/,
|
||||
createError: (match: RegExpMatchArray) =>
|
||||
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`),
|
||||
new TrackedBuildError('NPM_CORRUPTED_PACKAGE', `npm: corrupted package ${match[1]}`, {
|
||||
packageName: match[1],
|
||||
}),
|
||||
})),
|
||||
...[BuildPhase.INSTALL_DEPENDENCIES, BuildPhase.PREBUILD].map(phase => ({
|
||||
phase,
|
||||
|
||||
@@ -76,31 +76,21 @@ export async function resolveBuildPhaseErrorAsync(
|
||||
const userFacingError =
|
||||
error instanceof errors.UserFacingError
|
||||
? error
|
||||
: resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
|
||||
new errors.UnknownError(errorContext.phase);
|
||||
: (resolveError(userErrorHandlers, logLines, errorContext, xcodeBuildLogs) ??
|
||||
new errors.UnknownError(errorContext.phase));
|
||||
const buildError = resolveError(buildErrorHandlers, logLines, errorContext, xcodeBuildLogs);
|
||||
|
||||
const isUnknownUserError =
|
||||
!userFacingError ||
|
||||
(
|
||||
[
|
||||
errors.ErrorCode.UNKNOWN_ERROR,
|
||||
errors.ErrorCode.UNKNOWN_GRADLE_ERROR,
|
||||
errors.ErrorCode.UNKNOWN_FASTLANE_ERROR,
|
||||
] as string[]
|
||||
).includes(userFacingError.errorCode);
|
||||
const message =
|
||||
(isUnknownUserError ? buildError?.message : userFacingError.message) ?? userFacingError.message;
|
||||
const errorCode =
|
||||
(isUnknownUserError ? buildError?.errorCode : userFacingError.errorCode) ??
|
||||
userFacingError.errorCode;
|
||||
const trackingCode =
|
||||
buildError && buildError.errorCode !== userFacingError.errorCode
|
||||
? buildError.errorCode
|
||||
: undefined;
|
||||
|
||||
return new errors.BuildError(message, {
|
||||
errorCode,
|
||||
userFacingErrorCode: userFacingError.errorCode,
|
||||
userFacingMessage: userFacingError.message,
|
||||
return new errors.BuildError(userFacingError.message, {
|
||||
errorCode: userFacingError.errorCode,
|
||||
trackingCode,
|
||||
docsUrl: userFacingError.docsUrl,
|
||||
innerError: error,
|
||||
cause: error,
|
||||
buildPhase: phase,
|
||||
metadata: buildError?.metadata,
|
||||
});
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
`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"`,
|
||||
'https://docs.expo.dev/build-reference/eas-json/'
|
||||
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -30,7 +30,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
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.`,
|
||||
'https://docs.expo.dev/build-reference/eas-json/'
|
||||
{ docsUrl: 'https://docs.expo.dev/build-reference/eas-json/' }
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -70,7 +70,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
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.',
|
||||
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables'
|
||||
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -85,7 +85,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
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.',
|
||||
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables'
|
||||
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -98,7 +98,7 @@ export const userErrorHandlers: ErrorHandler<UserFacingError>[] = [
|
||||
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.',
|
||||
'https://docs.expo.dev/eas/environment-variables/#file-environment-variables'
|
||||
{ docsUrl: 'https://docs.expo.dev/eas/environment-variables/#file-environment-variables' }
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -234,13 +234,19 @@ You are seeing this error because either:
|
||||
'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.`,
|
||||
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations'
|
||||
{
|
||||
docsUrl:
|
||||
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
|
||||
}
|
||||
)
|
||||
: 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`,
|
||||
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations'
|
||||
{
|
||||
docsUrl:
|
||||
'https://docs.expo.dev/build-reference/infrastructure/#ios-build-server-configurations',
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,8 @@ export async function installDependenciesAsync({
|
||||
let args: string[];
|
||||
switch (packageManager) {
|
||||
case PackageManager.NPM: {
|
||||
args = useFrozenLockfile ? ['ci', '--include=dev'] : ['install'];
|
||||
args = useFrozenLockfile ? ['ci'] : ['install'];
|
||||
args.push('--include=dev');
|
||||
break;
|
||||
}
|
||||
case PackageManager.PNPM: {
|
||||
@@ -44,7 +45,12 @@ export async function installDependenciesAsync({
|
||||
];
|
||||
}
|
||||
} else {
|
||||
args = ['install', ...(useFrozenLockfile ? ['--frozen-lockfile'] : [])];
|
||||
args = [
|
||||
'install',
|
||||
...(useFrozenLockfile ? ['--frozen-lockfile'] : []),
|
||||
'--production',
|
||||
'false',
|
||||
];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -299,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.userFacingMessage}`);
|
||||
this.logger.error(`Error: ${buildError.message}`);
|
||||
}
|
||||
return buildError;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, ExternalBuildContextProvider } from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import assert from 'assert';
|
||||
import path from 'path';
|
||||
|
||||
@@ -53,6 +54,7 @@ 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;
|
||||
@@ -65,6 +67,7 @@ export class CustomBuildContext<TJob extends Job = Job> implements ExternalBuild
|
||||
this.metadata = buildCtx.metadata;
|
||||
|
||||
this.logger = buildCtx.logger.child({ phase: BuildPhase.CUSTOM });
|
||||
this.graphqlClient = buildCtx.graphqlClient;
|
||||
this.projectSourceDirectory = path.join(buildCtx.workingdir, 'temporary-custom-build');
|
||||
this.projectTargetDirectory = path.join(buildCtx.workingdir, 'build');
|
||||
this.defaultWorkingDirectory = buildCtx.getReactNativeProjectDirectory();
|
||||
|
||||
@@ -19,7 +19,9 @@ import { createInstallNodeModulesBuildFunction } from './functions/installNodeMo
|
||||
import { createInstallPodsBuildFunction } from './functions/installPods';
|
||||
import { createInternalEasMaestroTestFunction } from './functions/internalMaestroTest';
|
||||
import { createPrebuildBuildFunction } from './functions/prebuild';
|
||||
import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo';
|
||||
import { createRepackBuildFunction } from './functions/repack';
|
||||
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
|
||||
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
|
||||
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
|
||||
import {
|
||||
@@ -48,6 +50,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createSetUpNpmrcBuildFunction(),
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
createRestoreCacheFunction(),
|
||||
@@ -79,6 +82,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadToAscBuildFunction(),
|
||||
|
||||
createInternalEasMaestroTestFunction(ctx),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
];
|
||||
|
||||
if (ctx.hasBuildJob()) {
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,210 @@
|
||||
import { spawnAsync } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { findMaestroPathsFlowsToExecuteAsync } from '../../../utils/findMaestroPathsFlowsToExecuteAsync';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { createInternalEasMaestroTestFunction } from '../internalMaestroTest';
|
||||
|
||||
jest.mock('@expo/steps', () => ({
|
||||
...jest.requireActual('@expo/steps'),
|
||||
spawnAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils');
|
||||
jest.mock('../../../utils/findMaestroPathsFlowsToExecuteAsync');
|
||||
jest.mock('../../../utils/retry', () => ({
|
||||
retryAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawnAsync = jest.mocked(spawnAsync);
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
const mockedIosUtils = jest.mocked(IosSimulatorUtils);
|
||||
const mockedFindFlows = jest.mocked(findMaestroPathsFlowsToExecuteAsync);
|
||||
|
||||
describe(createInternalEasMaestroTestFunction, () => {
|
||||
const mockUploadArtifact = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vol.mkdirSync(os.tmpdir(), { recursive: true });
|
||||
|
||||
mockedSpawnAsync.mockResolvedValue(undefined as any);
|
||||
mockedSpawn.mockResolvedValue({ stdout: 'source-emulator\n', stderr: '' } as any);
|
||||
mockUploadArtifact.mockResolvedValue({ artifactId: null });
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([
|
||||
{ name: 'iPhone 15', udid: 'test-udid-123' } as any,
|
||||
]);
|
||||
mockedIosUtils.cloneAsync.mockResolvedValue(undefined as any);
|
||||
mockedIosUtils.startAsync.mockResolvedValue({ udid: 'cloned-udid' } as any);
|
||||
mockedIosUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
|
||||
mockedIosUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
|
||||
mockedIosUtils.deleteAsync.mockResolvedValue(undefined as any);
|
||||
|
||||
mockedAndroidUtils.getAttachedDevicesAsync.mockResolvedValue([
|
||||
{ serialId: 'emulator-5554', state: 'device' } as any,
|
||||
]);
|
||||
mockedAndroidUtils.cloneAsync.mockResolvedValue(undefined as any);
|
||||
mockedAndroidUtils.startAsync.mockResolvedValue({
|
||||
serialId: 'emulator-5556',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any);
|
||||
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined as any);
|
||||
mockedAndroidUtils.collectLogsAsync.mockResolvedValue({ outputPath: '/tmp/logs.txt' } as any);
|
||||
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined as any);
|
||||
|
||||
mockedFindFlows.mockResolvedValue(['/project/.maestro/home.yml']);
|
||||
});
|
||||
|
||||
function createStep(overrides?: { callInputs?: Record<string, unknown> }) {
|
||||
const ctx = {
|
||||
runtimeApi: { uploadArtifact: mockUploadArtifact },
|
||||
};
|
||||
const fn = createInternalEasMaestroTestFunction(ctx as any);
|
||||
return fn.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
}),
|
||||
{
|
||||
callInputs: {
|
||||
platform: 'ios',
|
||||
flow_paths: JSON.stringify(['.maestro']),
|
||||
...overrides?.callInputs,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
it('sets junit_report_directory output when output_format is junit', async () => {
|
||||
const step = createStep({
|
||||
callInputs: { output_format: 'junit' },
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
const junitDir = step.getOutputValueByName('junit_report_directory');
|
||||
expect(junitDir).toMatch(/maestro-reports-/);
|
||||
});
|
||||
|
||||
it('does not set junit_report_directory output when output_format is not junit', async () => {
|
||||
const step = createStep();
|
||||
await step.executeAsync();
|
||||
|
||||
const junitDir = step.getOutputValueByName('junit_report_directory');
|
||||
expect(junitDir).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not set junit_report_directory when function throws before reaching output code', async () => {
|
||||
mockedIosUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { output_format: 'junit' },
|
||||
});
|
||||
|
||||
try {
|
||||
await step.executeAsync();
|
||||
} catch {
|
||||
// Expected - no booted device found
|
||||
}
|
||||
|
||||
const junitDir = step.getOutputValueByName('junit_report_directory');
|
||||
expect(junitDir).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retries Android clone startup with increasing timeouts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-1',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-2',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any);
|
||||
mockedAndroidUtils.waitForReadyAsync
|
||||
.mockRejectedValueOnce(new Error('network unavailable'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { platform: 'android' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-1',
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-2',
|
||||
timeoutMs: 120_000,
|
||||
})
|
||||
);
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-1',
|
||||
deviceName: 'eas-simulator-0-0',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails Android flow when clone startup exhausts all attempts', async () => {
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-1',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-2',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
serialId: 'emulator-attempt-3',
|
||||
emulatorPromise: Promise.resolve({}),
|
||||
} as any);
|
||||
mockedAndroidUtils.waitForReadyAsync.mockRejectedValue(new Error('network unavailable'));
|
||||
|
||||
const step = createStep({
|
||||
callInputs: { platform: 'android' },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow('network unavailable');
|
||||
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
serialId: 'emulator-attempt-3',
|
||||
timeoutMs: 180_000,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,594 @@
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import {
|
||||
parseFlowMetadata,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseFlowMetadata, () => {
|
||||
it('parses valid ai-*.json', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toEqual({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when flow_name is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_file_path: '/Users/expo/workingdir/build/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when flow_file_path is missing', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid JSON', async () => {
|
||||
vol.fromJSON({
|
||||
'/tests/2026-01-28_055409/ai-home.json': 'not json',
|
||||
});
|
||||
|
||||
const result = await parseFlowMetadata('/tests/2026-01-28_055409/ai-home.json');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMaestroResults, () => {
|
||||
it('parses JUnit results and enriches with ai-*.json metadata', async () => {
|
||||
vol.fromJSON({
|
||||
// JUnit XML (primary data)
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="2" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.5" status="SUCCESS"/>',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
|
||||
' <failure>Tap failed</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Debug output (for flow_file_path)
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
name: 'home',
|
||||
path: '.maestro/home.yml',
|
||||
status: 'passed',
|
||||
errorMessage: null,
|
||||
duration: 10500,
|
||||
retryCount: 0,
|
||||
tags: [],
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
name: 'login',
|
||||
path: '.maestro/login.yml',
|
||||
status: 'failed',
|
||||
errorMessage: 'Tap failed',
|
||||
duration: 5000,
|
||||
retryCount: 0,
|
||||
tags: [],
|
||||
properties: {},
|
||||
},
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('calculates retryCount from timestamp directory occurrences', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Two timestamp dirs = 1 retry
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055420/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results[0].retryCount).toBe(1);
|
||||
});
|
||||
|
||||
it('uses flow name as fallback path when ai-*.json not found', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// No ai-*.json files
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results[0].path).toBe('home');
|
||||
expect(results[0].retryCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty array when no JUnit files found', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/.gitkeep': '',
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts tags from JUnit properties and separates from other properties', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
|
||||
' <properties>',
|
||||
' <property name="env" value="staging"/>',
|
||||
' <property name="tags" value="e2e, smoke"/>',
|
||||
' </properties>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results[0].tags).toEqual(['e2e', 'smoke']);
|
||||
expect(results[0].properties).toEqual({ env: 'staging' });
|
||||
});
|
||||
|
||||
it('handles reuse_devices=true (junit_report_directory == tests_directory)', async () => {
|
||||
vol.fromJSON({
|
||||
// Same directory for both JUnit and debug output
|
||||
'/maestro-tests/android-maestro-junit.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/maestro-tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/maestro-tests', '/maestro-tests', '/root/project');
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'home',
|
||||
path: '.maestro/home.yml',
|
||||
status: 'passed',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles reuse_devices=false (separate junit_report_directory)', async () => {
|
||||
vol.fromJSON({
|
||||
// JUnit in temp dir (per-flow files)
|
||||
'/tmp/maestro-reports-abc123/junit-report-flow-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tmp/maestro-reports-abc123/junit-report-flow-2.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
|
||||
' <failure>Failed</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
// Debug output in default location
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults(
|
||||
'/tmp/maestro-reports-abc123',
|
||||
'/tests',
|
||||
'/root/project'
|
||||
);
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'home', path: '.maestro/home.yml', status: 'passed' }),
|
||||
expect.objectContaining({ name: 'login', path: '.maestro/login.yml', status: 'failed' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('uses raw path when flow_file_path is outside project_root', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/somewhere/else/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
expect(results[0].path).toBe('/somewhere/else/.maestro/home.yml');
|
||||
});
|
||||
|
||||
it('filters out non-timestamp directories when scanning debug output', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
'/tests/not-a-timestamp/ai-home.json': JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await parseMaestroResults('/junit', '/tests', '/root/project');
|
||||
// Only 1 occurrence (not-a-timestamp dir should be ignored)
|
||||
expect(results[0].retryCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseJUnitTestCases, () => {
|
||||
it('parses a single testcase with SUCCESS status', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0" time="10.5">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.5" status="SUCCESS">',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results).toEqual([
|
||||
{
|
||||
name: 'home',
|
||||
status: 'passed',
|
||||
duration: 10500,
|
||||
errorMessage: null,
|
||||
tags: [],
|
||||
properties: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses a failed testcase with failure message', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
|
||||
' <failure>Element not visible</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results).toEqual([
|
||||
{
|
||||
name: 'login',
|
||||
status: 'failed',
|
||||
duration: 5000,
|
||||
errorMessage: 'Element not visible',
|
||||
tags: [],
|
||||
properties: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts properties from testcase', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
|
||||
' <properties>',
|
||||
' <property name="env" value="staging"/>',
|
||||
' <property name="priority" value="high"/>',
|
||||
' </properties>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].properties).toEqual({ env: 'staging', priority: 'high' });
|
||||
});
|
||||
|
||||
it('extracts tags from "tags" property as comma-separated values', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
|
||||
' <properties>',
|
||||
' <property name="tags" value="smoke, critical, auth"/>',
|
||||
' <property name="priority" value="high"/>',
|
||||
' </properties>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].tags).toEqual(['smoke', 'critical', 'auth']);
|
||||
expect(results[0].properties).toEqual({ priority: 'high' });
|
||||
});
|
||||
|
||||
it('returns empty tags when no "tags" property exists (older Maestro)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
|
||||
' <properties>',
|
||||
' <property name="priority" value="high"/>',
|
||||
' </properties>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].tags).toEqual([]);
|
||||
expect(results[0].properties).toEqual({ priority: 'high' });
|
||||
});
|
||||
|
||||
it('parses multiple testcases across multiple testsuites (shards)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" device="emulator-5554" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
' <testsuite name="Test Suite" device="emulator-5556" tests="1" failures="1">',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
|
||||
' <failure>Tap failed</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'home', status: 'passed' }),
|
||||
expect.objectContaining({ name: 'login', status: 'failed', errorMessage: 'Tap failed' }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('parses multiple JUnit XML files in the same directory', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/junit-report-flow-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/junit/junit-report-flow-2.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="ERROR">',
|
||||
' <failure>Tap failed</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles missing time attribute (defaults to 0)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].duration).toBe(0);
|
||||
});
|
||||
|
||||
it('handles invalid time attribute (defaults to 0)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="not_a_number" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].duration).toBe(0);
|
||||
});
|
||||
|
||||
it('uses @_status attribute for pass/fail (not <failure> presence)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].status).toBe('failed');
|
||||
expect(results[0].errorMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts error message from <error> element', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
|
||||
' <error>Runtime exception occurred</error>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].status).toBe('failed');
|
||||
expect(results[0].errorMessage).toBe('Runtime exception occurred');
|
||||
});
|
||||
|
||||
it('returns empty array when directory does not exist', async () => {
|
||||
const results = await parseJUnitTestCases('/nonexistent');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array when no XML files found', async () => {
|
||||
vol.fromJSON({ '/junit/.gitkeep': '' });
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips invalid XML files gracefully', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/bad.xml': 'not xml at all',
|
||||
'/junit/good.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results).toEqual([expect.objectContaining({ name: 'home', status: 'passed' })]);
|
||||
});
|
||||
|
||||
it('handles testcase with no properties element', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].properties).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { Client } from '@urql/core';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createReportMaestroTestResultsFunction } from '../reportMaestroTestResults';
|
||||
|
||||
const JUNIT_PASS = [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n');
|
||||
|
||||
const JUNIT_FAIL = [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="1">',
|
||||
' <testcase id="home" name="home" classname="home" time="5.0" status="ERROR">',
|
||||
' <failure>Tap failed</failure>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n');
|
||||
|
||||
const FLOW_AI = JSON.stringify({
|
||||
flow_name: 'home',
|
||||
flow_file_path: '/root/project/.maestro/home.yml',
|
||||
});
|
||||
|
||||
describe(createReportMaestroTestResultsFunction, () => {
|
||||
let mockMutationFn: jest.Mock;
|
||||
let mockGraphqlClient: Client;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMutationFn = jest.fn();
|
||||
mockGraphqlClient = {
|
||||
mutation: jest.fn().mockReturnValue({
|
||||
toPromise: mockMutationFn,
|
||||
}),
|
||||
} as unknown as Client;
|
||||
});
|
||||
|
||||
function createStep(overrides?: {
|
||||
callInputs?: Record<string, unknown>;
|
||||
staticContextContent?: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
}) {
|
||||
const ctx = { graphqlClient: mockGraphqlClient };
|
||||
const fn = createReportMaestroTestResultsFunction(ctx as any);
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
projectTargetDirectory: '/root/project',
|
||||
staticContextContent: {
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
job: { secrets: { robotAccessToken: 'test-token' } },
|
||||
...overrides?.staticContextContent,
|
||||
},
|
||||
});
|
||||
globalCtx.updateEnv(overrides?.env ?? { __WORKFLOW_JOB_ID: 'job-uuid-123' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
junit_report_directory: '/junit',
|
||||
tests_directory: '/tests',
|
||||
...overrides?.callInputs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('parses JUnit results and calls GraphQL mutation', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': JUNIT_PASS,
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
expect(variables.input.workflowJobId).toBe('job-uuid-123');
|
||||
expect(variables.input.testCaseResults).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'home',
|
||||
path: '.maestro/home.yml',
|
||||
status: 'PASSED',
|
||||
errorMessage: null,
|
||||
duration: 10000,
|
||||
retryCount: 0,
|
||||
properties: {},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends tags and properties from JUnit XML', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="1" failures="0">',
|
||||
' <testcase id="home" name="home" classname="home" time="10.0" status="SUCCESS">',
|
||||
' <properties>',
|
||||
' <property name="testCaseId" value="TC-001"/>',
|
||||
' <property name="priority" value="high"/>',
|
||||
' <property name="tags" value="e2e"/>',
|
||||
' </properties>',
|
||||
' </testcase>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
expect(variables.input.testCaseResults[0].tags).toEqual(['e2e']);
|
||||
expect(variables.input.testCaseResults[0].properties).toEqual({
|
||||
testCaseId: 'TC-001',
|
||||
priority: 'high',
|
||||
});
|
||||
});
|
||||
|
||||
it('reports failed flow with correct status and errorMessage', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': JUNIT_FAIL,
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
expect(variables.input.testCaseResults[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
status: 'FAILED',
|
||||
errorMessage: 'Tap failed',
|
||||
duration: 5000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// robotAccessToken guard removed — Generic.JobZ requires robotAccessToken (z.string(),
|
||||
// not optional), so it's always present in workflow jobs. graphqlClient is already
|
||||
// initialized with the token in BuildContext constructor.
|
||||
|
||||
it('skips report when junit_report_directory is empty string (upstream step failed)', async () => {
|
||||
const step = createStep({
|
||||
callInputs: { junit_report_directory: '' },
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips report when __WORKFLOW_JOB_ID env is not set', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': JUNIT_PASS,
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
const step = createStep({ env: {} });
|
||||
await step.executeAsync();
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips report when no JUnit files found', async () => {
|
||||
const step = createStep();
|
||||
await step.executeAsync();
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not throw when GraphQL returns error', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': JUNIT_PASS,
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
error: { message: 'Something went wrong' },
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
});
|
||||
|
||||
it('does not throw when mutation rejects', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': JUNIT_PASS,
|
||||
'/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
mockMutationFn.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await createStep().executeAsync();
|
||||
});
|
||||
|
||||
it('skips report when duplicate testcase names found', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites>',
|
||||
' <testsuite name="Test Suite" tests="2" failures="0">',
|
||||
' <testcase id="login" name="login" classname="login" time="10.0" status="SUCCESS"/>',
|
||||
' <testcase id="login" name="login" classname="login" time="5.0" status="SUCCESS"/>',
|
||||
' </testsuite>',
|
||||
'</testsuites>',
|
||||
].join('\n'),
|
||||
'/tests/2026-01-28_055409/ai-login.json': JSON.stringify({
|
||||
flow_name: 'login',
|
||||
flow_file_path: '/root/project/.maestro/login.yml',
|
||||
}),
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses default directories when inputs are not provided', async () => {
|
||||
vol.fromJSON({
|
||||
'/home/expo/.maestro/tests/report.xml': JUNIT_PASS,
|
||||
'/home/expo/.maestro/tests/2026-01-28_055409/ai-home.json': FLOW_AI,
|
||||
});
|
||||
|
||||
const ctx = { graphqlClient: mockGraphqlClient };
|
||||
const fn = createReportMaestroTestResultsFunction(ctx as any);
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
projectTargetDirectory: '/root/project',
|
||||
staticContextContent: {
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
job: { secrets: { robotAccessToken: 'test-token' } },
|
||||
},
|
||||
});
|
||||
globalCtx.updateEnv({ __WORKFLOW_JOB_ID: 'job-uuid-123', HOME: '/home/expo' });
|
||||
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: 'id-1' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const step = fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {},
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
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(),
|
||||
deleteAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>) {
|
||||
const logger = createMockLogger();
|
||||
const fn = createStartAndroidEmulatorBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
globalCtx.updateEnv({ HOME: '/home/expo', ANDROID_HOME: '/android/home' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs,
|
||||
});
|
||||
}
|
||||
|
||||
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.waitForReadyAsync.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.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.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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
isClosedVersionTrainError,
|
||||
isInvalidBundleIdentifierError,
|
||||
isMissingPurposeStringError,
|
||||
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(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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorName, IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { findMaestroPathsFlowsToExecuteAsync } from '../../utils/findMaestroPathsFlowsToExecuteAsync';
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
import { PlatformToProperNounMap } from '../../utils/strings';
|
||||
|
||||
export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
@@ -81,6 +82,10 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
|
||||
id: 'test_reports_artifact_id',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'junit_report_directory',
|
||||
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.
|
||||
@@ -348,6 +353,10 @@ export function createInternalEasMaestroTestFunction(ctx: CustomBuildContext): B
|
||||
}
|
||||
}
|
||||
|
||||
if (output_format === 'junit') {
|
||||
outputs.junit_report_directory.set(maestroReportsDir);
|
||||
}
|
||||
|
||||
const generatedDeviceLogs = await fs.promises.readdir(deviceLogsDir);
|
||||
if (generatedDeviceLogs.length === 0) {
|
||||
stepCtx.logger.warn('No device logs were successfully collected.');
|
||||
@@ -405,6 +414,9 @@ const MaestroOutputFormatToExtensionMap: Record<string, string | undefined> = {
|
||||
html: 'html',
|
||||
};
|
||||
|
||||
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
|
||||
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
|
||||
|
||||
async function withCleanDeviceAsync<TResult>({
|
||||
platform,
|
||||
sourceDeviceIdentifier,
|
||||
@@ -426,7 +438,7 @@ async function withCleanDeviceAsync<TResult>({
|
||||
}): Promise<{ fnResult: TResult; logsResult: Result<{ outputPath: string }> | null }> {
|
||||
// Clone and start the device
|
||||
|
||||
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId;
|
||||
let localDeviceIdentifier: IosSimulatorName | AndroidDeviceSerialId | null = null;
|
||||
|
||||
switch (platform) {
|
||||
case 'ios': {
|
||||
@@ -450,28 +462,81 @@ async function withCleanDeviceAsync<TResult>({
|
||||
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;
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
const attempt = attemptCount + 1;
|
||||
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
|
||||
let serialId: AndroidDeviceSerialId | null = null;
|
||||
try {
|
||||
logger.info(
|
||||
`Cloning Android Emulator ${sourceDeviceIdentifier} to ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
|
||||
);
|
||||
await AndroidEmulatorUtils.cloneAsync({
|
||||
sourceDeviceName: sourceDeviceIdentifier as AndroidVirtualDeviceName,
|
||||
destinationDeviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(
|
||||
`Starting Android Emulator ${localDeviceName} (attempt ${attempt}/${maxAttempts})...`
|
||||
);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
serialId = startResult.serialId;
|
||||
logger.info(`Waiting for Android Emulator ${localDeviceName} to be ready...`);
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
env,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
localDeviceIdentifier = serialId;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Failed to start Android Emulator ${localDeviceName} on attempt ${attempt}/${maxAttempts}.`
|
||||
);
|
||||
try {
|
||||
if (serialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId,
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName: localDeviceName as AndroidVirtualDeviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
logger.warn(
|
||||
{ err: cleanupErr },
|
||||
`Failed to clean up Android Emulator ${localDeviceName}.`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: ANDROID_STARTUP_RETRIES_COUNT,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!localDeviceIdentifier) {
|
||||
throw new Error('Device did not return an identifier after startup.');
|
||||
}
|
||||
|
||||
// Run the function
|
||||
|
||||
const fnResult = await asyncResult(fn({ deviceIdentifier: localDeviceIdentifier }));
|
||||
@@ -502,14 +567,14 @@ async function withCleanDeviceAsync<TResult>({
|
||||
logger.info(`Collecting logs from ${localDeviceName}...`);
|
||||
logsResult = await asyncResult(
|
||||
AndroidEmulatorUtils.collectLogsAsync({
|
||||
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
|
||||
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
|
||||
env,
|
||||
})
|
||||
);
|
||||
|
||||
logger.info(`Cleaning up ${localDeviceName}...`);
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: localDeviceIdentifier as AndroidDeviceSerialId,
|
||||
serialId: localDeviceIdentifier as unknown as AndroidDeviceSerialId,
|
||||
env,
|
||||
});
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface MaestroFlowResult {
|
||||
name: string;
|
||||
path: string;
|
||||
status: 'passed' | 'failed';
|
||||
errorMessage: string | null;
|
||||
duration: number; // milliseconds
|
||||
retryCount: number;
|
||||
tags: string[];
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
|
||||
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
|
||||
|
||||
export function extractFlowKey(filename: string, prefix: string): string | null {
|
||||
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
export interface JUnitTestCaseResult {
|
||||
name: string;
|
||||
status: 'passed' | 'failed';
|
||||
duration: number; // milliseconds
|
||||
errorMessage: string | null;
|
||||
tags: string[];
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
const xmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
// Ensure single-element arrays are always arrays
|
||||
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
||||
});
|
||||
|
||||
export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnitTestCaseResult[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(junitDirectory);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const xmlFiles = entries.filter(f => f.endsWith('.xml'));
|
||||
if (xmlFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
|
||||
for (const xmlFile of xmlFiles) {
|
||||
try {
|
||||
const content = await fs.readFile(path.join(junitDirectory, xmlFile), 'utf-8');
|
||||
const parsed = xmlParser.parse(content);
|
||||
|
||||
const testsuites = parsed?.testsuites?.testsuite;
|
||||
if (!Array.isArray(testsuites)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Use @_status as primary indicator (more robust than checking <failure> presence)
|
||||
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
|
||||
// Extract error message from <failure> or <error> elements
|
||||
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;
|
||||
|
||||
// Extract properties
|
||||
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;
|
||||
}
|
||||
|
||||
// Extract tags from "tags" property (Maestro 2.2.0+, comma-separated)
|
||||
const tagsValue = properties['tags'];
|
||||
const tags: string[] = tagsValue
|
||||
? tagsValue
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
delete properties['tags'];
|
||||
|
||||
results.push({ name, status, duration, errorMessage, tags, properties });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed XML files
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const FlowMetadataFileSchema = z.object({
|
||||
flow_name: z.string(),
|
||||
flow_file_path: z.string(),
|
||||
});
|
||||
|
||||
type FlowMetadata = z.output<typeof FlowMetadataFileSchema>;
|
||||
|
||||
/**
|
||||
* Parses an `ai-*.json` file produced by Maestro's TestDebugReporter.
|
||||
*
|
||||
* The file contains:
|
||||
* - `flow_name`: derived from the YAML `config.name` field if present, otherwise
|
||||
* the flow filename without extension.
|
||||
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/runner/TestRunner.kt#L70
|
||||
* - `flow_file_path`: absolute path to the original flow YAML file.
|
||||
* - `outputs`: screenshot defect data (unused here).
|
||||
*
|
||||
* Filename format: `ai-(flowName).json` where `/` in flowName is replaced with `_`.
|
||||
* See: https://github.com/mobile-dev-inc/Maestro/blob/c0e95fd/maestro-cli/src/main/java/maestro/cli/report/TestDebugReporter.kt#L67
|
||||
*/
|
||||
export async function parseFlowMetadata(filePath: string): Promise<FlowMetadata | null> {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
return FlowMetadataFileSchema.parse(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseMaestroResults(
|
||||
junitDirectory: string,
|
||||
testsDirectory: string,
|
||||
projectRoot: string
|
||||
): Promise<MaestroFlowResult[]> {
|
||||
// 1. Parse JUnit XML files (primary source)
|
||||
const junitResults = await parseJUnitTestCases(junitDirectory);
|
||||
if (junitResults.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. Parse ai-*.json from debug output for flow_file_path + retryCount
|
||||
const flowPathMap = new Map<string, string>(); // flowName → flowFilePath
|
||||
const flowOccurrences = new Map<string, number>(); // flowName → count
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(testsDirectory);
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
|
||||
const timestampDirs = entries.filter(name => TIMESTAMP_DIR_PATTERN.test(name)).sort();
|
||||
|
||||
for (const dir of timestampDirs) {
|
||||
const dirPath = path.join(testsDirectory, dir);
|
||||
let files: string[];
|
||||
try {
|
||||
files = await fs.readdir(dirPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const flowKey = extractFlowKey(file, 'ai');
|
||||
if (!flowKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = await parseFlowMetadata(path.join(dirPath, file));
|
||||
if (!metadata) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track latest path (last timestamp dir wins)
|
||||
flowPathMap.set(metadata.flow_name, metadata.flow_file_path);
|
||||
|
||||
// Count occurrences for retryCount
|
||||
flowOccurrences.set(metadata.flow_name, (flowOccurrences.get(metadata.flow_name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Merge: JUnit results + ai-*.json metadata
|
||||
const results: MaestroFlowResult[] = [];
|
||||
|
||||
for (const junit of junitResults) {
|
||||
const flowFilePath = flowPathMap.get(junit.name);
|
||||
const relativePath = flowFilePath
|
||||
? await relativizePathAsync(flowFilePath, projectRoot)
|
||||
: junit.name; // fallback: use flow name if ai-*.json not found
|
||||
|
||||
const occurrences = flowOccurrences.get(junit.name) ?? 0;
|
||||
const retryCount = Math.max(0, occurrences - 1);
|
||||
|
||||
results.push({
|
||||
name: junit.name,
|
||||
path: relativePath,
|
||||
status: junit.status,
|
||||
errorMessage: junit.errorMessage,
|
||||
duration: junit.duration,
|
||||
retryCount,
|
||||
tags: junit.tags,
|
||||
properties: junit.properties,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function relativizePathAsync(flowFilePath: string, projectRoot: string): Promise<string> {
|
||||
if (!path.isAbsolute(flowFilePath)) {
|
||||
return flowFilePath;
|
||||
}
|
||||
|
||||
// Resolve symlinks (e.g., /tmp -> /private/tmp on macOS) for consistent comparison
|
||||
let resolvedRoot = projectRoot;
|
||||
let resolvedFlow = flowFilePath;
|
||||
try {
|
||||
resolvedRoot = await fs.realpath(projectRoot);
|
||||
} catch {}
|
||||
try {
|
||||
resolvedFlow = await fs.realpath(flowFilePath);
|
||||
} catch {}
|
||||
|
||||
const relative = path.relative(resolvedRoot, resolvedFlow);
|
||||
if (relative.startsWith('..')) {
|
||||
return flowFilePath;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'node:path';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
import { z } from 'zod';
|
||||
import bplistParser from 'bplist-parser';
|
||||
import plist from 'plist';
|
||||
|
||||
export type IpaInfo = {
|
||||
bundleIdentifier: string;
|
||||
bundleShortVersion: string;
|
||||
bundleVersion: string;
|
||||
};
|
||||
|
||||
const INFO_PLIST_PATH_REGEXP = /^Payload\/[^/]+\.app\/Info\.plist$/;
|
||||
|
||||
export function createReadIpaInfoBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'read_ipa_info',
|
||||
name: 'Read IPA Info',
|
||||
__metricsId: 'eas/read_ipa_info',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ipa_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'bundle_identifier',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'bundle_short_version',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'bundle_version',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, outputs }) => {
|
||||
const ipaPathInput = z.string().parse(inputs.ipa_path.value);
|
||||
const ipaPath = path.resolve(stepCtx.workingDirectory, ipaPathInput);
|
||||
if (!(await fs.pathExists(ipaPath))) {
|
||||
throw new UserFacingError(
|
||||
'EAS_READ_IPA_INFO_FILE_NOT_FOUND',
|
||||
`IPA file not found: ${ipaPath}`
|
||||
);
|
||||
}
|
||||
|
||||
const ipaInfo = await readIpaInfoAsync(ipaPath);
|
||||
outputs.bundle_identifier.set(ipaInfo.bundleIdentifier);
|
||||
outputs.bundle_short_version.set(ipaInfo.bundleShortVersion);
|
||||
outputs.bundle_version.set(ipaInfo.bundleVersion);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function readIpaInfoAsync(ipaPath: string): Promise<IpaInfo> {
|
||||
try {
|
||||
const infoPlistBuffer = await readInfoPlistBufferFromIpaAsync(ipaPath);
|
||||
const infoPlist = parseInfoPlistBuffer(infoPlistBuffer);
|
||||
|
||||
const bundleIdentifier = infoPlist.CFBundleIdentifier;
|
||||
if (typeof bundleIdentifier !== 'string') {
|
||||
throw new UserFacingError(
|
||||
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
|
||||
'Failed to read IPA info: Missing or invalid CFBundleIdentifier in Info.plist'
|
||||
);
|
||||
}
|
||||
|
||||
const bundleShortVersion = infoPlist.CFBundleShortVersionString;
|
||||
if (typeof bundleShortVersion !== 'string') {
|
||||
throw new UserFacingError(
|
||||
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
|
||||
'Failed to read IPA info: Missing or invalid CFBundleShortVersionString in Info.plist'
|
||||
);
|
||||
}
|
||||
|
||||
const bundleVersion = infoPlist.CFBundleVersion;
|
||||
if (typeof bundleVersion !== 'string') {
|
||||
throw new UserFacingError(
|
||||
'EAS_READ_IPA_INFO_INVALID_INFO_PLIST',
|
||||
'Failed to read IPA info: Missing or invalid CFBundleVersion in Info.plist'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
bundleIdentifier,
|
||||
bundleShortVersion,
|
||||
bundleVersion,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UserFacingError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new UserFacingError(
|
||||
'EAS_READ_IPA_INFO_FAILED',
|
||||
`Failed to read IPA info: ${(error as Error).message}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseInfoPlistBuffer(data: Buffer): Record<string, unknown> {
|
||||
const isBinaryPlist = data.subarray(0, 8).toString('ascii') === 'bplist00';
|
||||
if (isBinaryPlist) {
|
||||
const parsedBinaryPlists = bplistParser.parseBuffer(data);
|
||||
const parsedBinaryPlist = parsedBinaryPlists[0];
|
||||
if (!parsedBinaryPlist || typeof parsedBinaryPlist !== 'object') {
|
||||
throw new UserFacingError(
|
||||
'EAS_READ_IPA_INFO_INVALID_BINARY_PLIST',
|
||||
'Invalid binary plist in IPA'
|
||||
);
|
||||
}
|
||||
return parsedBinaryPlist as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return plist.parse(data.toString('utf8')) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function readInfoPlistBufferFromIpaAsync(ipaPath: string): Promise<Buffer> {
|
||||
const zip = new StreamZip.async({ file: ipaPath });
|
||||
try {
|
||||
const entries = Object.values(await zip.entries());
|
||||
const infoPlistEntry = entries.find(entry => INFO_PLIST_PATH_REGEXP.test(entry.name));
|
||||
if (!infoPlistEntry) {
|
||||
throw new UserFacingError(
|
||||
'EAS_READ_IPA_INFO_INFO_PLIST_NOT_FOUND',
|
||||
`Failed to read IPA info: Could not find Info.plist in ${ipaPath}`
|
||||
);
|
||||
}
|
||||
return await zip.entryData(infoPlistEntry.name);
|
||||
} finally {
|
||||
await zip.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
const CREATE_MUTATION = graphql(`
|
||||
mutation CreateWorkflowDeviceTestCaseResults($input: CreateWorkflowDeviceTestCaseResultsInput!) {
|
||||
workflowDeviceTestCaseResult {
|
||||
createWorkflowDeviceTestCaseResults(input: $input) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const FLOW_STATUS_TO_TEST_CASE_RESULT_STATUS: Record<string, 'PASSED' | 'FAILED' | undefined> = {
|
||||
passed: 'PASSED',
|
||||
failed: 'FAILED',
|
||||
} satisfies Record<MaestroFlowResult['status'], 'PASSED' | 'FAILED'>;
|
||||
|
||||
export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'report_maestro_test_results',
|
||||
name: 'Report Maestro Test Results',
|
||||
__metricsId: 'eas/report_maestro_test_results',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'junit_report_directory',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
defaultValue: '${{ env.HOME }}/.maestro/tests',
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'tests_directory',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
defaultValue: '${{ env.HOME }}/.maestro/tests',
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs }) => {
|
||||
const { logger } = stepsCtx;
|
||||
const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID;
|
||||
if (!workflowJobId) {
|
||||
logger.info('Not running in a workflow job, skipping test results report');
|
||||
return;
|
||||
}
|
||||
const junitDirectory = (inputs.junit_report_directory.value as string | undefined) ?? '';
|
||||
if (!junitDirectory) {
|
||||
logger.info('No JUnit directory provided, skipping test results report');
|
||||
return;
|
||||
}
|
||||
const testsDirectory = inputs.tests_directory.value as string;
|
||||
|
||||
try {
|
||||
const flowResults = await parseMaestroResults(
|
||||
junitDirectory,
|
||||
testsDirectory,
|
||||
stepsCtx.workingDirectory
|
||||
);
|
||||
if (flowResults.length === 0) {
|
||||
logger.info('No maestro test results found, skipping report');
|
||||
return;
|
||||
}
|
||||
|
||||
// Maestro allows overriding flow names via config, so different flow files can share
|
||||
// the same name. JUnit XML only contains names (not file paths), making it impossible
|
||||
// to map duplicates back to their original flow files. Skip and let the user fix it.
|
||||
const names = flowResults.map(r => r.name);
|
||||
const duplicates = names.filter((n, i) => names.indexOf(n) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
logger.error(
|
||||
`Duplicate test case names found in JUnit output: ${[...new Set(duplicates)].join(
|
||||
', '
|
||||
)}. Skipping report. Ensure each Maestro flow has a unique name.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const testCaseResults = flowResults.flatMap(f => {
|
||||
const status = FLOW_STATUS_TO_TEST_CASE_RESULT_STATUS[f.status];
|
||||
if (!status) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: f.name,
|
||||
path: f.path,
|
||||
status,
|
||||
errorMessage: f.errorMessage,
|
||||
duration: f.duration,
|
||||
retryCount: f.retryCount,
|
||||
tags: f.tags,
|
||||
properties: f.properties,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_MUTATION, {
|
||||
input: {
|
||||
workflowJobId,
|
||||
testCaseResults,
|
||||
},
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
logger.error({ error: result.error }, 'GraphQL error creating test case results');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Reported ${testCaseResults.length} test case result(s).`);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Failed to create test case results');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -58,11 +58,6 @@ export function createRestoreCacheFunction(): BuildFunction {
|
||||
const { logger } = stepsCtx;
|
||||
|
||||
try {
|
||||
if (stepsCtx.global.staticContext.job.platform) {
|
||||
logger.error('Caches are not supported in build jobs yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const paths = z
|
||||
.array(z.string())
|
||||
.parse(((inputs.path.value ?? '') as string).split(/[\r\n]+/))
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
|
||||
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
|
||||
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
|
||||
|
||||
export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
@@ -72,25 +75,78 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Creating emulator device');
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
let emulatorPromise = null;
|
||||
let serialId = null;
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
const attempt = attemptCount + 1;
|
||||
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
|
||||
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
|
||||
let attemptSerialId = null;
|
||||
|
||||
logger.info('Starting emulator device');
|
||||
const { emulatorPromise, serialId } = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
env,
|
||||
serialId,
|
||||
});
|
||||
logger.info(`${deviceName} is ready.`);
|
||||
try {
|
||||
logger.info(`Creating emulator device${attemptSuffix}.`);
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`Starting emulator device${attemptSuffix}.`);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
attemptSerialId = startResult.serialId;
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
env,
|
||||
serialId: attemptSerialId,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
logger.info(`${deviceName} is ready.`);
|
||||
|
||||
serialId = attemptSerialId;
|
||||
emulatorPromise = startResult.emulatorPromise;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`
|
||||
);
|
||||
try {
|
||||
if (attemptSerialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: attemptSerialId,
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: ANDROID_STARTUP_RETRIES_COUNT,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!serialId || !emulatorPromise) {
|
||||
throw new Error(`Failed to start emulator ${deviceName}.`);
|
||||
}
|
||||
|
||||
const count = Number(inputs.count.value ?? 1);
|
||||
if (count > 1) {
|
||||
@@ -105,27 +161,70 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cloneIdentifier = `eas-simulator-${i + 1}` as AndroidVirtualDeviceName;
|
||||
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}...`);
|
||||
await AndroidEmulatorUtils.cloneAsync({
|
||||
sourceDeviceName: deviceName,
|
||||
destinationDeviceName: cloneIdentifier,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
const attempt = attemptCount + 1;
|
||||
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
|
||||
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
|
||||
let cloneSerialId = null;
|
||||
try {
|
||||
logger.info(`Cloning ${deviceName} to ${cloneIdentifier}${attemptSuffix}.`);
|
||||
await AndroidEmulatorUtils.cloneAsync({
|
||||
sourceDeviceName: deviceName,
|
||||
destinationDeviceName: cloneIdentifier,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Starting emulator device');
|
||||
const { serialId } = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
});
|
||||
logger.info(`Starting emulator device${attemptSuffix}.`);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
});
|
||||
cloneSerialId = startResult.serialId;
|
||||
|
||||
logger.info('Waiting for emulator to become ready');
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
env,
|
||||
});
|
||||
logger.info('Waiting for emulator to become ready');
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId: cloneSerialId,
|
||||
env,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`${cloneIdentifier} is ready.`);
|
||||
logger.info(`${cloneIdentifier} is ready.`);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`${cloneIdentifier} failed to start on attempt ${attempt}/${maxAttempts}.`
|
||||
);
|
||||
try {
|
||||
if (cloneSerialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: cloneSerialId,
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName: cloneIdentifier,
|
||||
env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
logger.warn({ err: cleanupErr }, `Failed to clean up ${cloneIdentifier}.`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: ANDROID_STARTUP_RETRIES_COUNT,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
@@ -12,6 +14,8 @@ import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient';
|
||||
import { AscApiUtils } from '../utils/ios/AscApiUtils';
|
||||
import { readIpaInfoAsync } from './readIpaInfo';
|
||||
|
||||
export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -101,34 +105,21 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
|
||||
const client = new AscApiClient({ token, logger: stepsCtx.logger });
|
||||
|
||||
stepsCtx.logger.info('Reading App information...');
|
||||
const appResponse = await client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{ 'fields[apps]': ['bundleId', 'name'] },
|
||||
{ id: appleAppIdentifier }
|
||||
);
|
||||
stepsCtx.logger.info(
|
||||
`Uploading Build to "${appResponse.data.attributes.name}" (${appResponse.data.attributes.bundleId})...`
|
||||
`Reading App information for Apple app identifier: ${appleAppIdentifier}...`
|
||||
);
|
||||
const appResponse = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
|
||||
const ascAppBundleIdentifier = appResponse.data.attributes.bundleId;
|
||||
stepsCtx.logger.info(
|
||||
`Uploading Build to "${appResponse.data.attributes.name}" (${ascAppBundleIdentifier})...`
|
||||
);
|
||||
|
||||
stepsCtx.logger.info('Creating Build Upload...');
|
||||
const buildUploadResponse = await client.postAsync('/v1/buildUploads', {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
attributes: {
|
||||
platform: 'IOS',
|
||||
cfBundleShortVersionString: bundleShortVersion,
|
||||
cfBundleVersion: bundleVersion,
|
||||
},
|
||||
relationships: {
|
||||
app: {
|
||||
data: {
|
||||
type: 'apps',
|
||||
id: appleAppIdentifier,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
const buildUploadResponse = await AscApiUtils.createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier,
|
||||
bundleShortVersion,
|
||||
bundleVersion,
|
||||
});
|
||||
|
||||
const buildUploadId = buildUploadResponse.data.id;
|
||||
@@ -234,7 +225,10 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
|
||||
stepsCtx.logger.info('Checking build upload status...');
|
||||
const waitingForBuildStartedAt = Date.now();
|
||||
while (Date.now() - waitingForBuildStartedAt < 10 * 60 * 1000 /* 10 minutes */) {
|
||||
const waitingLogIntervalMs = 10 * 1000;
|
||||
let lastWaitLogTime = 0;
|
||||
let lastWaitLogState: string | null = null;
|
||||
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
|
||||
const {
|
||||
data: {
|
||||
attributes: { state },
|
||||
@@ -246,7 +240,14 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
);
|
||||
|
||||
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
|
||||
stepsCtx.logger.info(`Waiting for build upload to complete... (status = ${state.state})`);
|
||||
const now = Date.now();
|
||||
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
|
||||
stepsCtx.logger.info(
|
||||
`Waiting for build upload to complete... (status = ${state.state})`
|
||||
);
|
||||
lastWaitLogTime = now;
|
||||
lastWaitLogState = state.state;
|
||||
}
|
||||
await setTimeout(2000);
|
||||
continue;
|
||||
}
|
||||
@@ -265,6 +266,50 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
}
|
||||
|
||||
if (state.state === 'FAILED') {
|
||||
if (isInvalidBundleIdentifierError(errors)) {
|
||||
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
|
||||
const ipaBundleIdentifier = ipaInfoResult.ok
|
||||
? ipaInfoResult.value.bundleIdentifier
|
||||
: null;
|
||||
|
||||
throw new UserFacingError(
|
||||
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
|
||||
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
|
||||
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
|
||||
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
|
||||
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
|
||||
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
|
||||
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).'
|
||||
);
|
||||
}
|
||||
if (isMissingPurposeStringError(errors)) {
|
||||
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
|
||||
throw new UserFacingError(
|
||||
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
|
||||
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
|
||||
`${
|
||||
missingUsageDescriptionKeys.length > 0
|
||||
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
|
||||
'\n- '
|
||||
)}\n\n`
|
||||
: ''
|
||||
}` +
|
||||
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
|
||||
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
|
||||
'If you do not use CNG, update your app target Info.plist directly.',
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
|
||||
}
|
||||
);
|
||||
}
|
||||
if (isClosedVersionTrainError(errors)) {
|
||||
throw new UserFacingError(
|
||||
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
|
||||
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
|
||||
'This usually means the version train is closed or lower than a previously approved version. ' +
|
||||
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
|
||||
);
|
||||
}
|
||||
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
|
||||
} else if (state.state === 'COMPLETE') {
|
||||
stepsCtx.logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
|
||||
@@ -279,6 +324,36 @@ function itemizeMessages(messages: { description: string; code: string }[]): str
|
||||
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
|
||||
}
|
||||
|
||||
export function isClosedVersionTrainError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 &&
|
||||
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
|
||||
);
|
||||
}
|
||||
|
||||
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
|
||||
return (
|
||||
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
|
||||
);
|
||||
}
|
||||
|
||||
export function isMissingPurposeStringError(messages: { code: string }[]): boolean {
|
||||
return messages.length > 0 && messages.every(message => message.code === '90683');
|
||||
}
|
||||
|
||||
export function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
|
||||
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
|
||||
const keys = new Set<string>();
|
||||
for (const message of messages) {
|
||||
const matches = message.description.matchAll(usageDescriptionKeyRegex);
|
||||
for (const match of matches) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
async function uploadChunksAsync({
|
||||
uploadOperations,
|
||||
ipaPath,
|
||||
|
||||
@@ -11,7 +11,43 @@ type ApiSchema = {
|
||||
};
|
||||
};
|
||||
|
||||
const AscErrorResponseSchema = z.object({
|
||||
errors: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
detail: z.string().optional(),
|
||||
source: z.unknown().optional(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
|
||||
const GetApi = {
|
||||
'/v1/apps': {
|
||||
path: z.object({}),
|
||||
request: z.object({
|
||||
'fields[apps]': z.array(z.enum(['bundleId', 'name'])).refine(opts => {
|
||||
return opts.includes('bundleId') && opts.includes('name');
|
||||
}),
|
||||
limit: z.number().int().min(1).max(200).optional(),
|
||||
}),
|
||||
response: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
type: z.literal('apps'),
|
||||
id: z.string(),
|
||||
attributes: z.object({
|
||||
bundleId: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
})
|
||||
),
|
||||
}),
|
||||
},
|
||||
'/v1/apps/:id': {
|
||||
path: z.object({
|
||||
id: z.string(),
|
||||
@@ -222,6 +258,13 @@ const PatchApi = {
|
||||
},
|
||||
} satisfies ApiSchema;
|
||||
|
||||
export type AscApiClientGetApi = {
|
||||
[Path in keyof typeof GetApi]: {
|
||||
request: z.input<(typeof GetApi)[Path]['request']>;
|
||||
response: z.output<(typeof GetApi)[Path]['response']>;
|
||||
};
|
||||
};
|
||||
|
||||
export type AscApiClientPostApi = {
|
||||
[Path in keyof typeof PostApi]: {
|
||||
request: z.input<(typeof PostApi)[Path]['request']>;
|
||||
@@ -236,6 +279,17 @@ export type AscApiClientPatchApi = {
|
||||
};
|
||||
};
|
||||
|
||||
export class AscApiRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly responseJson: z.output<typeof AscErrorResponseSchema>,
|
||||
options?: { cause?: unknown }
|
||||
) {
|
||||
super(message, { cause: options?.cause });
|
||||
}
|
||||
}
|
||||
|
||||
export class AscApiClient {
|
||||
private readonly baseUrl = 'https://api.appstoreconnect.apple.com';
|
||||
private readonly token: string;
|
||||
@@ -346,12 +400,31 @@ export class AscApiClient {
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const parsedAscErrorResponse = await asyncResult(
|
||||
(async () => AscErrorResponseSchema.parse(JSON.parse(text)))()
|
||||
);
|
||||
if (parsedAscErrorResponse.ok) {
|
||||
throw new AscApiRequestError(
|
||||
`Unexpected response (${response.status}) from App Store Connect: ${text}`,
|
||||
response.status,
|
||||
parsedAscErrorResponse.value,
|
||||
{ cause: response }
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected response (${response.status}) from App Store Connect: ${text}`, {
|
||||
cause: response,
|
||||
});
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const text = await response.text();
|
||||
const parsedJson = await asyncResult((async () => JSON.parse(text))());
|
||||
if (!parsedJson.ok) {
|
||||
throw new Error(
|
||||
`Malformed JSON response from App Store Connect (${response.status}): ${text}`
|
||||
);
|
||||
}
|
||||
const json = parsedJson.value;
|
||||
|
||||
this.logger?.debug(`Response from App Store Connect: ${JSON.stringify(json, null, 2)}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { UserFacingError } from '@expo/eas-build-job/dist/errors';
|
||||
|
||||
import {
|
||||
AscApiClient,
|
||||
AscApiClientGetApi,
|
||||
AscApiClientPostApi,
|
||||
AscApiRequestError,
|
||||
} from './AscApiClient';
|
||||
|
||||
export namespace AscApiUtils {
|
||||
export async function getAppInfoAsync({
|
||||
client,
|
||||
appleAppIdentifier,
|
||||
}: {
|
||||
client: Pick<AscApiClient, 'getAsync'>;
|
||||
appleAppIdentifier: string;
|
||||
}): Promise<AscApiClientGetApi['/v1/apps/:id']['response']> {
|
||||
try {
|
||||
return await client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{ 'fields[apps]': ['bundleId', 'name'] },
|
||||
{ id: appleAppIdentifier }
|
||||
);
|
||||
} catch (error) {
|
||||
const notFoundErrors =
|
||||
error instanceof AscApiRequestError && error.status === 404
|
||||
? error.responseJson.errors
|
||||
: [];
|
||||
const isAppNotFoundError =
|
||||
notFoundErrors.length > 0 && notFoundErrors.every(item => item.code === 'NOT_FOUND');
|
||||
if (!isAppNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
let visibleAppsSummary: string | null = null;
|
||||
try {
|
||||
visibleAppsSummary = await getVisibleAppsSummaryAsync(client);
|
||||
} catch {
|
||||
// Don't hide the original NOT_FOUND error with a secondary lookup failure.
|
||||
throw error;
|
||||
}
|
||||
throw new UserFacingError(
|
||||
'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
|
||||
`App Store Connect app for application identifier ${appleAppIdentifier} was not found. ` +
|
||||
'Verify the configured application identifier and that the App Store Connect API key has access to the application in the correct App Store Connect account.' +
|
||||
(visibleAppsSummary
|
||||
? `\n\nExample applications visible to this API key:\n${visibleAppsSummary}`
|
||||
: ''),
|
||||
{
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
cause: error,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier,
|
||||
bundleShortVersion,
|
||||
bundleVersion,
|
||||
}: {
|
||||
client: Pick<AscApiClient, 'postAsync'>;
|
||||
appleAppIdentifier: string;
|
||||
bundleShortVersion: string;
|
||||
bundleVersion: string;
|
||||
}): Promise<AscApiClientPostApi['/v1/buildUploads']['response']> {
|
||||
try {
|
||||
return await client.postAsync('/v1/buildUploads', {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
attributes: {
|
||||
platform: 'IOS',
|
||||
cfBundleShortVersionString: bundleShortVersion,
|
||||
cfBundleVersion: bundleVersion,
|
||||
},
|
||||
relationships: {
|
||||
app: {
|
||||
data: {
|
||||
type: 'apps',
|
||||
id: appleAppIdentifier,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errors =
|
||||
error instanceof AscApiRequestError && error.status === 409
|
||||
? error.responseJson.errors
|
||||
: [];
|
||||
const isDuplicateVersionError =
|
||||
errors.length > 0 &&
|
||||
errors.every(item => item.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE');
|
||||
|
||||
if (isDuplicateVersionError) {
|
||||
throw new UserFacingError(
|
||||
'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
|
||||
`Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
|
||||
'App Store Connect requires unique build numbers within each app version (version train). ' +
|
||||
'Increment it by setting ios.buildNumber in app.json, or set "autoIncrement": true in eas.json (recommended). Then rebuild and resubmit.',
|
||||
{
|
||||
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
|
||||
cause: error,
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getVisibleAppsSummaryAsync(
|
||||
client: Pick<AscApiClient, 'getAsync'>
|
||||
): Promise<string | null> {
|
||||
const appsResponse = await client.getAsync('/v1/apps', {
|
||||
'fields[apps]': ['bundleId', 'name'],
|
||||
limit: 10,
|
||||
});
|
||||
if (appsResponse.data.length === 0) {
|
||||
return ' (none)';
|
||||
}
|
||||
return appsResponse.data
|
||||
.map(app => `- ${app.attributes.name} (${app.attributes.bundleId}) (ID: ${app.id})`)
|
||||
.join('\n');
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import nock from 'nock';
|
||||
|
||||
import { AscApiClient } from '../AscApiClient';
|
||||
import { AscApiClient, AscApiRequestError } from '../AscApiClient';
|
||||
|
||||
// nock needs real fetch implementation
|
||||
jest.unmock('node-fetch');
|
||||
@@ -280,4 +280,54 @@ describe(AscApiClient, () => {
|
||||
});
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('throws AscApiRequestError for structured ASC error payload', async () => {
|
||||
const appId = '1491144534';
|
||||
const responseFixture = {
|
||||
errors: [
|
||||
{
|
||||
status: '409',
|
||||
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
|
||||
title:
|
||||
'The provided entity includes an attribute with a value that has already been used',
|
||||
detail: 'The bundle version must be higher than the previously uploaded version.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/apps/${appId}`)
|
||||
.query({ 'fields[apps]': 'bundleId,name' })
|
||||
.reply(409, responseFixture);
|
||||
|
||||
await expect(
|
||||
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
|
||||
).rejects.toBeInstanceOf(AscApiRequestError);
|
||||
});
|
||||
|
||||
it('throws regular Error for non-structured ASC error payload', async () => {
|
||||
const appId = '1491144534';
|
||||
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/apps/${appId}`)
|
||||
.query({ 'fields[apps]': 'bundleId,name' })
|
||||
.reply(503, 'Service unavailable');
|
||||
|
||||
await expect(
|
||||
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
|
||||
).rejects.not.toBeInstanceOf(AscApiRequestError);
|
||||
});
|
||||
|
||||
it('throws controlled error when success response body is not valid JSON', async () => {
|
||||
const appId = '1491144534';
|
||||
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get(`/v1/apps/${appId}`)
|
||||
.query({ 'fields[apps]': 'bundleId,name' })
|
||||
.reply(200, 'not-json');
|
||||
|
||||
await expect(
|
||||
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
|
||||
).rejects.toThrow('Malformed JSON response from App Store Connect (200): not-json');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { AscApiRequestError } from '../AscApiClient';
|
||||
import { AscApiUtils } from '../AscApiUtils';
|
||||
|
||||
describe('AscApiUtils', () => {
|
||||
describe('getAppInfoAsync', () => {
|
||||
it('returns app info when lookup succeeds', async () => {
|
||||
const response = {
|
||||
data: {
|
||||
type: 'apps',
|
||||
id: '1491144534',
|
||||
attributes: {
|
||||
name: 'Example App',
|
||||
bundleId: 'com.example.app',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const client = {
|
||||
getAsync: jest.fn().mockResolvedValue(response),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1491144534' })
|
||||
).resolves.toEqual(response);
|
||||
});
|
||||
|
||||
it('throws UserFacingError with visible apps when app id is not found', async () => {
|
||||
const notFoundPayload = {
|
||||
errors: [
|
||||
{
|
||||
status: '404',
|
||||
code: 'NOT_FOUND',
|
||||
detail: "There is no resource of type 'apps' with id '1234567890'",
|
||||
},
|
||||
],
|
||||
};
|
||||
const notFoundError = new AscApiRequestError(
|
||||
'Unexpected response (404) from App Store Connect',
|
||||
404,
|
||||
notFoundPayload
|
||||
);
|
||||
const client = {
|
||||
getAsync: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(notFoundError)
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
type: 'apps',
|
||||
id: '1111111111',
|
||||
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
message: expect.stringContaining(
|
||||
'App Store Connect app for application identifier 1234567890 was not found'
|
||||
),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rethrows original not-found error when app-list lookup fails', async () => {
|
||||
const notFoundPayload = {
|
||||
errors: [
|
||||
{
|
||||
status: '404',
|
||||
code: 'NOT_FOUND',
|
||||
},
|
||||
],
|
||||
};
|
||||
const notFoundError = new AscApiRequestError(
|
||||
'Unexpected response (404) from App Store Connect',
|
||||
404,
|
||||
notFoundPayload
|
||||
);
|
||||
|
||||
const listingError = new Error('listing failed');
|
||||
const client = {
|
||||
getAsync: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(notFoundError)
|
||||
.mockRejectedValueOnce(listingError),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
|
||||
).rejects.toBe(notFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBuildUploadAsync', () => {
|
||||
it('throws UserFacingError when ASC duplicate version error is returned', async () => {
|
||||
const payload = {
|
||||
errors: [
|
||||
{
|
||||
status: '409',
|
||||
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
|
||||
detail: 'The bundle version must be higher than the previously uploaded version.',
|
||||
},
|
||||
],
|
||||
};
|
||||
const duplicateError = new AscApiRequestError(
|
||||
'Unexpected response (409) from App Store Connect',
|
||||
409,
|
||||
payload
|
||||
);
|
||||
|
||||
const client = {
|
||||
postAsync: jest.fn().mockRejectedValue(duplicateError),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
})
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
|
||||
message: expect.stringContaining('Increment Build Number'),
|
||||
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns response when upload initialization succeeds', async () => {
|
||||
const response = {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
id: 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805',
|
||||
},
|
||||
} as const;
|
||||
|
||||
const client = {
|
||||
postAsync: jest.fn().mockResolvedValue(response),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
})
|
||||
).resolves.toEqual(response);
|
||||
});
|
||||
|
||||
it('rethrows when error payload includes mixed error codes', async () => {
|
||||
const payload = {
|
||||
errors: [
|
||||
{
|
||||
status: '409',
|
||||
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
|
||||
},
|
||||
{
|
||||
status: '409',
|
||||
code: 'SOME_OTHER_ERROR',
|
||||
},
|
||||
],
|
||||
};
|
||||
const mixedError = new AscApiRequestError(
|
||||
'Unexpected response (409) from App Store Connect',
|
||||
409,
|
||||
payload
|
||||
);
|
||||
const client = {
|
||||
postAsync: jest.fn().mockRejectedValue(mixedError),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.createBuildUploadAsync({
|
||||
client,
|
||||
appleAppIdentifier: '1491144534',
|
||||
bundleShortVersion: '1.2.3',
|
||||
bundleVersion: '42',
|
||||
})
|
||||
).rejects.toBe(mixedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import assert from 'assert';
|
||||
import FastGlob from 'fast-glob';
|
||||
@@ -17,6 +18,8 @@ export type AndroidDeviceName = string & z.BRAND<'AndroidDeviceName'>;
|
||||
export type AndroidDeviceSerialId = string & z.BRAND<'AndroidDeviceSerialId'>;
|
||||
|
||||
export namespace AndroidEmulatorUtils {
|
||||
const RETRY_INTERVAL_MS = 1_000;
|
||||
|
||||
export const defaultSystemImagePackage = `system-images;android-30;default;${
|
||||
process.arch === 'arm64' ? 'arm64-v8a' : 'x86_64'
|
||||
}`;
|
||||
@@ -352,10 +355,15 @@ export namespace AndroidEmulatorUtils {
|
||||
export async function waitForReadyAsync({
|
||||
serialId,
|
||||
env,
|
||||
timeoutMs = 3 * 60 * 1_000,
|
||||
logger,
|
||||
}: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
logger?: bunyan;
|
||||
}): Promise<void> {
|
||||
const retries = Math.max(0, Math.ceil(timeoutMs / RETRY_INTERVAL_MS) - 1);
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const { stdout } = await spawn(
|
||||
@@ -367,17 +375,48 @@ export namespace AndroidEmulatorUtils {
|
||||
if (!stdout.startsWith('1')) {
|
||||
throw new Error(`Emulator (${serialId}) boot has not completed.`);
|
||||
}
|
||||
|
||||
const hasNetworkConnection = await hasNetworkConnectionAsync({ serialId, env });
|
||||
if (!hasNetworkConnection) {
|
||||
throw new Error(`Emulator (${serialId}) network is not ready.`);
|
||||
}
|
||||
},
|
||||
{
|
||||
// Retry every second for 3 minutes.
|
||||
retryOptions: {
|
||||
retries: 3 * 60,
|
||||
retryIntervalMs: 1_000,
|
||||
retries,
|
||||
retryIntervalMs: RETRY_INTERVAL_MS,
|
||||
},
|
||||
logger,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function hasNetworkConnectionAsync({
|
||||
serialId,
|
||||
env,
|
||||
}: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<boolean> {
|
||||
const networkReadyCheckCommand = env.ANDROID_EMULATOR_NETWORK_READY_COMMAND?.trim();
|
||||
if (networkReadyCheckCommand) {
|
||||
const customNetworkCheckResult = await asyncResult(
|
||||
spawn('adb', ['-s', serialId, 'shell', networkReadyCheckCommand], { env })
|
||||
);
|
||||
return customNetworkCheckResult.ok;
|
||||
}
|
||||
|
||||
const netcatResult = await asyncResult(
|
||||
spawn(
|
||||
'adb',
|
||||
['-s', serialId, 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
|
||||
// Close stdin to make netcat exit cleanly on Android images that don't support `-z`.
|
||||
{ env, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
)
|
||||
);
|
||||
return netcatResult.ok;
|
||||
}
|
||||
|
||||
export async function collectLogsAsync({
|
||||
serialId,
|
||||
env,
|
||||
@@ -419,18 +458,13 @@ export namespace AndroidEmulatorUtils {
|
||||
return { outputPath };
|
||||
}
|
||||
|
||||
export async function deleteAsync({
|
||||
export async function stopAsync({
|
||||
serialId,
|
||||
env,
|
||||
}: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
|
||||
env,
|
||||
});
|
||||
const deviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0];
|
||||
|
||||
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env });
|
||||
|
||||
await retryAsync(
|
||||
@@ -443,12 +477,40 @@ export namespace AndroidEmulatorUtils {
|
||||
{
|
||||
retryOptions: {
|
||||
retries: 3 * 60,
|
||||
retryIntervalMs: 1_000,
|
||||
retryIntervalMs: RETRY_INTERVAL_MS,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await spawn('avdmanager', ['delete', 'avd', '-n', deviceName], { env });
|
||||
export async function deleteAsync({
|
||||
serialId,
|
||||
deviceName,
|
||||
env,
|
||||
}: {
|
||||
serialId?: AndroidDeviceSerialId;
|
||||
deviceName?: AndroidVirtualDeviceName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
let resolvedDeviceName = deviceName;
|
||||
if (!resolvedDeviceName && serialId) {
|
||||
const adbEmuAvdName = await spawn('adb', ['-s', serialId, 'emu', 'avd', 'name'], {
|
||||
env,
|
||||
});
|
||||
resolvedDeviceName = adbEmuAvdName.stdout.replace(/\r\n/g, '\n').split('\n')[0] as
|
||||
| AndroidVirtualDeviceName
|
||||
| undefined;
|
||||
}
|
||||
|
||||
assert(resolvedDeviceName, 'Either "serialId" or "deviceName" must resolve to a device name.');
|
||||
|
||||
const serialIdToStop =
|
||||
serialId ?? (await getSerialIdAsync({ deviceName: resolvedDeviceName, env })) ?? undefined;
|
||||
if (serialIdToStop) {
|
||||
await stopAsync({ serialId: serialIdToStop, env });
|
||||
}
|
||||
|
||||
await spawn('avdmanager', ['delete', 'avd', '-n', resolvedDeviceName], { env });
|
||||
}
|
||||
|
||||
export async function startScreenRecordingAsync({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
// We need to use real fs for cloning devices to work.
|
||||
jest.unmock('fs');
|
||||
@@ -104,7 +105,10 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
expect(stdout).toContain('data');
|
||||
|
||||
await spawn('adb', ['-s', serialId, 'emu', 'kill'], { env: process.env });
|
||||
await AndroidEmulatorUtils.stopAsync({
|
||||
serialId,
|
||||
env: process.env,
|
||||
});
|
||||
await asyncResult(emulatorPromise);
|
||||
|
||||
const cloneDeviceName = (deviceName + '-clone') as AndroidVirtualDeviceName;
|
||||
@@ -131,12 +135,133 @@ describe('AndroidEmulatorUtils', () => {
|
||||
expect(stdoutClone).toContain('data');
|
||||
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
});
|
||||
await asyncResult(emulatorPromiseClone);
|
||||
}, 60_000);
|
||||
|
||||
it('should delete a running emulator by device name', async () => {
|
||||
const deviceName = 'android-emulator-delete-by-name-test' as AndroidVirtualDeviceName;
|
||||
const avdPath = `${process.env.HOME}/.android/avd/${deviceName}.avd`;
|
||||
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
env: process.env,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
|
||||
const { serialId, emulatorPromise } = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: { ...process.env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
});
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName,
|
||||
env: process.env,
|
||||
});
|
||||
await asyncResult(emulatorPromise);
|
||||
|
||||
const devices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env: process.env });
|
||||
expect(devices.map(device => device.serialId)).not.toContain(serialId);
|
||||
|
||||
await expect(fs.promises.access(avdPath)).rejects.toThrow();
|
||||
}, 60_000);
|
||||
|
||||
it('should retry when first startup forces network check command failure', async () => {
|
||||
const deviceName = 'android-emulator-network-retry-test' as AndroidVirtualDeviceName;
|
||||
let attemptCounter = 0;
|
||||
let sawNetworkNotReadyError = false;
|
||||
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
attemptCounter += 1;
|
||||
const shouldFailNetworkCheck = attemptCount === 0;
|
||||
const envForAttempt: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
|
||||
ANDROID_EMULATOR_NETWORK_READY_COMMAND: shouldFailNetworkCheck
|
||||
? 'echo "network not ready" && exit 1'
|
||||
: 'echo "network ready"',
|
||||
};
|
||||
|
||||
let serialId: AndroidDeviceSerialId | null = null;
|
||||
let emulatorPromise: Promise<unknown> | null = null;
|
||||
|
||||
try {
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
deviceIdentifier: null,
|
||||
env: envForAttempt,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: envForAttempt,
|
||||
});
|
||||
serialId = startResult.serialId;
|
||||
emulatorPromise = asyncResult(startResult.emulatorPromise);
|
||||
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId,
|
||||
env: envForAttempt,
|
||||
timeoutMs: 30_000,
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
});
|
||||
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
});
|
||||
await emulatorPromise;
|
||||
} catch (err: any) {
|
||||
if (err?.message?.includes('network is not ready')) {
|
||||
sawNetworkNotReadyError = true;
|
||||
}
|
||||
try {
|
||||
if (serialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId,
|
||||
deviceName,
|
||||
env: process.env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName,
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn('Cleanup failed during retry test', cleanupError);
|
||||
}
|
||||
if (emulatorPromise) {
|
||||
await emulatorPromise;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger: createMockLogger({ logToConsole: true }),
|
||||
retryOptions: {
|
||||
retries: 1,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(sawNetworkNotReadyError).toBe(true);
|
||||
expect(attemptCounter).toBe(2);
|
||||
}, 360_000);
|
||||
|
||||
it('should work with screen recording', async () => {
|
||||
const deviceName = 'android-emulator-screen-recording-test' as AndroidVirtualDeviceName;
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { AndroidEmulatorUtils } from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../retry', () => ({
|
||||
retryAsync: jest.fn(async fn => await fn(0)),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
|
||||
describe('AndroidEmulatorUtils', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedRetryAsync.mockImplementation(async fn => await fn(0));
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.waitForReadyAsync, () => {
|
||||
it('checks boot completion and verifies network with netcat to 1.1.1.1:443', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[3] === 'getprop') {
|
||||
return { stdout: '1\n', stderr: '' } as any;
|
||||
}
|
||||
if (args[3] === 'nc' && args[6] === '1.1.1.1' && args[7] === '443') {
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}
|
||||
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
|
||||
}) as any);
|
||||
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(mockedRetryAsync).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
retryOptions: expect.objectContaining({
|
||||
retries: 59,
|
||||
retryIntervalMs: 1_000,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
|
||||
{ env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when network is unavailable despite completed boot', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[3] === 'getprop') {
|
||||
return { stdout: '1\n', stderr: '' } as any;
|
||||
}
|
||||
if (args[3] === 'nc') {
|
||||
throw new Error('network unreachable');
|
||||
}
|
||||
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
|
||||
}) as any);
|
||||
|
||||
await expect(
|
||||
AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow('network is not ready');
|
||||
});
|
||||
|
||||
it('uses overridden network readiness command when provided', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[3] === 'getprop') {
|
||||
return { stdout: '1\n', stderr: '' } as any;
|
||||
}
|
||||
if (args[3] === 'exit 0') {
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}
|
||||
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
|
||||
}) as any);
|
||||
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: {
|
||||
...process.env,
|
||||
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
['-s', 'emulator-5554', 'shell', 'exit 0'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANDROID_EMULATOR_NETWORK_READY_COMMAND: 'exit 0',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
['-s', 'emulator-5554', 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.stopAsync, () => {
|
||||
it('kills emulator and waits for it to detach', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[2] === 'emu' && args[3] === 'kill') {
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}
|
||||
if (args[0] === 'devices') {
|
||||
return { stdout: 'List of devices attached\n\n', stderr: '' } as any;
|
||||
}
|
||||
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
|
||||
}) as any);
|
||||
|
||||
await AndroidEmulatorUtils.stopAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
|
||||
env: process.env,
|
||||
});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['devices', '-l'], {
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(AndroidEmulatorUtils.deleteAsync, () => {
|
||||
it('stops by serial id and then deletes the AVD', async () => {
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
deviceName: 'eas-simulator-1' as any,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('adb', ['-s', 'emulator-5554', 'emu', 'kill'], {
|
||||
env: process.env,
|
||||
});
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'avdmanager',
|
||||
['delete', 'avd', '-n', 'eas-simulator-1'],
|
||||
{
|
||||
env: process.env,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../.eslintrc.js",
|
||||
"rules": {
|
||||
"async-protect/async-suffix": "off",
|
||||
"import/no-extraneous-dependencies": ["off"],
|
||||
"no-console": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
"no-void": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"jsPlugins": [
|
||||
{
|
||||
"name": "async-protect",
|
||||
"specifier": "eslint-plugin-async-protect"
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"async-protect/async-suffix": "off",
|
||||
"no-console": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
"no-void": "off"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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,40 +1,41 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "18.2.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
"react",
|
||||
"react-native"
|
||||
],
|
||||
"homepage": "https://docs.expo.dev",
|
||||
"license": "BSD-3-Clause",
|
||||
"author": "Expo <[email protected]>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/create-eas-build-function"
|
||||
},
|
||||
"version": "17.0.0",
|
||||
"bin": {
|
||||
"create-eas-build-function": "./build/index.js"
|
||||
},
|
||||
"main": "build",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"license": "BSD-3-Clause",
|
||||
"keywords": [
|
||||
"expo",
|
||||
"react-native",
|
||||
"react"
|
||||
],
|
||||
"homepage": "https://docs.expo.dev",
|
||||
"author": "Expo <[email protected]>",
|
||||
"bin": "./build/index.js",
|
||||
"files": [
|
||||
"build",
|
||||
"templates"
|
||||
],
|
||||
"main": "build",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"prepack": "yarn run clean && yarn run build",
|
||||
"lint": "eslint .",
|
||||
"lint": "oxlint --type-aware --tsconfig ../../tsconfig.oxlint.json .",
|
||||
"test": "echo 'No tests yet.'",
|
||||
"watch": "yarn run build:dev -w",
|
||||
"build:dev": "ncc build ./src/index.ts -o build/",
|
||||
"build": "ncc build ./src/index.ts -o build/ --minify --no-cache --no-source-map-register",
|
||||
"typecheck": "tsc",
|
||||
"clean": "rimraf ./build/ *.tsbuildinfo"
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "17.0.0"
|
||||
"@expo/steps": "18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -46,12 +47,11 @@
|
||||
"chalk": "4.1.2",
|
||||
"fs-extra": "^11.2.0",
|
||||
"ora": "^6.3.1",
|
||||
"oxlint": "1.43.0",
|
||||
"prompts": "^2.4.2",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.5.4",
|
||||
"update-check": "^1.5.4"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "custom-js-function",
|
||||
"version": "1.0.0",
|
||||
"main": "./build/index.js",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"@babel/cli": "^7.24.8",
|
||||
"@babel/core": "^7.24.9",
|
||||
"@babel/preset-env": "^7.24.8"
|
||||
},
|
||||
"main": "./build/index.js",
|
||||
"scripts": {
|
||||
"babel": "babel src --out-dir babel",
|
||||
"build": "yarn babel && ncc build ./babel/index.js -o build/ --minify --no-cache --no-source-map-register"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.24.8",
|
||||
"@babel/core": "^7.24.9",
|
||||
"@babel/preset-env": "^7.24.8",
|
||||
"@vercel/ncc": "^0.38.1"
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,17 @@
|
||||
"name": "custom-ts-function",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "./build/index.js",
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"type": "commonjs",
|
||||
"main": "./build/index.js",
|
||||
"scripts": {
|
||||
"build": "ncc build ./src/index.ts -o build/ --minify --no-cache --no-source-map-register"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"devDependencies": {
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"@expo/steps": "^1.0.119",
|
||||
"@types/node": "20.14.11",
|
||||
"typescript": "^5.5.3",
|
||||
"@expo/steps": "^1.0.119"
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"typescript": "^5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"extends": "../../.eslintrc.js",
|
||||
"rules": {
|
||||
"async-protect/async-suffix": "off"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"jsPlugins": [
|
||||
{
|
||||
"name": "async-protect",
|
||||
"specifier": "eslint-plugin-async-protect"
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"async-protect/async-suffix": "off"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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,29 +1,29 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "18.0.1",
|
||||
"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/downloader"
|
||||
},
|
||||
"version": "17.0.0",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
|
||||
"test": "jest --config jest.config.ts",
|
||||
"test:watch": "jest --config jest.config.ts --watch",
|
||||
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
|
||||
},
|
||||
"author": "Expo <[email protected]>",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"dependencies": {
|
||||
"fs-extra": "^11.2.0",
|
||||
"got": "11.8.5"
|
||||
|
||||
@@ -12,7 +12,5 @@
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"jsPlugins": [
|
||||
{
|
||||
"name": "async-protect",
|
||||
"specifier": "eslint-plugin-async-protect"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -48,6 +48,7 @@ Update your **app.json** to enable the EAS build cache provider:
|
||||
## How It Works
|
||||
|
||||
The build cache provider:
|
||||
|
||||
1. Hooks into Expo CLI's build process
|
||||
2. Uploads build cache artifacts to EAS infrastructure
|
||||
3. Downloads cached artifacts on subsequent builds
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "18.0.2",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"version": "17.0.0",
|
||||
"author": "Expo <[email protected]>",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/eas-build-cache-provider"
|
||||
},
|
||||
"files": [
|
||||
"/build"
|
||||
],
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
"watch": "yarn build --watch --preserveWatchOutput",
|
||||
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "yarn rebuild",
|
||||
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
|
||||
"test": "jest --passWithNoTests",
|
||||
"clean": "rimraf build node_modules yarn-error.log *.tsbuildinfo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/config": "11.0.10",
|
||||
@@ -22,37 +48,12 @@
|
||||
"@types/babel__code-frame": "7.0.3",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"@types/getenv": "^1.0.0",
|
||||
"jest": "29.7.0",
|
||||
"memfs": "3.4.13",
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"license": "MIT",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/eas-build-cache-provider"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
"watch": "yarn build --watch --preserveWatchOutput",
|
||||
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "yarn rebuild",
|
||||
"rebuild": "rimraf build && yarn build",
|
||||
"test": "jest --passWithNoTests",
|
||||
"clean": "rimraf build node_modules yarn-error.log *.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"/build"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ['universe/shared/typescript-analysis'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['*.ts', '*.d.ts'],
|
||||
parserOptions: {
|
||||
project: './packages/eas-build-cache-provider/tsconfig.json',
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/explicit-function-return-type': [
|
||||
'warn',
|
||||
{
|
||||
allowExpressions: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": ["../.oxlintrc.json"],
|
||||
"jsPlugins": [
|
||||
{
|
||||
"name": "async-protect",
|
||||
"specifier": "eslint-plugin-async-protect"
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"typescript/explicit-function-return-type": [
|
||||
"warn",
|
||||
{
|
||||
"allowExpressions": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "../../.eslintrc.js"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"jsPlugins": [
|
||||
{
|
||||
"name": "async-protect",
|
||||
"specifier": "eslint-plugin-async-protect"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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,40 +1,40 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "18.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/eas-build-job"
|
||||
},
|
||||
"version": "17.0.0",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
|
||||
"test": "jest --config jest.config.ts",
|
||||
"test:watch": "jest --config jest.config.ts --watch",
|
||||
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
|
||||
},
|
||||
"author": "Expo <[email protected]>",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/logger": "18.0.1",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "17.0.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
@@ -340,6 +340,60 @@ describe('Android.JobSchema', () => {
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('can set hooks with arbitrary names', () => {
|
||||
const job = {
|
||||
mode: BuildMode.BUILD,
|
||||
type: Workflow.GENERIC,
|
||||
platform: Platform.ANDROID,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.URL,
|
||||
url: 'https://expo.dev/builds/123',
|
||||
},
|
||||
projectRootDirectory: '.',
|
||||
hooks: {
|
||||
before_install_node_modules: [
|
||||
{
|
||||
id: 'before-install',
|
||||
run: 'echo before install',
|
||||
shell: 'sh',
|
||||
},
|
||||
],
|
||||
my_custom_hook: [],
|
||||
},
|
||||
secrets,
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
};
|
||||
const { value, error } = Android.JobSchema.validate(job, joiOptions);
|
||||
expect(value).toMatchObject(job);
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('can set hooks in custom mode', () => {
|
||||
const customBuildJob = {
|
||||
mode: BuildMode.CUSTOM,
|
||||
type: Workflow.UNKNOWN,
|
||||
platform: Platform.ANDROID,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.URL,
|
||||
url: 'https://expo.dev/builds/123',
|
||||
},
|
||||
projectRootDirectory: '.',
|
||||
customBuildConfig: {
|
||||
path: 'production.android.yml',
|
||||
},
|
||||
hooks: {
|
||||
before_install_node_modules: [],
|
||||
},
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
};
|
||||
|
||||
const { value, error } = Android.JobSchema.validate(customBuildJob, joiOptions);
|
||||
expect(value).toMatchObject(customBuildJob);
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('can set github trigger options', () => {
|
||||
const job = {
|
||||
mode: BuildMode.BUILD,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { BuildError, ExpoError, UserFacingError } from '../errors';
|
||||
import { BuildPhase } from '../logs';
|
||||
|
||||
class TestExpoError extends ExpoError {
|
||||
constructor(message: string, details: ConstructorParameters<typeof BuildError>[1]) {
|
||||
super(message, details);
|
||||
}
|
||||
}
|
||||
|
||||
describe(ExpoError, () => {
|
||||
it('stores shared metadata and formats external payload', () => {
|
||||
const cause = new Error('root cause');
|
||||
const error = new TestExpoError('canonical message', {
|
||||
errorCode: 'ERR_CODE',
|
||||
trackingCode: 'TRACKING_CODE',
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
metadata: { packageName: '@typescript-eslint/typescript-estree' },
|
||||
cause,
|
||||
});
|
||||
|
||||
expect(error.errorCode).toBe('ERR_CODE');
|
||||
expect(error.trackingCode).toBe('TRACKING_CODE');
|
||||
expect(error.docsUrl).toBe('https://docs.example.dev');
|
||||
expect(error.metadata).toEqual({
|
||||
packageName: '@typescript-eslint/typescript-estree',
|
||||
});
|
||||
expect(error.buildPhase).toBe(BuildPhase.PREBUILD);
|
||||
expect(error.cause).toBe(cause);
|
||||
expect(error.format()).toEqual({
|
||||
errorCode: 'ERR_CODE',
|
||||
message: 'canonical message',
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(BuildError, () => {
|
||||
it('formats using canonical message and errorCode', () => {
|
||||
const error = new BuildError('canonical message', {
|
||||
errorCode: 'ERR_CODE',
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
metadata: { packageName: '@expo/config' },
|
||||
});
|
||||
|
||||
expect(error).toBeInstanceOf(ExpoError);
|
||||
expect(error.metadata).toEqual({ packageName: '@expo/config' });
|
||||
expect(error.format()).toEqual({
|
||||
errorCode: 'ERR_CODE',
|
||||
message: 'canonical message',
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
buildPhase: BuildPhase.PREBUILD,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(UserFacingError, () => {
|
||||
it('supports docsUrl in options', () => {
|
||||
const error = new UserFacingError('ERR_CODE', 'message', {
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
});
|
||||
|
||||
expect(error).toBeInstanceOf(ExpoError);
|
||||
expect(error.docsUrl).toBe('https://docs.example.dev');
|
||||
expect(error.cause).toBeUndefined();
|
||||
});
|
||||
|
||||
it('supports cause in options', () => {
|
||||
const cause = new Error('root cause');
|
||||
const error = new UserFacingError('ERR_CODE', 'message', { cause });
|
||||
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it('supports docsUrl, metadata, and cause in options', () => {
|
||||
const cause = new Error('root cause');
|
||||
const error = new UserFacingError('ERR_CODE', 'message', {
|
||||
docsUrl: 'https://docs.example.dev',
|
||||
metadata: { packageName: 'expo' },
|
||||
cause,
|
||||
});
|
||||
|
||||
expect(error.docsUrl).toBe('https://docs.example.dev');
|
||||
expect(error.metadata).toEqual({ packageName: 'expo' });
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,107 @@ describe('Generic.JobZ', () => {
|
||||
expect(Generic.JobZ.parse(job)).toEqual(job);
|
||||
});
|
||||
|
||||
it('accepts hooks with arbitrary names', () => {
|
||||
const job: Generic.Job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
hooks: {
|
||||
before_install_node_modules: [
|
||||
{
|
||||
id: 'before-install',
|
||||
run: 'echo before install',
|
||||
shell: 'sh',
|
||||
},
|
||||
],
|
||||
my_custom_hook: [],
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
run: 'echo Hello, world!',
|
||||
shell: 'sh',
|
||||
},
|
||||
],
|
||||
secrets: {
|
||||
robotAccessToken: 'token',
|
||||
environmentSecrets: [
|
||||
{
|
||||
name: 'secret-name',
|
||||
value: 'secret-value',
|
||||
type: EnvironmentSecretType.STRING,
|
||||
},
|
||||
],
|
||||
},
|
||||
expoDevUrl: 'https://expo.dev/accounts/name/builds/id',
|
||||
builderEnvironment: {
|
||||
image: 'macos-sonoma-14.5-xcode-15.4',
|
||||
node: '20.15.1',
|
||||
corepack: false,
|
||||
env: {
|
||||
KEY1: 'value1',
|
||||
},
|
||||
},
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
appId: randomUUID(),
|
||||
initiatingUserId: randomUUID(),
|
||||
};
|
||||
expect(Generic.JobZ.parse(job)).toEqual(job);
|
||||
});
|
||||
|
||||
it('errors when hook step definition is invalid', () => {
|
||||
const job = {
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.GIT,
|
||||
repositoryUrl: 'https://github.com/expo/expo.git',
|
||||
gitCommitHash: '1234567890',
|
||||
gitRef: null,
|
||||
},
|
||||
hooks: {
|
||||
before_install_node_modules: [
|
||||
{
|
||||
id: 'missing-run-or-uses',
|
||||
},
|
||||
],
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
id: 'step1',
|
||||
name: 'Step 1',
|
||||
run: 'echo Hello, world!',
|
||||
shell: 'sh',
|
||||
},
|
||||
],
|
||||
secrets: {
|
||||
robotAccessToken: 'token',
|
||||
environmentSecrets: [
|
||||
{
|
||||
name: 'secret-name',
|
||||
value: 'secret-value',
|
||||
type: EnvironmentSecretType.STRING,
|
||||
},
|
||||
],
|
||||
},
|
||||
expoDevUrl: 'https://expo.dev/accounts/name/builds/id',
|
||||
builderEnvironment: {
|
||||
image: 'macos-sonoma-14.5-xcode-15.4',
|
||||
node: '20.15.1',
|
||||
corepack: false,
|
||||
env: {
|
||||
KEY1: 'value1',
|
||||
},
|
||||
},
|
||||
triggeredBy: BuildTrigger.GIT_BASED_INTEGRATION,
|
||||
appId: randomUUID(),
|
||||
initiatingUserId: randomUUID(),
|
||||
};
|
||||
expect(() => Generic.JobZ.parse(job)).toThrow(ZodError);
|
||||
});
|
||||
|
||||
it('errors when steps are not provided', () => {
|
||||
const job: Omit<Generic.Job, 'customBuildConfig' | 'steps'> = {
|
||||
projectArchive: {
|
||||
|
||||
@@ -84,6 +84,66 @@ describe('Ios.JobSchema', () => {
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('valid generic job with hooks', () => {
|
||||
const genericJob = {
|
||||
secrets: {
|
||||
buildCredentials,
|
||||
},
|
||||
type: Workflow.GENERIC,
|
||||
platform: Platform.IOS,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.URL,
|
||||
url: 'http://localhost:3000',
|
||||
},
|
||||
projectRootDirectory: '.',
|
||||
hooks: {
|
||||
before_install_node_modules: [
|
||||
{
|
||||
id: 'before-install',
|
||||
run: 'echo before install',
|
||||
shell: 'sh',
|
||||
},
|
||||
],
|
||||
my_custom_hook: [],
|
||||
},
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
};
|
||||
|
||||
const { value, error } = Ios.JobSchema.validate(genericJob, joiOptions);
|
||||
expect(value).toMatchObject(genericJob);
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('valid resign job with hooks', () => {
|
||||
const genericJob = {
|
||||
mode: BuildMode.RESIGN,
|
||||
secrets: {
|
||||
buildCredentials,
|
||||
},
|
||||
type: Workflow.UNKNOWN,
|
||||
platform: Platform.IOS,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.NONE,
|
||||
},
|
||||
resign: {
|
||||
applicationArchiveSource: {
|
||||
type: ArchiveSourceType.URL,
|
||||
url: 'http://localhost:3000/a.ipa',
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
before_install_node_modules: [],
|
||||
},
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
};
|
||||
|
||||
const { value, error } = Ios.JobSchema.validate(genericJob, joiOptions);
|
||||
expect(value).toMatchObject(genericJob);
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('valid custom build job with metadataLocation', () => {
|
||||
const customBuildJob = {
|
||||
mode: BuildMode.CUSTOM,
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
EnvSchema,
|
||||
EnvironmentSecret,
|
||||
EnvironmentSecretsSchema,
|
||||
Hooks,
|
||||
HooksSchema,
|
||||
Platform,
|
||||
StaticWorkflowInterpolationContext,
|
||||
StaticWorkflowInterpolationContextZ,
|
||||
@@ -106,6 +108,7 @@ export interface Job {
|
||||
customBuildConfig?: {
|
||||
path: string;
|
||||
};
|
||||
hooks?: Hooks;
|
||||
steps?: Step[];
|
||||
outputs?: Record<string, string>;
|
||||
|
||||
@@ -172,6 +175,7 @@ export const JobSchema = Joi.object({
|
||||
|
||||
buildType: Joi.string().valid(...Object.values(BuildType)),
|
||||
username: Joi.string(),
|
||||
hooks: HooksSchema,
|
||||
|
||||
experimental: Joi.object({
|
||||
prebuildCommand: Joi.string(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import Joi from 'joi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BuildPhase, BuildPhaseResult } from './logs';
|
||||
import { validateSteps } from './step';
|
||||
import { Step, StepZ, validateSteps } from './step';
|
||||
|
||||
export enum BuildMode {
|
||||
BUILD = 'build',
|
||||
@@ -142,6 +142,21 @@ export const EnvironmentSecretZ = z.object({
|
||||
type: z.nativeEnum(EnvironmentSecretType),
|
||||
});
|
||||
|
||||
export type Hooks = Record<string, Step[]>;
|
||||
export const HooksSchema = Joi.object().pattern(
|
||||
Joi.string(),
|
||||
Joi.array()
|
||||
.items(Joi.any())
|
||||
.required()
|
||||
.custom(steps => {
|
||||
if (steps.length === 0) {
|
||||
return steps;
|
||||
}
|
||||
return validateSteps(steps);
|
||||
}, 'steps validation')
|
||||
);
|
||||
export const HooksZ = z.record(z.string(), z.array(StepZ));
|
||||
|
||||
export interface Cache {
|
||||
disabled: boolean;
|
||||
clear: boolean;
|
||||
|
||||
@@ -17,49 +17,79 @@ export interface ExternalBuildError {
|
||||
buildPhase?: BuildPhase;
|
||||
}
|
||||
|
||||
interface BuildErrorDetails {
|
||||
export type ErrorMetadata = Record<string, unknown>;
|
||||
|
||||
interface ExpoErrorDetails<TMetadata extends ErrorMetadata = ErrorMetadata> {
|
||||
errorCode: string;
|
||||
userFacingMessage: string;
|
||||
userFacingErrorCode: string;
|
||||
trackingCode?: string;
|
||||
docsUrl?: string;
|
||||
innerError?: Error;
|
||||
buildPhase?: BuildPhase;
|
||||
/**
|
||||
* Metadata object for the error. Used internally for Sentry/logging/debugging.
|
||||
* It is not included in the external build error payload.
|
||||
*/
|
||||
metadata?: TMetadata;
|
||||
/**
|
||||
* Underlying error that caused this error to be created. Used internally to
|
||||
* propagate blame stack traces to the response.
|
||||
*/
|
||||
cause?: unknown;
|
||||
}
|
||||
|
||||
export class BuildError extends Error {
|
||||
export abstract class ExpoError<TMetadata extends ErrorMetadata = ErrorMetadata> extends Error {
|
||||
public errorCode: string;
|
||||
public userFacingMessage: string;
|
||||
public userFacingErrorCode: string;
|
||||
// Internal-only classification used for Sentry, analytics, and worker internalErrorCode.
|
||||
// The public error saved on builds and job runs is always `errorCode`.
|
||||
public trackingCode?: string;
|
||||
public docsUrl?: string;
|
||||
public innerError?: Error;
|
||||
public readonly metadata?: TMetadata;
|
||||
public buildPhase?: BuildPhase;
|
||||
|
||||
constructor(message: string, details: BuildErrorDetails) {
|
||||
super(message);
|
||||
constructor(message: string, details: ExpoErrorDetails<TMetadata>) {
|
||||
super(message, { cause: details.cause });
|
||||
this.errorCode = details.errorCode;
|
||||
this.userFacingErrorCode = details.userFacingErrorCode;
|
||||
this.userFacingMessage = details.userFacingMessage;
|
||||
this.trackingCode = details.trackingCode;
|
||||
this.docsUrl = details.docsUrl;
|
||||
this.innerError = details.innerError;
|
||||
this.metadata = details.metadata;
|
||||
this.buildPhase = details.buildPhase;
|
||||
}
|
||||
|
||||
public format(): ExternalBuildError {
|
||||
return {
|
||||
errorCode: this.userFacingErrorCode,
|
||||
message: this.userFacingMessage,
|
||||
errorCode: this.errorCode,
|
||||
message: this.message,
|
||||
docsUrl: this.docsUrl,
|
||||
buildPhase: this.buildPhase,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class UserFacingError extends Error {
|
||||
export class BuildError<
|
||||
TMetadata extends ErrorMetadata = ErrorMetadata,
|
||||
> extends ExpoError<TMetadata> {
|
||||
constructor(message: string, details: ExpoErrorDetails<TMetadata>) {
|
||||
super(message, details);
|
||||
}
|
||||
}
|
||||
|
||||
export class UserFacingError<
|
||||
TMetadata extends ErrorMetadata = ErrorMetadata,
|
||||
> extends ExpoError<TMetadata> {
|
||||
constructor(
|
||||
public errorCode: string,
|
||||
public message: string,
|
||||
public docsUrl?: string
|
||||
options?: {
|
||||
docsUrl?: string;
|
||||
metadata?: TMetadata;
|
||||
cause?: unknown;
|
||||
}
|
||||
) {
|
||||
super(message);
|
||||
super(message, {
|
||||
errorCode,
|
||||
docsUrl: options?.docsUrl,
|
||||
metadata: options?.metadata,
|
||||
cause: options?.cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +110,6 @@ export class UnknownBuildError extends BuildError {
|
||||
const message = 'Unknown error. See logs for more information.';
|
||||
super(message, {
|
||||
errorCode,
|
||||
userFacingMessage: message,
|
||||
userFacingErrorCode: errorCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -92,8 +120,6 @@ export class UnknownCustomBuildError extends BuildError {
|
||||
const message = 'Unknown custom build error. See logs for more information.';
|
||||
super(message, {
|
||||
errorCode,
|
||||
userFacingMessage: message,
|
||||
userFacingErrorCode: errorCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ArchiveSourceSchemaZ,
|
||||
BuildTrigger,
|
||||
EnvironmentSecretZ,
|
||||
HooksZ,
|
||||
StaticWorkflowInterpolationContextZ,
|
||||
} from './common';
|
||||
import { StepZ } from './step';
|
||||
@@ -45,6 +46,7 @@ export namespace Generic {
|
||||
initiatingUserId: z.string(),
|
||||
appId: z.string(),
|
||||
|
||||
hooks: HooksZ.optional(),
|
||||
steps: z.array(StepZ).min(1),
|
||||
outputs: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ export {
|
||||
Env,
|
||||
EnvironmentSecret,
|
||||
EnvironmentSecretType,
|
||||
Hooks,
|
||||
Workflow,
|
||||
Platform,
|
||||
Cache,
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
EnvSchema,
|
||||
EnvironmentSecret,
|
||||
EnvironmentSecretsSchema,
|
||||
Hooks,
|
||||
HooksSchema,
|
||||
Platform,
|
||||
StaticWorkflowInterpolationContext,
|
||||
StaticWorkflowInterpolationContextZ,
|
||||
@@ -120,6 +122,7 @@ export interface Job {
|
||||
customBuildConfig?: {
|
||||
path: string;
|
||||
};
|
||||
hooks?: Hooks;
|
||||
steps?: Step[];
|
||||
outputs?: Record<string, string>;
|
||||
|
||||
@@ -206,6 +209,7 @@ export const JobSchema = Joi.object({
|
||||
applicationArchivePath: Joi.string(),
|
||||
|
||||
username: Joi.string(),
|
||||
hooks: HooksSchema,
|
||||
|
||||
experimental: Joi.object({
|
||||
prebuildCommand: Joi.string(),
|
||||
|
||||
@@ -16,6 +16,7 @@ export enum BuildPhase {
|
||||
EAS_BUILD_INTERNAL = 'EAS_BUILD_INTERNAL',
|
||||
PREBUILD = 'PREBUILD',
|
||||
PREPARE_CREDENTIALS = 'PREPARE_CREDENTIALS',
|
||||
CONFIGURE_ANDROID_VERSION = 'CONFIGURE_ANDROID_VERSION',
|
||||
CALCULATE_EXPO_UPDATES_RUNTIME_VERSION = 'CALCULATE_EXPO_UPDATES_RUNTIME_VERSION',
|
||||
CONFIGURE_EXPO_UPDATES = 'CONFIGURE_EXPO_UPDATES',
|
||||
EAGER_BUNDLE = 'EAGER_BUNDLE',
|
||||
@@ -80,6 +81,7 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
|
||||
[BuildPhase.EAS_BUILD_INTERNAL]: 'Resolve build configuration',
|
||||
[BuildPhase.PREBUILD]: 'Prebuild',
|
||||
[BuildPhase.PREPARE_CREDENTIALS]: 'Prepare credentials',
|
||||
[BuildPhase.CONFIGURE_ANDROID_VERSION]: 'Configure Android version',
|
||||
[BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION]: 'Calculate expo-updates runtime version',
|
||||
[BuildPhase.CONFIGURE_EXPO_UPDATES]: 'Configure expo-updates',
|
||||
[BuildPhase.EAGER_BUNDLE]: 'Bundle JavaScript',
|
||||
@@ -143,6 +145,7 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
|
||||
[BuildPhase.EAS_BUILD_INTERNAL]: 'resolve-build-configuration',
|
||||
[BuildPhase.PREBUILD]: 'prebuild',
|
||||
[BuildPhase.PREPARE_CREDENTIALS]: 'prepare-credentials',
|
||||
[BuildPhase.CONFIGURE_ANDROID_VERSION]: 'configure-android-version',
|
||||
[BuildPhase.CALCULATE_EXPO_UPDATES_RUNTIME_VERSION]: 'calculate-expo-updates-runtime-version',
|
||||
[BuildPhase.CONFIGURE_EXPO_UPDATES]: 'configure-expo-updates',
|
||||
[BuildPhase.EAGER_BUNDLE]: 'eager-bundle',
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
const rootEslintrc = require('../../.eslintrc.js');
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
'no-restricted-properties': [
|
||||
'warn',
|
||||
{
|
||||
object: 'it',
|
||||
property: 'only',
|
||||
message: 'it.only should not be committed to main.',
|
||||
},
|
||||
{
|
||||
object: 'test',
|
||||
property: 'only',
|
||||
message: 'test.only should not be committed to main.',
|
||||
},
|
||||
{
|
||||
object: 'describe',
|
||||
property: 'only',
|
||||
message: 'describe.only should not be committed to main.',
|
||||
},
|
||||
],
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
...rootEslintrc.rules['no-restricted-imports'][1].paths,
|
||||
{
|
||||
name: 'ora',
|
||||
message: 'Import ora from src/ora.ts instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'graphql/template-strings': [
|
||||
'error',
|
||||
{
|
||||
env: 'apollo',
|
||||
schemaJson: require('./graphql.schema.json'),
|
||||
},
|
||||
],
|
||||
'graphql/named-operations': [
|
||||
'error',
|
||||
{
|
||||
schemaJson: require('./graphql.schema.json'),
|
||||
},
|
||||
],
|
||||
'graphql/required-fields': [
|
||||
'error',
|
||||
{
|
||||
env: 'apollo',
|
||||
schemaJson: require('./graphql.schema.json'),
|
||||
requiredFields: ['id'],
|
||||
},
|
||||
],
|
||||
'graphql/capitalized-type-name': [
|
||||
'error',
|
||||
{
|
||||
schemaJson: require('./graphql.schema.json'),
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: ['graphql'],
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"extends": ["../../.oxlintrc.json"],
|
||||
"jsPlugins": [
|
||||
{
|
||||
"name": "async-protect",
|
||||
"specifier": "eslint-plugin-async-protect"
|
||||
},
|
||||
{
|
||||
"name": "graphql",
|
||||
"specifier": "eslint-plugin-graphql"
|
||||
}
|
||||
],
|
||||
"rules": {
|
||||
"no-restricted-properties": [
|
||||
"warn",
|
||||
{
|
||||
"object": "it",
|
||||
"property": "only",
|
||||
"message": "it.only should not be committed to main."
|
||||
},
|
||||
{
|
||||
"object": "test",
|
||||
"property": "only",
|
||||
"message": "test.only should not be committed to main."
|
||||
},
|
||||
{
|
||||
"object": "describe",
|
||||
"property": "only",
|
||||
"message": "describe.only should not be committed to main."
|
||||
}
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "lodash",
|
||||
"message": "Don't use lodash, it's heavy!"
|
||||
},
|
||||
{
|
||||
"name": "ora",
|
||||
"message": "Import ora from src/ora.ts instead."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"graphql/template-strings": [
|
||||
"error",
|
||||
{
|
||||
"env": "apollo",
|
||||
"schemaJsonFilepath": "packages/eas-cli/graphql.schema.json"
|
||||
}
|
||||
],
|
||||
"graphql/named-operations": [
|
||||
"error",
|
||||
{
|
||||
"schemaJsonFilepath": "packages/eas-cli/graphql.schema.json"
|
||||
}
|
||||
],
|
||||
"graphql/required-fields": [
|
||||
"error",
|
||||
{
|
||||
"env": "apollo",
|
||||
"schemaJsonFilepath": "packages/eas-cli/graphql.schema.json",
|
||||
"requiredFields": ["id"]
|
||||
}
|
||||
],
|
||||
"graphql/capitalized-type-name": [
|
||||
"error",
|
||||
{
|
||||
"schemaJsonFilepath": "packages/eas-cli/graphql.schema.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ The main CLI tool for Expo Application Services (EAS). Published as `eas-cli` on
|
||||
## Development Commands
|
||||
|
||||
### Running Locally
|
||||
|
||||
```bash
|
||||
# From repository root:
|
||||
yarn eas <command>
|
||||
@@ -26,6 +27,7 @@ easd build --help
|
||||
```
|
||||
|
||||
### Build & Development
|
||||
|
||||
```bash
|
||||
yarn build # TypeScript compilation (src → build)
|
||||
yarn watch # Watch mode
|
||||
@@ -35,22 +37,26 @@ yarn typecheck-for-build # Type check for production build
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
yarn test # Run all tests
|
||||
yarn test <path-to-file> # Run specific test file
|
||||
```
|
||||
|
||||
### GraphQL Code Generation
|
||||
|
||||
```bash
|
||||
yarn generate-graphql-code # Generate TypeScript types from GraphQL schema
|
||||
yarn verify-graphql-code # Verify GraphQL code is up to date (CI check)
|
||||
```
|
||||
|
||||
The GraphQL schema is fetched from `https://staging-api.expo.dev/graphql` and generates:
|
||||
|
||||
- `src/graphql/generated.ts` - 499KB+ of TypeScript types
|
||||
- Types for all queries, mutations, and fragments
|
||||
|
||||
### oclif Commands
|
||||
|
||||
```bash
|
||||
yarn oclif readme # Auto-generate README.md from command metadata
|
||||
```
|
||||
@@ -81,6 +87,7 @@ Available context types:
|
||||
- Provides: methods to interact with server-side env vars
|
||||
|
||||
Example command structure:
|
||||
|
||||
```typescript
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
|
||||
@@ -88,7 +95,7 @@ export default class Build extends EasCommand {
|
||||
static override description = 'start a build';
|
||||
|
||||
static override ContextOptions = {
|
||||
...EasCommand.ContextOptions.LoggedIn, // Requires auth
|
||||
...EasCommand.ContextOptions.LoggedIn, // Requires auth
|
||||
...EasCommand.ContextOptions.ProjectConfig, // Requires project
|
||||
};
|
||||
|
||||
@@ -120,12 +127,14 @@ export default class Build extends EasCommand {
|
||||
**Location**: `src/graphql/`
|
||||
|
||||
**Client**: `ExpoGraphqlClient` (urql-based wrapper)
|
||||
|
||||
- Automatic retry logic with exponential backoff
|
||||
- Cache management (cache-first vs network-only)
|
||||
- Transient error handling
|
||||
- Authentication via Bearer token or session secret
|
||||
|
||||
**Structure**:
|
||||
|
||||
```
|
||||
src/graphql/
|
||||
├── generated.ts # Generated TypeScript types
|
||||
@@ -135,15 +144,14 @@ src/graphql/
|
||||
```
|
||||
|
||||
**Making GraphQL Requests**:
|
||||
|
||||
```typescript
|
||||
// In a command with LoggedIn context:
|
||||
const { graphqlClient } = await this.getContextAsync(LoggedIn, {
|
||||
nonInteractive: false,
|
||||
});
|
||||
|
||||
const result = await graphqlClient
|
||||
.query(MyQuery, { variables: { id: projectId } })
|
||||
.toPromise();
|
||||
const result = await graphqlClient.query(MyQuery, { variables: { id: projectId } }).toPromise();
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message);
|
||||
@@ -157,6 +165,7 @@ const data = result.data;
|
||||
**Client**: `ApiV2Client` in `src/api.ts`
|
||||
|
||||
**Base URLs**:
|
||||
|
||||
- Production: `https://api.expo.dev`
|
||||
- Staging: `https://staging-api.expo.dev` (set `EXPO_STAGING=1`)
|
||||
- Local: `http://127.0.0.1:3000` (set `EXPO_LOCAL=1`)
|
||||
@@ -202,6 +211,7 @@ src/
|
||||
## Common Patterns
|
||||
|
||||
### Working with eas.json
|
||||
|
||||
```typescript
|
||||
import { EasJsonAccessor, EasJsonUtils } from '@expo/eas-json';
|
||||
|
||||
@@ -214,6 +224,7 @@ const buildProfile = easJson.build?.[profileName];
|
||||
```
|
||||
|
||||
### Project Context
|
||||
|
||||
```typescript
|
||||
// In a command with ProjectConfig context:
|
||||
const { projectId, exp } = await this.getContextAsync(ProjectConfig, {
|
||||
@@ -243,6 +254,7 @@ const { projectId, exp } = await this.getContextAsync(ProjectConfig, {
|
||||
### Error Handling
|
||||
|
||||
Extend `CommandError` or use specific error classes in `src/commandUtils/errors.ts`:
|
||||
|
||||
```typescript
|
||||
import { CommandError } from '../commandUtils/errors';
|
||||
|
||||
@@ -264,6 +276,7 @@ throw new CommandError('BUILD_FAILED', 'The build failed due to...');
|
||||
- Utilities: `ts-mockito`, `mockdate`
|
||||
|
||||
Example test:
|
||||
|
||||
```typescript
|
||||
import { vol } from 'memfs';
|
||||
import nock from 'nock';
|
||||
|
||||
+519
-501
File diff suppressed because it is too large.
Load diff
@@ -1,17 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const oclif = require('@oclif/core');
|
||||
const path = require('path');
|
||||
(async () => {
|
||||
const oclif = require('@oclif/core');
|
||||
|
||||
const project = path.join(__dirname, '..', 'tsconfig.json');
|
||||
// Keep dev entrypoint failures verbose for local debugging.
|
||||
oclif.settings.debug = true;
|
||||
|
||||
// In dev mode -> use ts-node and dev plugins
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
require('ts-node').register({ project });
|
||||
|
||||
// In dev mode, always show stack traces
|
||||
oclif.settings.disableJsonFlag = true
|
||||
|
||||
// Start the CLI
|
||||
oclif.run().then(oclif.flush).catch(oclif.Errors.handle);
|
||||
await oclif.execute({ development: true, dir: __dirname });
|
||||
})();
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
require('@oclif/core')
|
||||
.run()
|
||||
.then(require('@oclif/core/flush'))
|
||||
.catch(require('@oclif/core/handle'));
|
||||
(async () => {
|
||||
const oclif = require('@oclif/core');
|
||||
await oclif.execute({ dir: __dirname });
|
||||
})();
|
||||
@@ -1,6 +1,5 @@
|
||||
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,57 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "18.3.0",
|
||||
"description": "EAS command line tool",
|
||||
"version": "17.0.0",
|
||||
"keywords": [
|
||||
"cli",
|
||||
"eas",
|
||||
"expo"
|
||||
],
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/eas-cli"
|
||||
},
|
||||
"bin": {
|
||||
"eas": "./bin/run"
|
||||
},
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"files": [
|
||||
"/bin",
|
||||
"/build",
|
||||
"/oclif.manifest.json",
|
||||
"/schema"
|
||||
],
|
||||
"scripts": {
|
||||
"postpack": "rimraf oclif.manifest.json",
|
||||
"prepack": "yarn rebuild && node ./scripts/prepack.js",
|
||||
"rebuild": "rimraf build *.tsbuildinfo && yarn build",
|
||||
"build": "yarn typecheck-for-build && yarn copy-new-templates",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
"watch": "yarn typecheck-for-build --watch --preserveWatchOutput",
|
||||
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"typecheck-for-build": "tsc --project tsconfig.build.json",
|
||||
"test": "jest",
|
||||
"version": "yarn run -T oclif readme && node scripts/patch-readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
|
||||
"verify-graphql-code": "./scripts/verify-graphql.sh",
|
||||
"clean": "rimraf dist build tmp node_modules yarn-error.log *.tsbuildinfo",
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.13",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "10.0.6",
|
||||
"@expo/config-plugins": "9.0.12",
|
||||
"@expo/eas-build-job": "17.0.0",
|
||||
"@expo/eas-json": "17.0.0",
|
||||
"@expo/eas-build-job": "18.2.0",
|
||||
"@expo/eas-json": "18.2.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "17.0.0",
|
||||
"@expo/logger": "18.0.1",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -28,10 +63,10 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "17.0.0",
|
||||
"@expo/steps": "18.2.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^1.26.2",
|
||||
"@oclif/plugin-autocomplete": "^2.3.10",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
"@segment/ajv-human-errors": "^2.1.2",
|
||||
"@urql/core": "4.0.11",
|
||||
"@urql/exchange-retry": "1.2.0",
|
||||
@@ -123,6 +158,7 @@
|
||||
"axios": "1.11.0",
|
||||
"eslint-plugin-graphql": "4.0.0",
|
||||
"express": "4.20.0",
|
||||
"jest": "29.7.0",
|
||||
"memfs": "3.4.13",
|
||||
"mockdate": "3.0.5",
|
||||
"nock": "13.4.0",
|
||||
@@ -131,23 +167,13 @@
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"files": [
|
||||
"/bin",
|
||||
"/build",
|
||||
"/oclif.manifest.json",
|
||||
"/schema"
|
||||
],
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"keywords": [
|
||||
"expo",
|
||||
"eas",
|
||||
"cli"
|
||||
],
|
||||
"license": "MIT",
|
||||
"oclif": {
|
||||
"additionalHelpFlags": [
|
||||
"-h"
|
||||
],
|
||||
"additionalVersionFlags": [
|
||||
"-v"
|
||||
],
|
||||
"bin": "eas",
|
||||
"commands": "./build/commands",
|
||||
"plugins": [
|
||||
@@ -171,6 +197,9 @@
|
||||
"device": {
|
||||
"description": "manage Apple devices for Internal Distribution"
|
||||
},
|
||||
"env": {
|
||||
"description": "manage project and account environment variables"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "manage store configuration"
|
||||
},
|
||||
@@ -183,9 +212,6 @@
|
||||
"secret": {
|
||||
"description": "manage project and account secrets"
|
||||
},
|
||||
"env": {
|
||||
"description": "manage project and account environment variables"
|
||||
},
|
||||
"update": {
|
||||
"description": "manage individual updates"
|
||||
},
|
||||
@@ -196,10 +222,6 @@
|
||||
"description": "manage workflows"
|
||||
}
|
||||
},
|
||||
"warn-if-update-available": {
|
||||
"timeoutInDays": 0.5,
|
||||
"message": "<%= chalk('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available.') %>\n<%= chalk.dim('To upgrade, run:') %>\n<%= chalk.dim(chalk.bold('npm install -g eas-cli')) %>\n<%= chalk.dim('Proceeding with outdated version.') %>\n"
|
||||
},
|
||||
"update": {
|
||||
"node": {
|
||||
"version": "20.19.4"
|
||||
@@ -213,34 +235,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalHelpFlags": [
|
||||
"-h"
|
||||
],
|
||||
"additionalVersionFlags": [
|
||||
"-v"
|
||||
]
|
||||
"warn-if-update-available": {
|
||||
"message": "<%= chalk('★') %> <%= chalk.bold(config.name + '@' + latest) %> <%= chalk.bold('is now available.') %>\n<%= chalk.dim('To upgrade, run:') %>\n<%= chalk.dim(chalk.bold('npm install -g eas-cli')) %>\n<%= chalk.dim('Proceeding with outdated version.') %>\n",
|
||||
"timeoutInDays": 0.5
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/eas-cli"
|
||||
},
|
||||
"scripts": {
|
||||
"postpack": "rimraf oclif.manifest.json",
|
||||
"prepack": "yarn rebuild && node ./scripts/prepack.js",
|
||||
"rebuild": "rimraf build && yarn build",
|
||||
"pretarball-ci": "./scripts/pretarball-ci.sh",
|
||||
"build": "yarn typecheck-for-build && yarn copy-new-templates",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
"watch": "yarn typecheck-for-build --watch --preserveWatchOutput",
|
||||
"watch-allow-unused": "yarn build-allow-unused --watch --preserveWatchOutput",
|
||||
"typecheck": "tsc",
|
||||
"typecheck-for-build": "tsc --project tsconfig.build.json",
|
||||
"test": "jest",
|
||||
"version": "yarn oclif readme && node scripts/patch-readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
|
||||
"verify-graphql-code": "./scripts/verify-graphql.sh",
|
||||
"clean": "rimraf dist build tmp node_modules yarn-error.log *.tsbuildinfo",
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,6 @@ const spawn = require('@expo/spawn-async');
|
||||
|
||||
(async () => {
|
||||
if (!process.env.CLI_SIZE_CHECK) {
|
||||
await spawn('yarn', ['oclif', 'manifest'], { stdio: 'inherit' });
|
||||
await spawn('yarn', ['run', '-T', 'oclif', 'manifest'], { stdio: 'inherit' });
|
||||
}
|
||||
})();
|
||||
Loaded 100 of 314 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user