mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5484dd6074 | ||
|
|
8fd3fe0a3a | ||
|
|
ce93a92dc8 | ||
|
|
3f4ed25bb9 | ||
|
|
02b54ac216 | ||
|
|
4b44fecc69 | ||
|
|
1c082665e8 | ||
|
|
7cec953e9a | ||
|
|
868178fb37 | ||
|
|
8439f4fbc9 | ||
|
|
78644d299f | ||
|
|
0d0134dae5 | ||
|
|
b03d31561c | ||
|
|
0e85408741 | ||
|
|
4107b7e611 | ||
|
|
6cbddf5b8e | ||
|
|
a3f4136ac8 | ||
|
|
6926209e53 | ||
|
|
efc97f3bdb | ||
|
|
f72cff08a1 | ||
|
|
7b98b074f4 | ||
|
|
40b02895f7 | ||
|
|
1f4c1edd99 | ||
|
|
2f320aea43 | ||
|
|
f41696701e | ||
|
|
a9d693d867 | ||
|
|
b92a9cc5a7 | ||
|
|
763cbadaaa |
No files matched your search
@@ -27,6 +27,9 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-mise
|
||||
|
||||
- name: Install eas-cli
|
||||
run: npm install --global eas-cli@latest
|
||||
|
||||
- uses: ./.github/internal-actions/setup-gcloud
|
||||
|
||||
- name: Check if worker already exists
|
||||
@@ -61,7 +64,7 @@ jobs:
|
||||
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
|
||||
|
||||
# Run workflow and capture output (includes run ID)
|
||||
WORKFLOW_OUTPUT=$(npx -y eas-cli@latest workflow:run build-worker.yml \
|
||||
WORKFLOW_OUTPUT=$(eas workflow:run build-worker.yml \
|
||||
--ref ${{ github.sha }} \
|
||||
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
|
||||
--non-interactive \
|
||||
@@ -96,7 +99,7 @@ jobs:
|
||||
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
|
||||
|
||||
while true; do
|
||||
VIEW_OUTPUT=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" \
|
||||
VIEW_OUTPUT=$(eas workflow:view "$WORKFLOW_RUN_ID" \
|
||||
--non-interactive \
|
||||
--json)
|
||||
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
|
||||
@@ -181,7 +184,7 @@ jobs:
|
||||
|
||||
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
|
||||
if [ -n "$WORKFLOW_RUN_ID" ]; then
|
||||
npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
|
||||
eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
|
||||
fi
|
||||
|
||||
- name: Upload worker tarballs to GCP
|
||||
|
||||
@@ -15,12 +15,15 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-mise
|
||||
|
||||
- name: Install eas-cli
|
||||
run: npm install --global eas-cli@latest
|
||||
|
||||
- name: Create test app
|
||||
run: npx -y create-expo-app@latest ${{ runner.temp }}/test-app --template tabs
|
||||
|
||||
- name: Link to EAS project
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: npx -y eas-cli@latest init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
|
||||
run: eas init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
|
||||
|
||||
- name: Add bundle identifiers to app.json
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
@@ -30,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Configure EAS Build
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
run: npx -y eas-cli@latest build:configure --platform all
|
||||
run: eas build:configure --platform all
|
||||
|
||||
- name: Add system-test profile to eas.json
|
||||
working-directory: ${{ runner.temp }}/test-app
|
||||
@@ -49,7 +52,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_JSON=$(npx -y eas-cli@latest build --platform ios --profile system-test --non-interactive --no-wait --json)
|
||||
BUILD_JSON=$(eas build --platform ios --profile system-test --non-interactive --no-wait --json)
|
||||
BUILD_ID=$(echo "$BUILD_JSON" | jq -r 'if type=="array" then .[0].id // .[0].build.id // .[0].buildId else .id // .build.id // .buildId end // empty')
|
||||
if [ -z "$BUILD_ID" ]; then
|
||||
echo "Error: could not determine build ID"
|
||||
@@ -59,7 +62,7 @@ jobs:
|
||||
echo "BUILD_ID=$BUILD_ID" >> "$GITHUB_ENV"
|
||||
echo "Resolved BUILD_ID=$BUILD_ID"
|
||||
|
||||
WORKFLOW_JSON=$(npx -y eas-cli@latest workflow:run build-android.yml --non-interactive --no-wait --json)
|
||||
WORKFLOW_JSON=$(eas workflow:run build-android.yml --non-interactive --no-wait --json)
|
||||
WORKFLOW_RUN_ID=$(echo "$WORKFLOW_JSON" | jq -r '.id // .workflowRun.id // .workflowRunId // empty')
|
||||
if [ -z "$WORKFLOW_RUN_ID" ]; then
|
||||
echo "Error: could not determine workflow run ID"
|
||||
@@ -95,8 +98,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json | jq -r '.status // empty')
|
||||
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json | jq -r '.status // empty')
|
||||
BUILD_STATUS=$(eas build:view "$BUILD_ID" --json | jq -r '.status // empty')
|
||||
WORKFLOW_STATUS=$(eas workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json | jq -r '.status // empty')
|
||||
|
||||
echo "iOS build status: $BUILD_STATUS"
|
||||
echo "Android workflow status: $WORKFLOW_STATUS"
|
||||
@@ -130,13 +133,13 @@ jobs:
|
||||
|
||||
if [ -n "${BUILD_ID:-}" ]; then
|
||||
echo "Canceling EAS build: $BUILD_ID"
|
||||
BUILD_CANCEL_OUTPUT=$(npx -y eas-cli@latest build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
|
||||
BUILD_CANCEL_OUTPUT=$(eas build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
|
||||
if [ -n "$BUILD_CANCEL_OUTPUT" ]; then
|
||||
echo "$BUILD_CANCEL_OUTPUT"
|
||||
else
|
||||
echo "build:cancel produced no output"
|
||||
fi
|
||||
BUILD_STATUS=$(npx -y eas-cli@latest build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
BUILD_STATUS=$(eas build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
if [ -n "$BUILD_STATUS" ]; then
|
||||
echo "Post-cancel build status: $BUILD_STATUS"
|
||||
else
|
||||
@@ -145,13 +148,13 @@ jobs:
|
||||
fi
|
||||
if [ -n "${WORKFLOW_RUN_ID:-}" ]; then
|
||||
echo "Canceling EAS workflow run: $WORKFLOW_RUN_ID"
|
||||
WORKFLOW_CANCEL_OUTPUT=$(npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
|
||||
WORKFLOW_CANCEL_OUTPUT=$(eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
|
||||
if [ -n "$WORKFLOW_CANCEL_OUTPUT" ]; then
|
||||
echo "$WORKFLOW_CANCEL_OUTPUT"
|
||||
else
|
||||
echo "workflow:cancel produced no output"
|
||||
fi
|
||||
WORKFLOW_STATUS=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
WORKFLOW_STATUS=$(eas workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
|
||||
if [ -n "$WORKFLOW_STATUS" ]; then
|
||||
echo "Post-cancel workflow status: $WORKFLOW_STATUS"
|
||||
else
|
||||
|
||||
+25
-2
@@ -8,16 +8,38 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--device` flag to `eas simulator` for selecting the virtual device to start. ([#4172](https://github.com/expo/eas-cli/pull/4172) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Add support for Appium EAS Simulator sessions. ([#4190](https://github.com/expo/eas-cli/pull/4190) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Disallow 0-length uploads. ([#4192](https://github.com/expo/eas-cli/pull/4192) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Send the expected `Content-MD5` checksum with GCS signed URL uploads so GCS rejects corrupted uploads. ([#4208](https://github.com/expo/eas-cli/pull/4208) by [@wschurman](https://github.com/wschurman))
|
||||
- [eas-cli] Resolve the `eas go` SDK version to its repack target, so `--sdk-version 57` no longer fails with "No Expo Go repack target is published for SDK 57". ([#4215](https://github.com/expo/eas-cli/pull/4215) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Handle missing fingerprint source in fingerprint:compare command. ([#4210](https://github.com/expo/eas-cli/pull/4210) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Fix lint and enforce max-warnings=0. ([#4219](https://github.com/expo/eas-cli/pull/4219) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Rename serve-sim ngrok preview URLs to use the `web-preview` prefix. ([#4231](https://github.com/expo/eas-cli/pull/4231) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
## [22.0.0](https://github.com/expo/eas-cli/releases/tag/v22.0.0) - 2026-08-14
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Build objects in `--json` output (`eas build`, `eas build:list`, `eas build:view`, `eas build:resign`) now have `app`, `updateChannel` (object with `id` and `name`), and `runtime` (object with `id` and `version`) instead of `project`, `channel`, and `runtimeVersion`. `eas update:republish --json` output likewise has `runtime` instead of `runtimeVersion`. Other update commands' `--json` output and `eas-update-metadata.json` are unchanged. ([#4021](https://github.com/expo/eas-cli/pull/4021) by [@tchayen](https://github.com/tchayen))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Upload JavaScript source maps generated by builds. ([#4170](https://github.com/expo/eas-cli/pull/4170) by [@AbbanMustafa](https://github.com/AbbanMustafa))
|
||||
- [eas-cli] Set the project icon on the EAS dashboard from the app config when running `eas init`, if the project does not have one yet. ([#4177](https://github.com/expo/eas-cli/pull/4177) by [@tchayen](https://github.com/tchayen))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Report EAS Simulator as available to Expo admins in `eas simulator:availability`, even when the project account's feature gate is disabled. ([#4181](https://github.com/expo/eas-cli/pull/4181) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Make the non-interactive "EAS project not configured" error actionable on every path: list the exact `eas init --id` / `eas init --account` commands and the accounts you can create projects in, instead of suggesting bare `eas init`, which fails the same way. ([#4153](https://github.com/expo/eas-cli/pull/4153) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Set up the internal TestFlight group and invite admin testers when submitting interactively with an existing `ascAppId`, with `--no-auto-testflight-setup` to disable it, and fix the TestFlight group URL printed when adding testers partially fails. ([#4136](https://github.com/expo/eas-cli/pull/4136) by [@tchayen](https://github.com/tchayen))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [21.8.0](https://github.com/expo/eas-cli/releases/tag/v21.8.0) - 2026-08-11
|
||||
|
||||
### 🎉 New features
|
||||
@@ -79,6 +101,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Soft-stop EAS Simulator sessions before the backing job times out so cleanup can finish. ([#4117](https://github.com/expo/eas-cli/pull/4117) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] clean up error handling in local builds. ([#4105](https://github.com/expo/eas-cli/pull/4105) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Install ffmpeg when it is missing so Argent screen recording works in EAS Simulator sessions. ([#4110](https://github.com/expo/eas-cli/pull/4110) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Stop simulator job runs when `eas simulator:start` is canceled before the session is ready. ([#4113](https://github.com/expo/eas-cli/pull/4113) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "21.8.0",
|
||||
"STAGING": "22.0.0",
|
||||
"PRODUCTION": "21.5.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.8.0",
|
||||
"version": "22.2.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"watch": "yarn start",
|
||||
"eas": "packages/eas-cli/bin/run",
|
||||
"actionlint": "actionlint",
|
||||
"lint": "oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json .",
|
||||
"lint": "oxlint --type-aware --import-plugin --max-warnings=0 --tsconfig ./tsconfig.oxlint.json .",
|
||||
"fmt": "oxfmt .",
|
||||
"fmt:check": "oxfmt --check .",
|
||||
"lint-changelog": "./scripts/bin/run lint-changelog",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "21.8.0",
|
||||
"version": "22.2.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -38,17 +38,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "21.0.0",
|
||||
"@expo/eas-build-job": "21.6.0",
|
||||
"@expo/downloader": "22.0.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.7.1",
|
||||
"@expo/template-file": "21.0.2",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@expo/steps": "22.1.0",
|
||||
"@expo/template-file": "22.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
#include <dlfcn.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/*
|
||||
* Xcode loads one plugin that implements the llcas_* API. This small shim loads
|
||||
* Apple's local CAS plugin and forwards every API call to it. The only exception
|
||||
* is the "remote-service-path" option: Xcode needs that option to schedule C and
|
||||
* Objective-C cache tasks, but Apple's local plugin does not need a remote service.
|
||||
* The shim accepts and discards that option. No cache data leaves the machine.
|
||||
*/
|
||||
|
||||
typedef void *llcas_cas_options_t;
|
||||
typedef void *llcas_cas_t;
|
||||
typedef void *llcas_cancellable_t;
|
||||
typedef uint32_t llcas_lookup_result_t;
|
||||
|
||||
typedef struct {
|
||||
const uint8_t *data;
|
||||
size_t size;
|
||||
} llcas_digest_t;
|
||||
|
||||
typedef struct {
|
||||
const void *data;
|
||||
size_t size;
|
||||
} llcas_data_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t opaque;
|
||||
} llcas_objectid_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t opaque;
|
||||
} llcas_loaded_object_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t opaque_b;
|
||||
uint64_t opaque_e;
|
||||
} llcas_object_refs_t;
|
||||
|
||||
typedef void (*llcas_cas_load_object_cb)(void *, llcas_lookup_result_t,
|
||||
llcas_loaded_object_t, char *);
|
||||
typedef void (*llcas_actioncache_get_cb)(void *, llcas_lookup_result_t,
|
||||
llcas_objectid_t, char *);
|
||||
typedef void (*llcas_actioncache_put_cb)(void *, bool, char *);
|
||||
|
||||
static void *apple_plugin_handle;
|
||||
|
||||
static void *apple_plugin_symbol(const char *name) {
|
||||
if (apple_plugin_handle == NULL) {
|
||||
const char *path = getenv("EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN");
|
||||
if (path == NULL || path[0] == '\0') {
|
||||
path = "/Applications/Xcode.app/Contents/Developer/usr/lib/"
|
||||
"libToolchainCASPlugin.dylib";
|
||||
}
|
||||
apple_plugin_handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
|
||||
if (apple_plugin_handle == NULL) {
|
||||
fprintf(stderr, "EAS local CAS plugin could not load %s: %s\n", path,
|
||||
dlerror());
|
||||
abort();
|
||||
}
|
||||
}
|
||||
|
||||
void *symbol = dlsym(apple_plugin_handle, name);
|
||||
if (symbol == NULL) {
|
||||
fprintf(stderr, "EAS local CAS plugin could not find %s: %s\n", name,
|
||||
dlerror());
|
||||
abort();
|
||||
}
|
||||
return symbol;
|
||||
}
|
||||
|
||||
#define APPLE_PLUGIN(name, type) ((type)apple_plugin_symbol(#name))
|
||||
|
||||
void llcas_get_plugin_version(uint32_t *major, uint32_t *minor) {
|
||||
typedef void (*fn_t)(uint32_t *, uint32_t *);
|
||||
APPLE_PLUGIN(llcas_get_plugin_version, fn_t)(major, minor);
|
||||
}
|
||||
|
||||
void llcas_string_dispose(char *value) {
|
||||
typedef void (*fn_t)(char *);
|
||||
APPLE_PLUGIN(llcas_string_dispose, fn_t)(value);
|
||||
}
|
||||
|
||||
void llcas_cancellable_cancel(llcas_cancellable_t token) {
|
||||
typedef void (*fn_t)(llcas_cancellable_t);
|
||||
APPLE_PLUGIN(llcas_cancellable_cancel, fn_t)(token);
|
||||
}
|
||||
|
||||
void llcas_cancellable_dispose(llcas_cancellable_t token) {
|
||||
typedef void (*fn_t)(llcas_cancellable_t);
|
||||
APPLE_PLUGIN(llcas_cancellable_dispose, fn_t)(token);
|
||||
}
|
||||
|
||||
llcas_cas_options_t llcas_cas_options_create(void) {
|
||||
typedef llcas_cas_options_t (*fn_t)(void);
|
||||
return APPLE_PLUGIN(llcas_cas_options_create, fn_t)();
|
||||
}
|
||||
|
||||
void llcas_cas_options_dispose(llcas_cas_options_t options) {
|
||||
typedef void (*fn_t)(llcas_cas_options_t);
|
||||
APPLE_PLUGIN(llcas_cas_options_dispose, fn_t)(options);
|
||||
}
|
||||
|
||||
void llcas_cas_options_set_client_version(llcas_cas_options_t options,
|
||||
uint32_t major, uint32_t minor) {
|
||||
typedef void (*fn_t)(llcas_cas_options_t, uint32_t, uint32_t);
|
||||
APPLE_PLUGIN(llcas_cas_options_set_client_version, fn_t)(options, major, minor);
|
||||
}
|
||||
|
||||
void llcas_cas_options_set_ondisk_path(llcas_cas_options_t options,
|
||||
const char *path) {
|
||||
typedef void (*fn_t)(llcas_cas_options_t, const char *);
|
||||
APPLE_PLUGIN(llcas_cas_options_set_ondisk_path, fn_t)(options, path);
|
||||
}
|
||||
|
||||
bool llcas_cas_options_set_option(llcas_cas_options_t options, const char *name,
|
||||
const char *value, char **error) {
|
||||
if (name != NULL && strcmp(name, "remote-service-path") == 0) {
|
||||
if (error != NULL) {
|
||||
*error = NULL;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
typedef bool (*fn_t)(llcas_cas_options_t, const char *, const char *,
|
||||
char **);
|
||||
return APPLE_PLUGIN(llcas_cas_options_set_option, fn_t)(options, name, value,
|
||||
error);
|
||||
}
|
||||
|
||||
llcas_cas_t llcas_cas_create(llcas_cas_options_t options, char **error) {
|
||||
typedef llcas_cas_t (*fn_t)(llcas_cas_options_t, char **);
|
||||
return APPLE_PLUGIN(llcas_cas_create, fn_t)(options, error);
|
||||
}
|
||||
|
||||
void llcas_cas_dispose(llcas_cas_t cas) {
|
||||
typedef void (*fn_t)(llcas_cas_t);
|
||||
APPLE_PLUGIN(llcas_cas_dispose, fn_t)(cas);
|
||||
}
|
||||
|
||||
int64_t llcas_cas_get_ondisk_size(llcas_cas_t cas, char **error) {
|
||||
typedef int64_t (*fn_t)(llcas_cas_t, char **);
|
||||
return APPLE_PLUGIN(llcas_cas_get_ondisk_size, fn_t)(cas, error);
|
||||
}
|
||||
|
||||
bool llcas_cas_set_ondisk_size_limit(llcas_cas_t cas, int64_t size,
|
||||
char **error) {
|
||||
typedef bool (*fn_t)(llcas_cas_t, int64_t, char **);
|
||||
return APPLE_PLUGIN(llcas_cas_set_ondisk_size_limit, fn_t)(cas, size, error);
|
||||
}
|
||||
|
||||
bool llcas_cas_prune_ondisk_data(llcas_cas_t cas, char **error) {
|
||||
typedef bool (*fn_t)(llcas_cas_t, char **);
|
||||
return APPLE_PLUGIN(llcas_cas_prune_ondisk_data, fn_t)(cas, error);
|
||||
}
|
||||
|
||||
char *llcas_cas_get_hash_schema_name(llcas_cas_t cas) {
|
||||
typedef char *(*fn_t)(llcas_cas_t);
|
||||
return APPLE_PLUGIN(llcas_cas_get_hash_schema_name, fn_t)(cas);
|
||||
}
|
||||
|
||||
uint32_t llcas_digest_parse(llcas_cas_t cas, const char *printed,
|
||||
uint8_t *bytes, size_t bytes_size, char **error) {
|
||||
typedef uint32_t (*fn_t)(llcas_cas_t, const char *, uint8_t *, size_t,
|
||||
char **);
|
||||
return APPLE_PLUGIN(llcas_digest_parse, fn_t)(cas, printed, bytes,
|
||||
bytes_size, error);
|
||||
}
|
||||
|
||||
bool llcas_digest_print(llcas_cas_t cas, llcas_digest_t digest, char **printed,
|
||||
char **error) {
|
||||
typedef bool (*fn_t)(llcas_cas_t, llcas_digest_t, char **, char **);
|
||||
return APPLE_PLUGIN(llcas_digest_print, fn_t)(cas, digest, printed, error);
|
||||
}
|
||||
|
||||
bool llcas_cas_get_objectid(llcas_cas_t cas, llcas_digest_t digest,
|
||||
llcas_objectid_t *object, char **error) {
|
||||
typedef bool (*fn_t)(llcas_cas_t, llcas_digest_t, llcas_objectid_t *,
|
||||
char **);
|
||||
return APPLE_PLUGIN(llcas_cas_get_objectid, fn_t)(cas, digest, object, error);
|
||||
}
|
||||
|
||||
llcas_digest_t llcas_objectid_get_digest(llcas_cas_t cas,
|
||||
llcas_objectid_t object) {
|
||||
typedef llcas_digest_t (*fn_t)(llcas_cas_t, llcas_objectid_t);
|
||||
return APPLE_PLUGIN(llcas_objectid_get_digest, fn_t)(cas, object);
|
||||
}
|
||||
|
||||
llcas_lookup_result_t llcas_cas_contains_object(llcas_cas_t cas,
|
||||
llcas_objectid_t object,
|
||||
bool globally, char **error) {
|
||||
typedef llcas_lookup_result_t (*fn_t)(llcas_cas_t, llcas_objectid_t, bool,
|
||||
char **);
|
||||
return APPLE_PLUGIN(llcas_cas_contains_object, fn_t)(cas, object, globally,
|
||||
error);
|
||||
}
|
||||
|
||||
llcas_lookup_result_t llcas_cas_load_object(llcas_cas_t cas,
|
||||
llcas_objectid_t object,
|
||||
llcas_loaded_object_t *loaded,
|
||||
char **error) {
|
||||
typedef llcas_lookup_result_t (*fn_t)(llcas_cas_t, llcas_objectid_t,
|
||||
llcas_loaded_object_t *, char **);
|
||||
return APPLE_PLUGIN(llcas_cas_load_object, fn_t)(cas, object, loaded, error);
|
||||
}
|
||||
|
||||
void llcas_cas_load_object_async(llcas_cas_t cas, llcas_objectid_t object,
|
||||
void *context,
|
||||
llcas_cas_load_object_cb callback,
|
||||
llcas_cancellable_t *cancellable) {
|
||||
typedef void (*fn_t)(llcas_cas_t, llcas_objectid_t, void *,
|
||||
llcas_cas_load_object_cb, llcas_cancellable_t *);
|
||||
APPLE_PLUGIN(llcas_cas_load_object_async,
|
||||
fn_t)(cas, object, context, callback, cancellable);
|
||||
}
|
||||
|
||||
bool llcas_cas_store_object(llcas_cas_t cas, llcas_data_t data,
|
||||
const llcas_objectid_t *refs, size_t refs_count,
|
||||
llcas_objectid_t *stored, char **error) {
|
||||
typedef bool (*fn_t)(llcas_cas_t, llcas_data_t, const llcas_objectid_t *,
|
||||
size_t, llcas_objectid_t *, char **);
|
||||
return APPLE_PLUGIN(llcas_cas_store_object,
|
||||
fn_t)(cas, data, refs, refs_count, stored, error);
|
||||
}
|
||||
|
||||
llcas_data_t llcas_loaded_object_get_data(llcas_cas_t cas,
|
||||
llcas_loaded_object_t object) {
|
||||
typedef llcas_data_t (*fn_t)(llcas_cas_t, llcas_loaded_object_t);
|
||||
return APPLE_PLUGIN(llcas_loaded_object_get_data, fn_t)(cas, object);
|
||||
}
|
||||
|
||||
llcas_object_refs_t llcas_loaded_object_get_refs(llcas_cas_t cas,
|
||||
llcas_loaded_object_t object) {
|
||||
typedef llcas_object_refs_t (*fn_t)(llcas_cas_t, llcas_loaded_object_t);
|
||||
return APPLE_PLUGIN(llcas_loaded_object_get_refs, fn_t)(cas, object);
|
||||
}
|
||||
|
||||
size_t llcas_object_refs_get_count(llcas_cas_t cas, llcas_object_refs_t refs) {
|
||||
typedef size_t (*fn_t)(llcas_cas_t, llcas_object_refs_t);
|
||||
return APPLE_PLUGIN(llcas_object_refs_get_count, fn_t)(cas, refs);
|
||||
}
|
||||
|
||||
llcas_objectid_t llcas_object_refs_get_id(llcas_cas_t cas,
|
||||
llcas_object_refs_t refs,
|
||||
size_t index) {
|
||||
typedef llcas_objectid_t (*fn_t)(llcas_cas_t, llcas_object_refs_t, size_t);
|
||||
return APPLE_PLUGIN(llcas_object_refs_get_id, fn_t)(cas, refs, index);
|
||||
}
|
||||
|
||||
llcas_lookup_result_t llcas_actioncache_get_for_digest(llcas_cas_t cas,
|
||||
llcas_digest_t digest,
|
||||
llcas_objectid_t *result,
|
||||
bool globally,
|
||||
char **error) {
|
||||
typedef llcas_lookup_result_t (*fn_t)(llcas_cas_t, llcas_digest_t,
|
||||
llcas_objectid_t *, bool, char **);
|
||||
return APPLE_PLUGIN(llcas_actioncache_get_for_digest,
|
||||
fn_t)(cas, digest, result, globally, error);
|
||||
}
|
||||
|
||||
void llcas_actioncache_get_for_digest_async(llcas_cas_t cas,
|
||||
llcas_digest_t digest,
|
||||
bool globally, void *context,
|
||||
llcas_actioncache_get_cb callback,
|
||||
llcas_cancellable_t *cancellable) {
|
||||
typedef void (*fn_t)(llcas_cas_t, llcas_digest_t, bool, void *,
|
||||
llcas_actioncache_get_cb, llcas_cancellable_t *);
|
||||
APPLE_PLUGIN(llcas_actioncache_get_for_digest_async,
|
||||
fn_t)(cas, digest, globally, context, callback, cancellable);
|
||||
}
|
||||
|
||||
bool llcas_actioncache_put_for_digest(llcas_cas_t cas, llcas_digest_t digest,
|
||||
llcas_objectid_t value, bool globally,
|
||||
char **error) {
|
||||
typedef bool (*fn_t)(llcas_cas_t, llcas_digest_t, llcas_objectid_t, bool,
|
||||
char **);
|
||||
return APPLE_PLUGIN(llcas_actioncache_put_for_digest,
|
||||
fn_t)(cas, digest, value, globally, error);
|
||||
}
|
||||
|
||||
void llcas_actioncache_put_for_digest_async(llcas_cas_t cas,
|
||||
llcas_digest_t digest,
|
||||
llcas_objectid_t value,
|
||||
bool globally, void *context,
|
||||
llcas_actioncache_put_cb callback,
|
||||
llcas_cancellable_t *cancellable) {
|
||||
typedef void (*fn_t)(llcas_cas_t, llcas_digest_t, llcas_objectid_t, bool,
|
||||
void *, llcas_actioncache_put_cb, llcas_cancellable_t *);
|
||||
APPLE_PLUGIN(llcas_actioncache_put_for_digest_async,
|
||||
fn_t)(cas, digest, value, globally, context, callback,
|
||||
cancellable);
|
||||
}
|
||||
@@ -18,12 +18,8 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
|
||||
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import {
|
||||
cacheStatsAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreXcodeCompilationCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveXcodeCompilationCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
@@ -104,21 +100,13 @@ async function buildInnerAsync(
|
||||
return;
|
||||
}
|
||||
await ctx.cacheManager?.restoreCache(ctx);
|
||||
await Promise.all([
|
||||
restoreCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
}),
|
||||
restoreXcodeCompilationCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
}),
|
||||
]);
|
||||
await restoreCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
|
||||
@@ -254,22 +242,14 @@ async function buildInnerAsync(
|
||||
return;
|
||||
}
|
||||
await ctx.cacheManager?.saveCache(ctx);
|
||||
await Promise.all([
|
||||
saveCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
evictUsedBefore,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
}),
|
||||
saveXcodeCompilationCacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
}),
|
||||
]);
|
||||
await saveCcacheAsync({
|
||||
logger: ctx.logger,
|
||||
workingDirectory,
|
||||
platform: ctx.job.platform,
|
||||
evictUsedBefore,
|
||||
env: ctx.env,
|
||||
secrets: ctx.job.secrets,
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import {
|
||||
XCODE_COMPILATION_CACHE_ENV,
|
||||
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
|
||||
compressXcodeCompilationCacheAsync,
|
||||
decompressXcodeCompilationCacheAsync,
|
||||
prepareXcodeCompilationCacheEnvAsync,
|
||||
} from '../compilationCache';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const DEVELOPER_DIRECTORY = '/Applications/Xcode.app/Contents/Developer';
|
||||
const APPLE_PLUGIN_PATH = path.join(DEVELOPER_DIRECTORY, 'usr/lib/libToolchainCASPlugin.dylib');
|
||||
const BUNDLED_SHIM_PATH = path.resolve(
|
||||
__dirname,
|
||||
'../../../bin/libeas_xcode_local_cas_plugin.dylib'
|
||||
);
|
||||
|
||||
describe(prepareXcodeCompilationCacheEnvAsync, () => {
|
||||
test('does nothing without the explicit opt-in', async () => {
|
||||
const result = await prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath: '/project/ios/build',
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('enables compilation caching after validating the bundled arm64 shim', async () => {
|
||||
vol.fromJSON({
|
||||
[BUNDLED_SHIM_PATH]: 'bundled shim',
|
||||
[APPLE_PLUGIN_PATH]: 'Apple plugin',
|
||||
});
|
||||
vol.mkdirSync(os.tmpdir(), { recursive: true });
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
|
||||
const result = await prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath: '/project/ios/build',
|
||||
env: {
|
||||
[XCODE_COMPILATION_CACHE_ENV]: '1',
|
||||
DEVELOPER_DIR: DEVELOPER_DIRECTORY,
|
||||
},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
expect.arrayContaining([
|
||||
'llvm-cas',
|
||||
`--fcas-plugin-path=${BUNDLED_SHIM_PATH}`,
|
||||
'--fcas-plugin-option=remote-service-path=/dev/null',
|
||||
'--ingest',
|
||||
BUNDLED_SHIM_PATH,
|
||||
]),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: APPLE_PLUGIN_PATH,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(result).toEqual({
|
||||
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: APPLE_PLUGIN_PATH,
|
||||
GYM_XCARGS: `COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES COMPILATION_CACHE_ENABLE_PLUGIN=YES COMPILATION_CACHE_PLUGIN_PATH=${BUNDLED_SHIM_PATH} COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null`,
|
||||
GYM_DERIVED_DATA_PATH: '/project/ios/build',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not enable compilation caching when the bundled shim is missing', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
[APPLE_PLUGIN_PATH]: 'Apple plugin',
|
||||
});
|
||||
|
||||
const result = await prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath: '/project/ios/build',
|
||||
env: {
|
||||
[XCODE_COMPILATION_CACHE_ENV]: '1',
|
||||
DEVELOPER_DIR: DEVELOPER_DIRECTORY,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
expect.stringContaining('compilation caching is disabled')
|
||||
);
|
||||
});
|
||||
|
||||
test('does not enable compilation caching when the bundled shim is incompatible', async () => {
|
||||
const logger = createMockLogger();
|
||||
vol.fromJSON({
|
||||
[BUNDLED_SHIM_PATH]: 'bundled shim',
|
||||
[APPLE_PLUGIN_PATH]: 'Apple plugin',
|
||||
});
|
||||
vol.mkdirSync(os.tmpdir(), { recursive: true });
|
||||
jest.mocked(spawn).mockRejectedValue(new Error('incompatible plugin'));
|
||||
|
||||
const result = await prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath: '/project/ios/build',
|
||||
env: {
|
||||
[XCODE_COMPILATION_CACHE_ENV]: '1',
|
||||
DEVELOPER_DIR: DEVELOPER_DIRECTORY,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
expect.stringContaining('compilation caching is disabled')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Xcode compilation cache archives', () => {
|
||||
test('uses system tar to preserve sparse files', async () => {
|
||||
const workingDirectory = '/working-directory';
|
||||
vol.fromJSON({
|
||||
[path.join(workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH, 'cache.db')]: 'cache',
|
||||
});
|
||||
vol.mkdirSync(os.tmpdir(), { recursive: true });
|
||||
jest.mocked(spawn).mockResolvedValue({} as any);
|
||||
|
||||
const { archivePath } = await compressXcodeCompilationCacheAsync({
|
||||
workingDirectory,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'tar',
|
||||
['-czf', archivePath, '-C', workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH],
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
await decompressXcodeCompilationCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'tar',
|
||||
['-xzSf', archivePath, '-C', workingDirectory],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import path from 'path';
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import * as CompilationCache from '../compilationCache';
|
||||
import { Credentials } from '../credentials/manager';
|
||||
import { DistributionType } from '../credentials/provisioningProfile';
|
||||
import { runFastlaneGym } from '../fastlane';
|
||||
@@ -24,19 +23,13 @@ const WORKING_DIR = '/workingdir';
|
||||
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
|
||||
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
|
||||
|
||||
function makeIosBuildContext({
|
||||
simulator,
|
||||
env = {},
|
||||
}: {
|
||||
simulator: boolean;
|
||||
env?: Record<string, string>;
|
||||
}): BuildContext<Ios.Job> {
|
||||
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
|
||||
const job: Ios.Job = { ...createTestIosJob(), simulator };
|
||||
return new BuildContext(job, {
|
||||
workingdir: WORKING_DIR,
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test', ...env },
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
}
|
||||
@@ -78,10 +71,6 @@ describe(runFastlaneGym, () => {
|
||||
(spawn as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: 'archive',
|
||||
@@ -136,68 +125,4 @@ describe(runFastlaneGym, () => {
|
||||
workspacePath: IOS_DIR,
|
||||
});
|
||||
});
|
||||
|
||||
it('enables local Swift, C, and Objective-C compilation caching', async () => {
|
||||
jest.spyOn(CompilationCache, 'prepareXcodeCompilationCacheEnvAsync').mockResolvedValue({
|
||||
GYM_XCARGS:
|
||||
'OTHER_SWIFT_FLAGS=-warnings-as-errors COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES COMPILATION_CACHE_ENABLE_PLUGIN=YES COMPILATION_CACHE_PLUGIN_PATH=/workingdir/bin/libeas_xcode_local_cas_plugin.dylib COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null',
|
||||
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN:
|
||||
'/Applications/Xcode.app/Contents/Developer/usr/lib/libToolchainCASPlugin.dylib',
|
||||
GYM_DERIVED_DATA_PATH: path.join(IOS_DIR, 'build'),
|
||||
});
|
||||
const ctx = makeIosBuildContext({
|
||||
simulator: false,
|
||||
env: {
|
||||
EAS_BUILD_XCODE_COMPILATION_CACHE: '1',
|
||||
GYM_XCARGS: 'OTHER_SWIFT_FLAGS=-warnings-as-errors',
|
||||
},
|
||||
});
|
||||
|
||||
await runFastlaneGym(ctx, {
|
||||
scheme: 'App',
|
||||
credentials: ARCHIVE_CREDENTIALS,
|
||||
entitlements: null,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'fastlane',
|
||||
['gym'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
GYM_XCARGS:
|
||||
'OTHER_SWIFT_FLAGS=-warnings-as-errors COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES COMPILATION_CACHE_ENABLE_PLUGIN=YES COMPILATION_CACHE_PLUGIN_PATH=/workingdir/bin/libeas_xcode_local_cas_plugin.dylib COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null',
|
||||
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN:
|
||||
'/Applications/Xcode.app/Contents/Developer/usr/lib/libToolchainCASPlugin.dylib',
|
||||
GYM_DERIVED_DATA_PATH: path.join(IOS_DIR, 'build'),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not enable compilation caching when the local plugin cannot be prepared', async () => {
|
||||
const ctx = makeIosBuildContext({
|
||||
simulator: false,
|
||||
env: {
|
||||
EAS_BUILD_XCODE_COMPILATION_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
await runFastlaneGym(ctx, {
|
||||
scheme: 'App',
|
||||
credentials: ARCHIVE_CREDENTIALS,
|
||||
entitlements: null,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'fastlane',
|
||||
['gym'],
|
||||
expect.objectContaining({
|
||||
env: expect.not.objectContaining({ GYM_XCARGS: expect.any(String) }),
|
||||
})
|
||||
);
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
expect.stringContaining('compilation caching is disabled')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
import { Env, Platform } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { createHash } from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { CACHE_KEY_PREFIX_BY_PLATFORM, generateDefaultBuildCacheKeyAsync } from '../utils/cacheKey';
|
||||
|
||||
export const XCODE_COMPILATION_CACHE_ENV = 'EAS_BUILD_XCODE_COMPILATION_CACHE';
|
||||
export const XCODE_COMPILATION_CACHE_RELATIVE_PATH = 'ios/build/CompilationCache.noindex';
|
||||
|
||||
export function getXcodeCompilationCachePath(derivedDataPath: string): string {
|
||||
return path.join(derivedDataPath, 'CompilationCache.noindex');
|
||||
}
|
||||
|
||||
async function prepareXcodeLocalCompilationCachePluginAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
}): Promise<{ pluginPath: string; applePluginPath: string }> {
|
||||
const bundledPluginPath = path.resolve(
|
||||
__dirname,
|
||||
'../../bin/libeas_xcode_local_cas_plugin.dylib'
|
||||
);
|
||||
const developerDirectory =
|
||||
env.DEVELOPER_DIR ??
|
||||
(
|
||||
await spawn('xcode-select', ['--print-path'], {
|
||||
env,
|
||||
logger,
|
||||
})
|
||||
).stdout.trim();
|
||||
const applePluginPath = path.join(
|
||||
developerDirectory,
|
||||
'usr',
|
||||
'lib',
|
||||
'libToolchainCASPlugin.dylib'
|
||||
);
|
||||
|
||||
if (!(await fs.pathExists(applePluginPath))) {
|
||||
throw new Error(`The Xcode CAS plugin is missing at ${applePluginPath}.`);
|
||||
}
|
||||
if (!(await fs.pathExists(bundledPluginPath))) {
|
||||
throw new Error(`The EAS local CAS plugin is missing at ${bundledPluginPath}.`);
|
||||
}
|
||||
|
||||
const validationDirectory = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-xcode-local-cache-validation-')
|
||||
);
|
||||
try {
|
||||
await spawn(
|
||||
'xcrun',
|
||||
[
|
||||
'llvm-cas',
|
||||
`--fcas-plugin-path=${bundledPluginPath}`,
|
||||
'--fcas-plugin-option=remote-service-path=/dev/null',
|
||||
`--cas=${path.join(validationDirectory, 'cas')}`,
|
||||
'--ingest',
|
||||
bundledPluginPath,
|
||||
],
|
||||
{
|
||||
env: { ...env, EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: applePluginPath },
|
||||
logger,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
await fs.remove(validationDirectory);
|
||||
}
|
||||
|
||||
return { pluginPath: bundledPluginPath, applePluginPath };
|
||||
}
|
||||
|
||||
export async function compressXcodeCompilationCacheAsync({
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
}): Promise<{ archivePath: string }> {
|
||||
const cachePath = path.join(workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH);
|
||||
if (!(await fs.pathExists(cachePath))) {
|
||||
throw new Error(`The Xcode compilation cache does not exist at ${cachePath}.`);
|
||||
}
|
||||
|
||||
const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'xcode-compilation-cache-'));
|
||||
const archivePath = path.join(archiveDirectory, 'cache.tar.gz');
|
||||
await spawn(
|
||||
'tar',
|
||||
['-czf', archivePath, '-C', workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH],
|
||||
{ env, logger }
|
||||
);
|
||||
return { archivePath };
|
||||
}
|
||||
|
||||
export async function decompressXcodeCompilationCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
archivePath: string;
|
||||
workingDirectory: string;
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
await fs.ensureDir(workingDirectory);
|
||||
await spawn('tar', ['-xzSf', archivePath, '-C', workingDirectory], { env, logger });
|
||||
}
|
||||
|
||||
export async function generateXcodeCompilationCacheKeyAsync({
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
workingDirectory: string;
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
}): Promise<{ key: string; keyPrefix: string; xcodeVersion: string }> {
|
||||
const xcodeVersion = (
|
||||
await spawn('xcodebuild', ['-version'], {
|
||||
env,
|
||||
logger,
|
||||
})
|
||||
).stdout.trim();
|
||||
const xcodeVersionHash = createHash('sha256').update(xcodeVersion).digest('hex').slice(0, 16);
|
||||
const ccacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, Platform.IOS);
|
||||
const dependencyHash = ccacheKey.slice(CACHE_KEY_PREFIX_BY_PLATFORM[Platform.IOS].length);
|
||||
const keyPrefix = `ios-xcode-compilation-cache-${xcodeVersionHash}-`;
|
||||
|
||||
return {
|
||||
key: `${keyPrefix}${dependencyHash}`,
|
||||
keyPrefix,
|
||||
xcodeVersion: xcodeVersion.replace(/\s+/g, ' '),
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
derivedDataPath: string;
|
||||
env: Env;
|
||||
logger: bunyan;
|
||||
}): Promise<Env> {
|
||||
if (env[XCODE_COMPILATION_CACHE_ENV] !== '1') {
|
||||
return {};
|
||||
}
|
||||
|
||||
let plugin: { pluginPath: string; applePluginPath: string };
|
||||
try {
|
||||
plugin = await prepareXcodeLocalCompilationCachePluginAsync({
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(`The EAS local Xcode CAS plugin is ready at ${plugin.pluginPath}.`);
|
||||
} catch (error: any) {
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'The EAS local Xcode CAS plugin is missing or incompatible. Xcode compilation caching is disabled.'
|
||||
);
|
||||
return {};
|
||||
}
|
||||
logger.info(
|
||||
`Xcode compilation caching is enabled. The cache directory is ${getXcodeCompilationCachePath(derivedDataPath)}.`
|
||||
);
|
||||
|
||||
const pluginXcargs = [
|
||||
'COMPILATION_CACHE_ENABLE_PLUGIN=YES',
|
||||
`COMPILATION_CACHE_PLUGIN_PATH=${plugin.pluginPath}`,
|
||||
// A non-empty remote service path makes Xcode schedule C, Objective-C,
|
||||
// precompiled-module, and precompiled-header cache tasks. The EAS plugin
|
||||
// consumes this option and never opens the path or performs remote I/O.
|
||||
'COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null',
|
||||
].join(' ');
|
||||
|
||||
return {
|
||||
GYM_XCARGS: [
|
||||
env.GYM_XCARGS,
|
||||
'COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES',
|
||||
pluginXcargs,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
GYM_DERIVED_DATA_PATH: derivedDataPath,
|
||||
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: plugin.applePluginPath,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import type { Credentials } from './credentials/manager';
|
||||
import * as CompilationCache from './compilationCache';
|
||||
import { createFastfileForResigningBuild } from './fastfile';
|
||||
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
|
||||
import { isTVOS } from './tvos';
|
||||
@@ -47,18 +46,13 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
if (ctx.skipNativeBuild) {
|
||||
throw new SkipNativeBuildError('Skipping fastlane build');
|
||||
}
|
||||
const compilationCacheEnv = await CompilationCache.prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath,
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
const buildLogger = new XcodeBuildLogger(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
|
||||
try {
|
||||
await runFastlane(['gym'], {
|
||||
cwd: workspacePath,
|
||||
logger: ctx.logger,
|
||||
env: { ...ctx.env, ...extraEnv, ...compilationCacheEnv },
|
||||
env: { ...ctx.env, ...extraEnv },
|
||||
});
|
||||
} finally {
|
||||
await buildLogger.flush();
|
||||
|
||||
@@ -45,6 +45,7 @@ import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAppiumRemoteSession';
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartIosSimulatorRecordingsBuildFunction } from './functions/startIosSimulatorRecordings';
|
||||
@@ -94,6 +95,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
|
||||
+12
-1
@@ -1,6 +1,6 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { createInstallMaestroBuildFunction } from '../installMaestro';
|
||||
import { createInstallMaestroBuildFunction, installIdbFromBrew } from '../installMaestro';
|
||||
|
||||
jest.unmock('fs');
|
||||
jest.unmock('@expo/logger');
|
||||
@@ -57,3 +57,14 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
console.log(latestStep.outputs[0].value);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describe('installIdbFromBrew', () => {
|
||||
it('installs 1.1.8 version', async () => {
|
||||
const logger = createMockLogger({ logToConsole: true });
|
||||
|
||||
await installIdbFromBrew({
|
||||
logger,
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { Datadog } from '../../../datadog';
|
||||
@@ -179,7 +181,7 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
['--version'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
PATH: expect.stringMatching(/:\/home\/expo\/\.maestro-runner\/bin$/),
|
||||
PATH: expect.stringMatching(/^\/home\/expo\/\.maestro-runner\/bin:/),
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -189,6 +191,217 @@ describe('createInstallMaestroBuildFunction', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('installs maestro-runner 1.1.15 on Xcode versions below 26', async () => {
|
||||
let versionChecks = 0;
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 16.4\nBuild version 16F6\n' };
|
||||
case 'maestro-runner':
|
||||
return {
|
||||
stdout: versionChecks++ === 0 ? 'maestro-runner 1.1.23\n' : 'maestro-runner 1.1.15\n',
|
||||
};
|
||||
default:
|
||||
return { stdout: '' };
|
||||
}
|
||||
}) as any);
|
||||
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
text: async () => '#!/bin/sh\n',
|
||||
} as Response);
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
try {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
|
||||
['--version', '1.1.15'],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
|
||||
);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.15');
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not check Xcode when a compatible maestro-runner version is installed', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'xcodebuild') {
|
||||
throw new Error('xcodebuild should not be called');
|
||||
}
|
||||
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.1.15\n' : '' };
|
||||
}) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'xcodebuild',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.15');
|
||||
});
|
||||
|
||||
it('checks Xcode for latest when a compatible maestro-runner version is installed', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
switch (command) {
|
||||
case 'xcodebuild':
|
||||
return { stdout: 'Xcode 16.4\nBuild version 16F6\n' };
|
||||
case 'maestro-runner':
|
||||
return { stdout: 'maestro-runner 1.1.15\n' };
|
||||
default:
|
||||
return { stdout: '' };
|
||||
}
|
||||
}) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner', maestro_version: 'latest' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcodebuild',
|
||||
['-version'],
|
||||
expect.objectContaining({ env: expect.anything() })
|
||||
);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.15');
|
||||
});
|
||||
|
||||
it('installs the latest maestro-runner version on Xcode 26', async () => {
|
||||
let versionChecks = 0;
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 26.0\nBuild version 17A324\n' };
|
||||
}
|
||||
if (command === 'maestro-runner' && versionChecks++ === 0) {
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
}
|
||||
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.1.23\n' : '' };
|
||||
}) as any);
|
||||
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
text: async () => '#!/bin/sh\n',
|
||||
} as Response);
|
||||
const originalPath = process.env.PATH;
|
||||
|
||||
try {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
|
||||
[],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
|
||||
);
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.1.23');
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
});
|
||||
|
||||
it('throws a system error when the Xcode version cannot be determined', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'unexpected output' };
|
||||
}
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
}) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner' },
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
});
|
||||
|
||||
it('rejects a requested maestro-runner version that is incompatible with Xcode', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
if (command === 'xcodebuild') {
|
||||
return { stdout: 'Xcode 16.4\nBuild version 16F6\n' };
|
||||
}
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
}) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
});
|
||||
globalCtx.updateEnv({
|
||||
EAS_BUILD_RUNNER: 'eas-build',
|
||||
HOME: '/home/expo',
|
||||
PATH: '/usr/bin',
|
||||
});
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { backend: 'maestro-runner', maestro_version: '1.1.23' },
|
||||
});
|
||||
|
||||
const executePromise = step.executeAsync();
|
||||
await expect(executePromise).rejects.toThrow(UserError);
|
||||
await expect(executePromise).rejects.toMatchObject({
|
||||
errorCode: 'ERR_MAESTRO_INVALID_INPUT',
|
||||
message:
|
||||
'maestro-runner 1.1.23 is not compatible with Xcode 16.4.0. Use maestro-runner 1.1.15 or an Xcode 26+ image.',
|
||||
});
|
||||
});
|
||||
|
||||
it('installs the requested maestro-runner version', async () => {
|
||||
let versionChecks = 0;
|
||||
mockedSpawn.mockImplementation((async (command: string) => {
|
||||
|
||||
@@ -10,11 +10,92 @@ import {
|
||||
parseFailedFlowNamesFromJUnitFile,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseFailedFlowsFromMaestroRunnerReport,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
parseMaestroRunnerReport,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseFailedFlowsFromMaestroRunnerReport, () => {
|
||||
it('returns exact sourceFile paths for failed flows', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/flows/pass.yml': '',
|
||||
'/project/flows/nested/fail.yml': '',
|
||||
'/reports/attempt-0/report.json': JSON.stringify({
|
||||
flows: [
|
||||
{ name: 'Passing flow', sourceFile: 'flows/pass.yml', status: 'passed' },
|
||||
{
|
||||
name: 'Failing flow',
|
||||
sourceFile: 'flows/nested/fail.yml',
|
||||
status: 'failed',
|
||||
},
|
||||
{ status: 'skipped' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
parseFailedFlowsFromMaestroRunnerReport({
|
||||
reportDirectory: '/reports/attempt-0',
|
||||
workingDirectory: '/project',
|
||||
})
|
||||
).resolves.toEqual(['flows/nested/fail.yml']);
|
||||
await expect(parseMaestroRunnerReport('/reports/attempt-0')).resolves.toEqual({
|
||||
flows: [
|
||||
{ name: 'Passing flow', sourceFile: 'flows/pass.yml', status: 'passed' },
|
||||
{
|
||||
name: 'Failing flow',
|
||||
sourceFile: 'flows/nested/fail.yml',
|
||||
status: 'failed',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'incomplete flow',
|
||||
report: {
|
||||
version: '1.0.0',
|
||||
status: 'failed',
|
||||
summary: { total: 1 },
|
||||
flows: [{ name: 'Failing flow', sourceFile: 'flows/fail.yml', status: 'running' }],
|
||||
},
|
||||
},
|
||||
])('returns null for an $name', async ({ report }) => {
|
||||
vol.fromJSON({
|
||||
'/project/flows/fail.yml': '',
|
||||
'/reports/attempt-0/report.json': JSON.stringify(report),
|
||||
});
|
||||
|
||||
await expect(
|
||||
parseFailedFlowsFromMaestroRunnerReport({
|
||||
reportDirectory: '/reports/attempt-0',
|
||||
workingDirectory: '/project',
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when a failed sourceFile does not exist', async () => {
|
||||
vol.fromJSON({
|
||||
'/reports/attempt-0/report.json': JSON.stringify({
|
||||
version: '1.0.0',
|
||||
status: 'failed',
|
||||
summary: { total: 1 },
|
||||
flows: [{ name: 'Failing flow', sourceFile: 'flows/missing.yml', status: 'failed' }],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
parseFailedFlowsFromMaestroRunnerReport({
|
||||
reportDirectory: '/reports/attempt-0',
|
||||
workingDirectory: '/project',
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFailedFlowNamesFromJUnitFile, () => {
|
||||
it('returns the names of failed testcases, keeping slashes', async () => {
|
||||
vol.fromJSON({
|
||||
@@ -453,6 +534,20 @@ describe('junitFileHasFileAttrs', () => {
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for a maestro-runner report with a file property', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" time="1.0">',
|
||||
' <properties><property name="file" value="flows/a.yaml"/></properties>',
|
||||
' </testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the file cannot be read', async () => {
|
||||
expect(await junitFileHasFileAttrs('/missing.xml')).toBe(false);
|
||||
});
|
||||
@@ -478,6 +573,41 @@ describe(parseJUnitTestCases, () => {
|
||||
expect(results[0].file).toBe('.maestro/login.yaml');
|
||||
});
|
||||
|
||||
it('parses maestro-runner file properties and standard JUnit pass status', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" time="1.0">',
|
||||
' <properties><property name="file" value="flows/login.yaml"/></properties>',
|
||||
' </testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
|
||||
expect(results[0]).toEqual(
|
||||
expect.objectContaining({ file: 'flows/login.yaml', status: 'passed' })
|
||||
);
|
||||
});
|
||||
|
||||
it('excludes skipped testcases instead of counting them as passed', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="passing" time="1.0"/>',
|
||||
' <testcase name="skipped-flow" time="0"><skipped/></testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
|
||||
expect(results.map(r => r.name)).toEqual(['passing']);
|
||||
});
|
||||
|
||||
it('treats a missing or empty file= attribute as undefined', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
|
||||
@@ -3,14 +3,160 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import {
|
||||
type HarvestedScreenshot,
|
||||
computePureFailureFlowNames,
|
||||
harvestFailureScreenshotsAsync,
|
||||
harvestMaestroRunnerFailureScreenshotsAsync,
|
||||
parseFailureScreenshotFilename,
|
||||
selectFailureScreenshots,
|
||||
} from '../maestroScreenshots';
|
||||
|
||||
describe(harvestMaestroRunnerFailureScreenshotsAsync, () => {
|
||||
const logger = createMockLogger();
|
||||
let reportDirectory: string;
|
||||
let captureSpy: jest.SpiedFunction<typeof Sentry.capture>;
|
||||
|
||||
beforeEach(async () => {
|
||||
reportDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'maestro-runner-harvest-test-'));
|
||||
await fs.mkdir(path.join(reportDirectory, 'flows'));
|
||||
await fs.mkdir(path.join(reportDirectory, 'assets', 'flow-000'), { recursive: true });
|
||||
captureSpy = jest.spyOn(Sentry, 'capture').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(reportDirectory, { recursive: true, force: true });
|
||||
captureSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('reads the failed command screenshot from a maestro-runner report bundle', async () => {
|
||||
const capturedSinceMs = Date.now() - 1_000;
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'report.json'),
|
||||
JSON.stringify({
|
||||
flows: [
|
||||
{
|
||||
name: 'authentication/Login',
|
||||
status: 'failed',
|
||||
dataFile: 'flows/flow-000.json',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'flows', 'flow-000.json'),
|
||||
JSON.stringify({
|
||||
commands: [
|
||||
{
|
||||
status: 'failed',
|
||||
artifacts: { screenshotAfter: 'assets/flow-000/cmd-001-after.png' },
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
const screenshotPath = path.join(reportDirectory, 'assets', 'flow-000', 'cmd-001-after.png');
|
||||
await fs.writeFile(screenshotPath, 'png');
|
||||
|
||||
const shots = await harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs,
|
||||
attemptIndex: 1,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([
|
||||
{
|
||||
fileAbsPath: screenshotPath,
|
||||
displayName: 'Failure Screenshot: authentication_Login (attempt 2)',
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: 'authentication_Login',
|
||||
attemptIndex: 1,
|
||||
capturedAtMs: expect.any(Number),
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a screenshot path that resolves to the parent directory (exact "..")', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'report.json'),
|
||||
JSON.stringify({
|
||||
flows: [{ name: 'Login', status: 'failed', dataFile: 'flows/flow-000.json' }],
|
||||
})
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'flows', 'flow-000.json'),
|
||||
JSON.stringify({
|
||||
commands: [{ status: 'failed', artifacts: { screenshotAfter: '..' } }],
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores passing flows and paths outside the report directory', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(reportDirectory, 'report.json'),
|
||||
JSON.stringify({
|
||||
flows: [
|
||||
{ name: 'Passing', status: 'passed', dataFile: 'flows/flow-000.json' },
|
||||
{ name: 'Unsafe', status: 'failed', dataFile: '../outside.json' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', 'null'],
|
||||
['a non-object flows value', JSON.stringify({ flows: 5 })],
|
||||
['a null flow entry', JSON.stringify({ flows: [null] })],
|
||||
])('reports to Sentry and returns [] when report.json is %s', async (_label, contents) => {
|
||||
await fs.writeFile(path.join(reportDirectory, 'report.json'), contents);
|
||||
|
||||
await expect(
|
||||
harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
expect(captureSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns [] without reporting to Sentry when report.json is not valid JSON', async () => {
|
||||
await fs.writeFile(path.join(reportDirectory, 'report.json'), 'not-json');
|
||||
|
||||
await expect(
|
||||
harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory,
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
expect(captureSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFailureScreenshotFilename, () => {
|
||||
it('parses a plain failure screenshot', () => {
|
||||
expect(parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login Flow).png')).toEqual({
|
||||
|
||||
@@ -27,10 +27,14 @@ jest.mock('../../../utils/retry', () => ({
|
||||
jest.mock('../maestroScreenshots', () => ({
|
||||
...jest.requireActual('../maestroScreenshots'),
|
||||
harvestFailureScreenshotsAsync: jest.fn(),
|
||||
harvestMaestroRunnerFailureScreenshotsAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedHarvest = jest.mocked(maestroScreenshots.harvestFailureScreenshotsAsync);
|
||||
const mockedRunnerHarvest = jest.mocked(
|
||||
maestroScreenshots.harvestMaestroRunnerFailureScreenshotsAsync
|
||||
);
|
||||
const mockUploadArtifact = jest.fn();
|
||||
|
||||
function makeShot(index: number): maestroScreenshots.HarvestedScreenshot {
|
||||
@@ -89,6 +93,8 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
mockedHarvest.mockReset();
|
||||
mockedHarvest.mockResolvedValue([]);
|
||||
mockedRunnerHarvest.mockReset();
|
||||
mockedRunnerHarvest.mockResolvedValue([]);
|
||||
mockUploadArtifact.mockReset();
|
||||
mockUploadArtifact.mockResolvedValue({ artifactId: 'artifact-1' });
|
||||
});
|
||||
@@ -137,6 +143,171 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
|
||||
});
|
||||
|
||||
it('runs maestro-runner and collects its JUnit report when selected by input', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const copyFileSpy = jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const junitFailureNamesSpy = jest.spyOn(parser, 'parseFailedFlowNamesFromJUnitFile');
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'ios',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
const [command, args] = mockedSpawn.mock.calls[0];
|
||||
expect(command).toBe('maestro-runner');
|
||||
expect(args).toEqual([
|
||||
'--platform=ios',
|
||||
'test',
|
||||
'--output=/home/expo/.maestro/tests/ios-maestro-runner-attempt-0',
|
||||
'--flatten',
|
||||
'flows/a.yaml',
|
||||
'flows/b.yaml',
|
||||
]);
|
||||
expect(copyFileSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0/junit-report.xml',
|
||||
'/home/expo/.maestro/tests/junit-reports/ios-maestro-junit-attempt-0.xml'
|
||||
);
|
||||
expect(mockedHarvest).not.toHaveBeenCalled();
|
||||
expect(junitFailureNamesSpy).not.toHaveBeenCalled();
|
||||
expect(mockedRunnerHarvest).toHaveBeenCalledWith({
|
||||
reportDirectory: '/home/expo/.maestro/tests/ios-maestro-runner-attempt-0',
|
||||
capturedSinceMs: expect.any(Number),
|
||||
attemptIndex: 0,
|
||||
logger: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('clears the deterministic maestro-runner output directory before each attempt', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const rmSpy = jest.spyOn(fs, 'rm').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'junit',
|
||||
platform: 'ios',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(rmSpy).toHaveBeenCalledWith('/home/expo/.maestro/tests/ios-maestro-runner-attempt-0', {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
// Cleared before the run, so a crash before fresh output can't resurrect stale results.
|
||||
expect(rmSpy.mock.invocationCallOrder[0]).toBeLessThan(mockedSpawn.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('selects maestro-runner from EAS_MAESTRO_BACKEND', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep(
|
||||
{ flow_path: ['flows/a.yaml'], platform: 'android' },
|
||||
{ env: { HOME: '/home/expo', EAS_MAESTRO_BACKEND: 'maestro-runner' } }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro-runner');
|
||||
});
|
||||
|
||||
it('prefers the backend input over EAS_MAESTRO_BACKEND', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const step = createStep(
|
||||
{ flow_path: ['flows/a.yaml'], platform: 'android', backend: 'maestro' },
|
||||
{ env: { HOME: '/home/expo', EAS_MAESTRO_BACKEND: 'maestro-runner' } }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro');
|
||||
});
|
||||
|
||||
it('passes tags to maestro-runner', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
include_tags: 'smoke',
|
||||
exclude_tags: 'slow',
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['--include-tags=smoke', '--exclude-tags=slow'])
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid backend values', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'other',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects sharding with maestro-runner', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
shards: 2,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner does not support EAS Maestro test sharding'
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-junit output_format with maestro-runner', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'html',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner only supports the "junit" output_format'
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs that maestro-runner does not support direct DADB', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
android_connection_mode: 'dadb',
|
||||
},
|
||||
{ env: { HOME: '/home/expo', PATH: '/usr/bin' }, logger }
|
||||
);
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSpawn.mock.calls[0][0]).toBe('maestro-runner');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'maestro-runner does not support DADB. Using the default ADB connection.'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses direct DADB when android_connection_mode is dadb without cleanup', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const writeFileSpy = jest.spyOn(fs, 'writeFile');
|
||||
@@ -453,6 +624,36 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(a1Args).not.toContain('flows/c.yaml');
|
||||
});
|
||||
|
||||
it('uses maestro-runner report.json for junit retries', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const runnerReportSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromMaestroRunnerReport')
|
||||
.mockResolvedValue(['flows/b.yaml']);
|
||||
const junitParseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
platform: 'ios',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(runnerReportSpy).toHaveBeenCalledWith({
|
||||
reportDirectory: '/home/expo/.maestro/tests/ios-maestro-runner-attempt-0',
|
||||
workingDirectory: expect.any(String),
|
||||
});
|
||||
expect(junitParseSpy).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml'])
|
||||
);
|
||||
const retryArgs = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(retryArgs).toContain('flows/b.yaml');
|
||||
expect(retryArgs).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('retries all flows when parseFailedFlowsFromJUnit returns null', async () => {
|
||||
const spawnMock = mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
@@ -1014,6 +1215,29 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('discovers maestro-runner flow results from the attempt report directory', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const shot = makeShot(0);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
mockedRunnerHarvest.mockResolvedValue([shot]);
|
||||
const parseFlowResultsSpy = jest.spyOn(parser, 'parseMaestroRunnerReport').mockResolvedValue({
|
||||
flows: [{ name: 'Login', sourceFile: 'a.yaml', status: 'failed' }],
|
||||
});
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
platform: 'ios',
|
||||
output_format: 'junit',
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
|
||||
);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const shot = makeShot(0);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync } from '../saveCache';
|
||||
import { decompressCacheAsync } from '../restoreCache';
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
describe('cache compress/decompress round trip', () => {
|
||||
it('preserves multiple sibling directories archived together', async () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const cachesDir = path.join(os.tmpdir(), 'gradle-caches');
|
||||
const buildCacheDir = path.join(cachesDir, 'build-cache-1');
|
||||
const journalDir = path.join(cachesDir, 'journal-1');
|
||||
await fs.promises.mkdir(buildCacheDir, { recursive: true });
|
||||
await fs.promises.mkdir(journalDir, { recursive: true });
|
||||
await fs.promises.writeFile(path.join(buildCacheDir, 'entry'), 'cache entry');
|
||||
await fs.promises.writeFile(path.join(journalDir, 'file-access.properties'), 'inception=1');
|
||||
|
||||
const { archivePath } = await compressCacheAsync({
|
||||
paths: [buildCacheDir, journalDir],
|
||||
workingDirectory: cachesDir,
|
||||
verbose: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
const restoreDir = path.join(os.tmpdir(), 'gradle-caches-restored');
|
||||
await fs.promises.mkdir(restoreDir, { recursive: true });
|
||||
await decompressCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory: restoreDir,
|
||||
verbose: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(
|
||||
await fs.promises.readFile(path.join(restoreDir, 'build-cache-1', 'entry'), 'utf8')
|
||||
).toBe('cache entry');
|
||||
expect(
|
||||
await fs.promises.readFile(
|
||||
path.join(restoreDir, 'journal-1', 'file-access.properties'),
|
||||
'utf8'
|
||||
)
|
||||
).toBe('inception=1');
|
||||
});
|
||||
|
||||
it('preserves file modification times so journal-fallback entries can still age out', async () => {
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const sourceDir = path.join(os.tmpdir(), 'mtime-source');
|
||||
await fs.promises.mkdir(sourceDir, { recursive: true });
|
||||
const filePath = path.join(sourceDir, 'entry');
|
||||
await fs.promises.writeFile(filePath, 'cache entry');
|
||||
|
||||
// A whole-second value in the past — tar stores mtimes at one-second resolution.
|
||||
const mtime = new Date('2026-08-01T00:00:00.000Z');
|
||||
await fs.promises.utimes(filePath, mtime, mtime);
|
||||
|
||||
const { archivePath } = await compressCacheAsync({
|
||||
paths: [sourceDir],
|
||||
workingDirectory: sourceDir,
|
||||
verbose: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
const restoreDir = path.join(os.tmpdir(), 'mtime-restored');
|
||||
await fs.promises.mkdir(restoreDir, { recursive: true });
|
||||
await decompressCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory: restoreDir,
|
||||
verbose: false,
|
||||
logger,
|
||||
});
|
||||
|
||||
const restoredStat = await fs.promises.stat(path.join(restoreDir, 'entry'));
|
||||
expect(Math.floor(restoredStat.mtimeMs / 1000)).toBe(Math.floor(mtime.getTime() / 1000));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import { resolveAppium3VersionSpec, resolveAppiumDeviceAsync } from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as never;
|
||||
|
||||
describe(resolveAppium3VersionSpec, () => {
|
||||
it('uses the worker-supported Appium 3 version by default', () => {
|
||||
expect(resolveAppium3VersionSpec(undefined)).toBe('^3');
|
||||
});
|
||||
|
||||
it.each(['3', '^3', '3.x', '3.5.0', '>=3 <4'])('accepts Appium 3 version %s', version => {
|
||||
expect(resolveAppium3VersionSpec(version)).toBe(version);
|
||||
});
|
||||
|
||||
it.each(['2', '2.19.0', 'latest', '4.0.0', '>=3'])('rejects version %s', version => {
|
||||
expect(() => resolveAppium3VersionSpec(version)).toThrow(
|
||||
`Appium 3 is required for EAS Simulator sessions. Received package version "${version}".`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveAppiumDeviceAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns XCUITest capabilities for the booted iOS simulator', async () => {
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'ios-simulator-id' } as never]);
|
||||
|
||||
await expect(
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: 'ios-simulator-id',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger });
|
||||
expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({
|
||||
env: {},
|
||||
filter: 'booted',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns UiAutomator2 capabilities for the booted Android emulator', async () => {
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
|
||||
|
||||
await expect(
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'Android',
|
||||
automationName: 'UiAutomator2',
|
||||
driverName: 'uiautomator2',
|
||||
udid: 'emulator-5554',
|
||||
});
|
||||
expect(AndroidEmulatorUtils.getAttachedDevicesAsync).toHaveBeenCalledWith({ env: {} });
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import {
|
||||
XCODE_COMPILATION_CACHE_ENV,
|
||||
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
|
||||
compressXcodeCompilationCacheAsync,
|
||||
decompressXcodeCompilationCacheAsync,
|
||||
generateXcodeCompilationCacheKeyAsync,
|
||||
} from '../../../ios/compilationCache';
|
||||
import { downloadCacheAsync } from '../restoreCache';
|
||||
import { restoreXcodeCompilationCacheAsync } from '../restoreBuildCache';
|
||||
import { uploadCacheAsync } from '../saveCache';
|
||||
import { saveXcodeCompilationCacheAsync } from '../saveBuildCache';
|
||||
|
||||
jest.mock('../restoreCache', () => ({
|
||||
downloadCacheAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../saveCache', () => ({
|
||||
uploadCacheAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../ios/compilationCache', () => ({
|
||||
XCODE_COMPILATION_CACHE_ENV: 'EAS_BUILD_XCODE_COMPILATION_CACHE',
|
||||
XCODE_COMPILATION_CACHE_RELATIVE_PATH: 'ios/build/CompilationCache.noindex',
|
||||
compressXcodeCompilationCacheAsync: jest.fn(),
|
||||
decompressXcodeCompilationCacheAsync: jest.fn(),
|
||||
generateXcodeCompilationCacheKeyAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const WORKING_DIRECTORY = '/working-directory';
|
||||
const ENV = {
|
||||
[XCODE_COMPILATION_CACHE_ENV]: '1',
|
||||
EAS_USE_CACHE: '1',
|
||||
EAS_BUILD_ID: 'build-id',
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
};
|
||||
const SECRETS = { robotAccessToken: 'robot-access-token' };
|
||||
|
||||
describe('Xcode compilation build cache', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(generateXcodeCompilationCacheKeyAsync).mockResolvedValue({
|
||||
key: 'ios-xcode-compilation-cache-version-dependencies',
|
||||
keyPrefix: 'ios-xcode-compilation-cache-version-',
|
||||
xcodeVersion: 'Xcode 26.0 Build version 17A000',
|
||||
});
|
||||
});
|
||||
|
||||
test('restores through the active build cache service', async () => {
|
||||
jest.mocked(downloadCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/cache.tar.gz',
|
||||
matchedKey: 'ios-xcode-compilation-cache-version-dependencies',
|
||||
});
|
||||
|
||||
await restoreXcodeCompilationCacheAsync({
|
||||
logger: createMockLogger(),
|
||||
workingDirectory: WORKING_DIRECTORY,
|
||||
env: ENV,
|
||||
secrets: SECRETS,
|
||||
});
|
||||
|
||||
expect(downloadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
jobId: 'build-id',
|
||||
key: 'ios-xcode-compilation-cache-version-dependencies',
|
||||
keyPrefixes: ['ios-xcode-compilation-cache-version-'],
|
||||
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
|
||||
platform: Platform.IOS,
|
||||
})
|
||||
);
|
||||
expect(decompressXcodeCompilationCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
archivePath: '/tmp/cache.tar.gz',
|
||||
workingDirectory: WORKING_DIRECTORY,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('saves through the active build cache service', async () => {
|
||||
vol.fromJSON({ '/tmp/xcode-cache.tar.gz': 'cache' });
|
||||
jest.mocked(compressXcodeCompilationCacheAsync).mockResolvedValue({
|
||||
archivePath: '/tmp/xcode-cache.tar.gz',
|
||||
});
|
||||
|
||||
await saveXcodeCompilationCacheAsync({
|
||||
logger: createMockLogger(),
|
||||
workingDirectory: WORKING_DIRECTORY,
|
||||
env: ENV,
|
||||
secrets: SECRETS,
|
||||
});
|
||||
|
||||
expect(compressXcodeCompilationCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workingDirectory: WORKING_DIRECTORY,
|
||||
})
|
||||
);
|
||||
|
||||
expect(uploadCacheAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
jobId: 'build-id',
|
||||
key: 'ios-xcode-compilation-cache-version-dependencies',
|
||||
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
|
||||
platform: Platform.IOS,
|
||||
size: 5,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import assert from 'assert';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
|
||||
import { Datadog } from '../../datadog';
|
||||
@@ -120,19 +122,64 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
await installIdbFromBrew({ logger, env });
|
||||
}
|
||||
|
||||
// Skip installing if the input sets a specific Maestro version to install
|
||||
// and it is already installed which happens when developing on a local computer.
|
||||
if (
|
||||
!currentMaestroVersion ||
|
||||
(requestedVersion && requestedVersion !== currentMaestroVersion)
|
||||
) {
|
||||
switch (backend) {
|
||||
case 'maestro':
|
||||
switch (backend) {
|
||||
case 'maestro':
|
||||
// Skip installing if the input sets a specific Maestro version to install
|
||||
// and it is already installed, either on a build image or a local computer.
|
||||
if (
|
||||
!currentMaestroVersion ||
|
||||
(requestedVersion && requestedVersion !== currentMaestroVersion)
|
||||
) {
|
||||
await installMaestro({ version: requestedVersion, global, logger, env });
|
||||
break;
|
||||
case 'maestro-runner':
|
||||
await installMaestroRunner({ version: requestedVersion, global, logger, env });
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 'maestro-runner': {
|
||||
let maestroRunnerVersionToInstall = requestedVersion;
|
||||
const currentMaestroRunnerVersion = semver.coerce(currentMaestroVersion)?.version;
|
||||
const requestedMaestroRunnerVersion =
|
||||
requestedVersion && requestedVersion !== 'latest'
|
||||
? semver.valid(requestedVersion)
|
||||
: null;
|
||||
if (
|
||||
global.runtimePlatform === BuildRuntimePlatform.DARWIN &&
|
||||
((requestedMaestroRunnerVersion &&
|
||||
semver.gte(requestedMaestroRunnerVersion, '1.1.16')) ||
|
||||
requestedVersion === 'latest' ||
|
||||
(requestedVersion === undefined &&
|
||||
(!currentMaestroRunnerVersion || semver.gt(currentMaestroRunnerVersion, '1.1.15'))))
|
||||
) {
|
||||
const xcodeVersion = await getXcodeVersion({ env });
|
||||
|
||||
// maestro-runner 1.1.16 added `arch` to its xcodebuild destination. Xcode versions
|
||||
// below 26 reject that option, so use the last compatible maestro-runner version.
|
||||
if (semver.lt(xcodeVersion, '26.0.0')) {
|
||||
if (requestedMaestroRunnerVersion) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
`maestro-runner ${requestedVersion} is not compatible with Xcode ${xcodeVersion}. Use maestro-runner 1.1.15 or an Xcode 26+ image.`
|
||||
);
|
||||
}
|
||||
maestroRunnerVersionToInstall = '1.1.15';
|
||||
logger.info(
|
||||
`Xcode ${xcodeVersion} requires maestro-runner ${maestroRunnerVersionToInstall}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!currentMaestroVersion ||
|
||||
(maestroRunnerVersionToInstall &&
|
||||
maestroRunnerVersionToInstall !== currentMaestroVersion)
|
||||
) {
|
||||
await installMaestroRunner({
|
||||
version: maestroRunnerVersionToInstall,
|
||||
global,
|
||||
logger,
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +201,21 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
async function getXcodeVersion({ env }: { env: BuildStepEnv }): Promise<string> {
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = await spawn('xcodebuild', ['-version'], { stdio: 'pipe', env }));
|
||||
} catch (error) {
|
||||
throw new SystemError('Failed to get Xcode version', { cause: error });
|
||||
}
|
||||
|
||||
const xcodeVersion = semver.coerce(/^Xcode\s+(\S+)/m.exec(stdout)?.[1])?.version;
|
||||
if (!xcodeVersion) {
|
||||
throw new SystemError(`Failed to parse Xcode version from xcodebuild output: ${stdout.trim()}`);
|
||||
}
|
||||
return xcodeVersion;
|
||||
}
|
||||
|
||||
async function getMaestroVersion({
|
||||
env,
|
||||
backend,
|
||||
@@ -211,10 +273,10 @@ async function installMaestroRunner({
|
||||
const binDir = path.join(env.HOME, '.maestro-runner', 'bin');
|
||||
global.updateEnv({
|
||||
...global.env,
|
||||
PATH: `${global.env.PATH}:${binDir}`,
|
||||
PATH: `${binDir}:${global.env.PATH}`,
|
||||
});
|
||||
env.PATH = `${env.PATH}:${binDir}`;
|
||||
process.env.PATH = `${process.env.PATH}:${binDir}`;
|
||||
env.PATH = `${binDir}:${env.PATH}`;
|
||||
process.env.PATH = `${binDir}:${process.env.PATH}`;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
@@ -261,10 +323,10 @@ async function installMaestro({
|
||||
const maestroBinDir = path.join(maestroDir, 'bin');
|
||||
global.updateEnv({
|
||||
...global.env,
|
||||
PATH: `${global.env.PATH}:${maestroBinDir}`,
|
||||
PATH: `${maestroBinDir}:${global.env.PATH}`,
|
||||
});
|
||||
env.PATH = `${env.PATH}:${maestroBinDir}`;
|
||||
process.env.PATH = `${process.env.PATH}:${maestroBinDir}`;
|
||||
env.PATH = `${maestroBinDir}:${env.PATH}`;
|
||||
process.env.PATH = `${maestroBinDir}:${process.env.PATH}`;
|
||||
} finally {
|
||||
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
|
||||
}
|
||||
@@ -272,37 +334,64 @@ async function installMaestro({
|
||||
|
||||
async function isIdbInstalled({ env }: { env: BuildStepEnv }): Promise<boolean> {
|
||||
try {
|
||||
await spawn('idb', ['-h'], { ignoreStdio: true, env });
|
||||
await spawn('idb_companion', ['--version'], { ignoreStdio: true, env });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function installIdbFromBrew({
|
||||
export async function installIdbFromBrew({
|
||||
logger,
|
||||
env,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
// Unfortunately our Mac images sometimes have two Homebrew
|
||||
// installations. We should use the ARM64 one, located in /opt/homebrew.
|
||||
const brewPath = '/opt/homebrew/bin/brew';
|
||||
const localEnv = {
|
||||
...env,
|
||||
HOMEBREW_NO_AUTO_UPDATE: '1',
|
||||
HOMEBREW_NO_INSTALL_CLEANUP: '1',
|
||||
};
|
||||
try {
|
||||
// Unfortunately our Mac images sometimes have two Homebrew
|
||||
// installations. We should use the ARM64 one, located in /opt/homebrew.
|
||||
const brewPath = '/opt/homebrew/bin/brew';
|
||||
const localEnv = {
|
||||
...env,
|
||||
HOMEBREW_NO_AUTO_UPDATE: '1',
|
||||
HOMEBREW_NO_INSTALL_CLEANUP: '1',
|
||||
};
|
||||
|
||||
await spawn(brewPath, ['tap', 'facebook/fb'], {
|
||||
env: localEnv,
|
||||
logger,
|
||||
});
|
||||
await spawn(brewPath, ['install', 'idb-companion'], {
|
||||
env: localEnv,
|
||||
logger,
|
||||
});
|
||||
logger.info('Tapping facebook/fb...');
|
||||
await spawn(brewPath, ['tap', 'facebook/fb'], {
|
||||
env: localEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
const brewRepo = await spawn(brewPath, ['--repo', 'facebook/fb'], {
|
||||
env: localEnv,
|
||||
});
|
||||
const tapPath = brewRepo.stdout.trim();
|
||||
|
||||
// c0386793f59da10c619787f2aa18d938ef1d69c9 is hash for 1.1.8 release,
|
||||
// last known compatible version + post_install fix.
|
||||
const gitSha = 'c0386793f59da10c619787f2aa18d938ef1d69c9';
|
||||
logger.info('Checking out facebook/fb at [email protected]...');
|
||||
await spawn('git', ['fetch', 'origin', gitSha], {
|
||||
cwd: tapPath,
|
||||
logger,
|
||||
});
|
||||
await spawn('git', ['checkout', gitSha], {
|
||||
cwd: tapPath,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Installing idb_companion v1.1.8...');
|
||||
await spawn(brewPath, ['install', 'facebook/fb/idb-companion'], {
|
||||
env: localEnv,
|
||||
logger,
|
||||
});
|
||||
} catch (err) {
|
||||
throw new SystemError('Failed to install idb-companion required for Maestro to run.', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function isJavaInstalled({ env }: { env: BuildStepEnv }): Promise<boolean> {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { asyncResult } from '@expo/results';
|
||||
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface MaestroFlowResult {
|
||||
name: string;
|
||||
@@ -34,10 +35,16 @@ const xmlParser = new XMLParser({
|
||||
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
||||
});
|
||||
|
||||
// A `file=` attribute counts as present only when it is a non-empty string.
|
||||
// Official Maestro writes the flow path as a `file=` testcase attribute. maestro-runner writes
|
||||
// the same value as a `<property name="file" value="..."/>` child.
|
||||
function fileAttrOf(tc: any): string | undefined {
|
||||
const f = tc?.['@_file'];
|
||||
return typeof f === 'string' && f.length > 0 ? f : undefined;
|
||||
if (typeof f === 'string' && f.length > 0) {
|
||||
return f;
|
||||
}
|
||||
const properties: { '@_name'?: unknown; '@_value'?: unknown }[] = tc?.properties?.property ?? [];
|
||||
const fileProperty = properties.find(property => property['@_name'] === 'file')?.['@_value'];
|
||||
return typeof fileProperty === 'string' && fileProperty.length > 0 ? fileProperty : undefined;
|
||||
}
|
||||
|
||||
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
@@ -62,26 +69,46 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Standard JUnit marks skipped tests with a <skipped/> child (no failure/error). Exclude
|
||||
// them so they aren't miscounted as passed, matching the report.json path which drops
|
||||
// skipped flows.
|
||||
if (tc.skipped != null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = fileAttrOf(tc);
|
||||
|
||||
const timeStr = tc['@_time'];
|
||||
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
|
||||
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
|
||||
|
||||
const status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
|
||||
// maestro-runner puts the real error in the `message` attribute and the command
|
||||
// label (e.g. `tapOn`) in the body; official Maestro only writes the body. Prefer
|
||||
// `@_message` and fall back to `#text` so both stay correct.
|
||||
const failureText =
|
||||
tc.failure != null
|
||||
? typeof tc.failure === 'string'
|
||||
? tc.failure
|
||||
: (tc.failure?.['#text'] ?? null)
|
||||
: (tc.failure?.['@_message'] ?? tc.failure?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorText =
|
||||
tc.error != null
|
||||
? typeof tc.error === 'string'
|
||||
? tc.error
|
||||
: (tc.error?.['#text'] ?? null)
|
||||
: (tc.error?.['@_message'] ?? tc.error?.['#text'] ?? null)
|
||||
: null;
|
||||
const errorMessage: string | null = failureText ?? errorText ?? null;
|
||||
// Official Maestro uses status="SUCCESS". maestro-runner uses standard JUnit semantics:
|
||||
// a testcase passes when it has no failure or error child.
|
||||
const statusAttribute = tc['@_status'];
|
||||
const status: 'passed' | 'failed' =
|
||||
typeof statusAttribute === 'string'
|
||||
? statusAttribute === 'SUCCESS'
|
||||
? 'passed'
|
||||
: 'failed'
|
||||
: tc.failure == null && tc.error == null
|
||||
? 'passed'
|
||||
: 'failed';
|
||||
|
||||
const rawProperties: { '@_name': string; '@_value': string }[] =
|
||||
tc.properties?.property ?? [];
|
||||
@@ -176,6 +203,69 @@ async function fileExists(absPath: string): Promise<boolean> {
|
||||
return (await asyncResult(fs.stat(absPath))).ok;
|
||||
}
|
||||
|
||||
// maestro-runner writes report.json as the source of truth for a run. Use it for runner control
|
||||
// flow because sourceFile preserves the exact flow path, while older runner JUnit reports flatten
|
||||
// it to a basename. Keep only passed and failed flows so the result matches Maestro JUnit reports,
|
||||
// which do not include skipped flows.
|
||||
const MaestroRunnerRecordedFlowSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
sourceFile: z.string().min(1),
|
||||
status: z.enum(['passed', 'failed']),
|
||||
});
|
||||
const MaestroRunnerReportSchema = z.object({
|
||||
flows: z
|
||||
.array(
|
||||
z.discriminatedUnion('status', [
|
||||
MaestroRunnerRecordedFlowSchema,
|
||||
z.object({ status: z.literal('skipped') }),
|
||||
])
|
||||
)
|
||||
.transform(flows =>
|
||||
flows.filter(
|
||||
(flow): flow is z.infer<typeof MaestroRunnerRecordedFlowSchema> => flow.status !== 'skipped'
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
type MaestroRunnerReport = z.infer<typeof MaestroRunnerReportSchema>;
|
||||
|
||||
export async function parseFailedFlowsFromMaestroRunnerReport(args: {
|
||||
reportDirectory: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<string[] | null> {
|
||||
const report = await parseMaestroRunnerReport(args.reportDirectory);
|
||||
if (report === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failedPaths = [
|
||||
...new Set(report.flows.filter(flow => flow.status === 'failed').map(flow => flow.sourceFile)),
|
||||
];
|
||||
if (failedPaths.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const flowPath of failedPaths) {
|
||||
if (!(await fileExists(path.resolve(args.workingDirectory, flowPath)))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return failedPaths;
|
||||
}
|
||||
|
||||
export async function parseMaestroRunnerReport(
|
||||
reportDirectory: string
|
||||
): Promise<MaestroRunnerReport | null> {
|
||||
let report: unknown;
|
||||
try {
|
||||
report = JSON.parse(await fs.readFile(path.join(reportDirectory, 'report.json'), 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const result = MaestroRunnerReportSchema.safeParse(report);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
// Group by `file=` so two same-named flows in different files stay separate.
|
||||
export async function parseMaestroResultsFromFileAttrs(
|
||||
junitDirectory: string
|
||||
|
||||
@@ -2,6 +2,9 @@ import { bunyan } from '@expo/logger';
|
||||
import { type Dirent } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
|
||||
export interface ParsedFailureScreenshot {
|
||||
flowName: string;
|
||||
@@ -133,6 +136,156 @@ export async function harvestFailureScreenshotsAsync(args: {
|
||||
return [...legacyShots, ...dedupeBundleShotsByFlowName(bundleShots)];
|
||||
}
|
||||
|
||||
const MaestroRunnerCommandSchema = z.object({
|
||||
status: z.string().optional(),
|
||||
artifacts: z
|
||||
.object({
|
||||
screenshotBefore: z.string().optional(),
|
||||
screenshotAfter: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
get subCommands() {
|
||||
return z.array(MaestroRunnerCommandSchema).optional();
|
||||
},
|
||||
});
|
||||
type MaestroRunnerCommand = z.infer<typeof MaestroRunnerCommandSchema>;
|
||||
|
||||
const MaestroRunnerFlowDetailSchema = z.object({
|
||||
commands: z.array(MaestroRunnerCommandSchema).optional(),
|
||||
});
|
||||
|
||||
const MaestroRunnerScreenshotReportSchema = z.object({
|
||||
flows: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
dataFile: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// maestro-runner records failure screenshot paths in its report JSON instead of using the
|
||||
// official Maestro debug-directory layout. Never throws, so screenshots cannot change the test
|
||||
// result.
|
||||
export async function harvestMaestroRunnerFailureScreenshotsAsync(args: {
|
||||
reportDirectory: string;
|
||||
capturedSinceMs: number;
|
||||
attemptIndex: number;
|
||||
logger: bunyan;
|
||||
}): Promise<HarvestedScreenshot[]> {
|
||||
let report: unknown;
|
||||
try {
|
||||
report = JSON.parse(await fs.readFile(path.join(args.reportDirectory, 'report.json'), 'utf8'));
|
||||
} catch (err: any) {
|
||||
args.logger.info(
|
||||
{ err },
|
||||
`Skipping maestro-runner screenshot harvest: cannot read ${args.reportDirectory}.`
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsedReport = MaestroRunnerScreenshotReportSchema.safeParse(report);
|
||||
if (!parsedReport.success) {
|
||||
args.logger.warn(
|
||||
{ err: parsedReport.error },
|
||||
`Skipping maestro-runner screenshot harvest: unexpected report.json shape in ${args.reportDirectory}.`
|
||||
);
|
||||
Sentry.capture('maestro-runner report.json failed schema validation', parsedReport.error);
|
||||
return [];
|
||||
}
|
||||
const flows = parsedReport.data.flows ?? [];
|
||||
|
||||
const shots: HarvestedScreenshot[] = [];
|
||||
for (const { name, status, dataFile } of flows) {
|
||||
if (status !== 'failed' || !name || !dataFile) {
|
||||
continue;
|
||||
}
|
||||
const flowDataPath = resolvePathInsideDirectory(args.reportDirectory, dataFile);
|
||||
if (!flowDataPath) {
|
||||
args.logger.info(`Skipping maestro-runner flow data outside the report directory.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let screenshotPath: string | undefined;
|
||||
try {
|
||||
const detail = MaestroRunnerFlowDetailSchema.safeParse(
|
||||
JSON.parse(await fs.readFile(flowDataPath, 'utf8'))
|
||||
);
|
||||
if (!detail.success) {
|
||||
args.logger.warn(
|
||||
{ err: detail.error },
|
||||
`Skipping malformed maestro-runner flow data ${flowDataPath}.`
|
||||
);
|
||||
Sentry.capture('maestro-runner flow data failed schema validation', detail.error);
|
||||
continue;
|
||||
}
|
||||
screenshotPath = findFailedMaestroRunnerScreenshot(detail.data.commands ?? []);
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping unreadable maestro-runner flow data ${flowDataPath}.`);
|
||||
continue;
|
||||
}
|
||||
if (!screenshotPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileAbsPath = resolvePathInsideDirectory(args.reportDirectory, screenshotPath);
|
||||
if (!fileAbsPath) {
|
||||
args.logger.info(`Skipping maestro-runner screenshot outside the report directory.`);
|
||||
continue;
|
||||
}
|
||||
let capturedAtMs: number;
|
||||
try {
|
||||
capturedAtMs = Math.round((await fs.stat(fileAbsPath)).mtimeMs);
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping unreadable screenshot ${fileAbsPath}.`);
|
||||
continue;
|
||||
}
|
||||
if (capturedAtMs < args.capturedSinceMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const flowName = normalizeFlowName(name);
|
||||
shots.push({
|
||||
fileAbsPath,
|
||||
displayName: `Failure Screenshot: ${flowName} (attempt ${args.attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName,
|
||||
attemptIndex: args.attemptIndex,
|
||||
capturedAtMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
return shots;
|
||||
}
|
||||
|
||||
function findFailedMaestroRunnerScreenshot(
|
||||
commands: readonly MaestroRunnerCommand[]
|
||||
): string | undefined {
|
||||
for (const command of commands) {
|
||||
if (command.status !== 'failed') {
|
||||
continue;
|
||||
}
|
||||
const nested = findFailedMaestroRunnerScreenshot(command.subCommands ?? []);
|
||||
const screenshot =
|
||||
nested ?? command.artifacts?.screenshotAfter ?? command.artifacts?.screenshotBefore;
|
||||
if (screenshot) {
|
||||
return screenshot;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolvePathInsideDirectory(directory: string, relativePath: string): string | null {
|
||||
const candidate = path.resolve(directory, relativePath);
|
||||
const relative = path.relative(directory, candidate);
|
||||
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)
|
||||
? candidate
|
||||
: null;
|
||||
}
|
||||
|
||||
// Maestro >= 2.7.0: resolve a bundle dir to its owning failed flow and return that flow's failure
|
||||
// screenshot — the highest-numbered step in `screenshots/`. A required failure halts the flow, so
|
||||
// the failed step is normally the last (highest-numbered) captured step; earlier warned steps have
|
||||
|
||||
@@ -13,6 +13,7 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
@@ -21,12 +22,15 @@ import {
|
||||
parseFailedFlowNamesFromJUnitFile,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseFailedFlowsFromMaestroRunnerReport,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroRunnerReport,
|
||||
} from './maestroResultParser';
|
||||
import {
|
||||
type HarvestedScreenshot,
|
||||
computePureFailureFlowNames,
|
||||
harvestFailureScreenshotsAsync,
|
||||
harvestMaestroRunnerFailureScreenshotsAsync,
|
||||
selectFailureScreenshots,
|
||||
} from './maestroScreenshots';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -62,35 +66,58 @@ function isFilesystemError(err: any): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
|
||||
// other declared formats pass an explicit path so downstream upload steps
|
||||
// know where to find the result.
|
||||
function buildMaestroArgs(args: {
|
||||
flow_path: string[];
|
||||
outputPath: string | null;
|
||||
output_format: string | undefined;
|
||||
function buildMaestroArgs({
|
||||
backend,
|
||||
platform,
|
||||
flowPaths,
|
||||
output,
|
||||
outputFormat,
|
||||
shards,
|
||||
includeTags,
|
||||
excludeTags,
|
||||
}: {
|
||||
backend: MaestroBackend;
|
||||
platform: 'ios' | 'android';
|
||||
flowPaths: string[];
|
||||
output: string | null;
|
||||
outputFormat: string | undefined;
|
||||
shards: number | undefined;
|
||||
include_tags: string | undefined;
|
||||
exclude_tags: string | undefined;
|
||||
}): string[] {
|
||||
const out: string[] = ['test'];
|
||||
if (args.output_format) {
|
||||
out.push(`--format=${args.output_format.toUpperCase()}`);
|
||||
includeTags: string | undefined;
|
||||
excludeTags: string | undefined;
|
||||
}): { executable: MaestroBackend; args: string[] } {
|
||||
switch (backend) {
|
||||
case 'maestro': {
|
||||
const args = ['test'];
|
||||
if (outputFormat) {
|
||||
args.push(`--format=${outputFormat.toUpperCase()}`);
|
||||
}
|
||||
if (output) {
|
||||
args.push(`--output=${output}`);
|
||||
}
|
||||
if (shards !== undefined) {
|
||||
args.push(`--shard-split=${shards}`);
|
||||
}
|
||||
if (includeTags) {
|
||||
args.push(`--include-tags=${includeTags}`);
|
||||
}
|
||||
if (excludeTags) {
|
||||
args.push(`--exclude-tags=${excludeTags}`);
|
||||
}
|
||||
args.push(...flowPaths);
|
||||
return { executable: 'maestro', args };
|
||||
}
|
||||
case 'maestro-runner': {
|
||||
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
|
||||
if (includeTags) {
|
||||
args.push(`--include-tags=${includeTags}`);
|
||||
}
|
||||
if (excludeTags) {
|
||||
args.push(`--exclude-tags=${excludeTags}`);
|
||||
}
|
||||
args.push(...flowPaths);
|
||||
return { executable: 'maestro-runner', args };
|
||||
}
|
||||
}
|
||||
if (args.outputPath) {
|
||||
out.push(`--output=${args.outputPath}`);
|
||||
}
|
||||
if (args.shards !== undefined) {
|
||||
out.push(`--shard-split=${args.shards}`);
|
||||
}
|
||||
if (args.include_tags) {
|
||||
out.push(`--include-tags=${args.include_tags}`);
|
||||
}
|
||||
if (args.exclude_tags) {
|
||||
out.push(`--exclude-tags=${args.exclude_tags}`);
|
||||
}
|
||||
out.push(...args.flow_path);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
@@ -149,6 +176,11 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'backend',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
|
||||
@@ -197,6 +229,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
outputs.final_report_path.set(finalReportPath);
|
||||
}
|
||||
|
||||
// Resolved after the output assignments above: an invalid backend input or
|
||||
// EAS_MAESTRO_BACKEND throws, and downstream `if: always()` upload steps still
|
||||
// need the outputs interpolated.
|
||||
const backend = resolveMaestroBackend({
|
||||
input: inputs.backend.value,
|
||||
env,
|
||||
});
|
||||
|
||||
const flowPaths = parseInput(
|
||||
FlowPathSchema,
|
||||
inputs.flow_path.value,
|
||||
@@ -219,6 +259,18 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
undefined,
|
||||
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
|
||||
);
|
||||
if (backend === 'maestro-runner' && shards !== undefined && shards > 1) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
'maestro-runner does not support EAS Maestro test sharding. Remove shards or set it to 1.'
|
||||
);
|
||||
}
|
||||
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
`maestro-runner only supports the "junit" output_format, but received "${outputFormat}".`
|
||||
);
|
||||
}
|
||||
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
|
||||
|
||||
try {
|
||||
@@ -227,7 +279,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
throw new SystemError('Failed to create JUnit report directory', { cause: err });
|
||||
}
|
||||
|
||||
// Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
|
||||
// Official Maestro legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
|
||||
// flow scan is built lazily in the retry branch below and memoized so
|
||||
// retries share one scan. Never runs when the report has `file=`.
|
||||
let nameToPathPromise: Promise<Map<string, string> | null> | undefined;
|
||||
@@ -237,15 +289,23 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// numeric err.status → maestro exited non-zero → retry.
|
||||
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
||||
// downgraded to "tests failed".
|
||||
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
|
||||
// flows. The failed-flow parsers return null when the JUnit cannot be
|
||||
// trusted; we then fall through to dumb retry (re-run everything).
|
||||
// Retry-failed-only: after a failed attempt, subset to the failing flows. The
|
||||
// failed-flow parsers return null when a report cannot be trusted; we then fall
|
||||
// through to dumb retry (re-run everything).
|
||||
let flowsToRun: string[] = flowPaths;
|
||||
let lastAttemptExitCode: number | null = null;
|
||||
const harvested: HarvestedScreenshot[] = [];
|
||||
const reportDirectories = backend === 'maestro' ? [junitReportDirectory] : [];
|
||||
|
||||
const totalAttempts = retries + 1;
|
||||
if (platform === 'android' && androidConnectionMode === 'dadb') {
|
||||
if (
|
||||
backend === 'maestro-runner' &&
|
||||
platform === 'android' &&
|
||||
androidConnectionMode === 'dadb'
|
||||
) {
|
||||
logger.info('maestro-runner does not support DADB. Using the default ADB connection.');
|
||||
}
|
||||
if (backend === 'maestro' && platform === 'android' && androidConnectionMode === 'dadb') {
|
||||
try {
|
||||
const adbOverrideDirectoryPath = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'maestro-tests-adb-override-')
|
||||
@@ -280,29 +340,45 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
// maestro-runner writes its JUnit report and screenshot metadata to this directory.
|
||||
const runnerOutputDirectory = path.join(
|
||||
testsDirectory,
|
||||
`${platform}-maestro-runner-attempt-${attempt}`
|
||||
);
|
||||
const outputPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
||||
: outputFormat
|
||||
: backend === 'maestro' && outputFormat
|
||||
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
||||
: null;
|
||||
|
||||
const maestroArgs = buildMaestroArgs({
|
||||
flow_path: flowsToRun,
|
||||
outputPath,
|
||||
output_format: outputFormat,
|
||||
const { executable, args: maestroArgs } = buildMaestroArgs({
|
||||
backend,
|
||||
platform,
|
||||
flowPaths: flowsToRun,
|
||||
output: backend === 'maestro-runner' ? runnerOutputDirectory : outputPath,
|
||||
outputFormat,
|
||||
shards,
|
||||
include_tags: includeTags,
|
||||
exclude_tags: excludeTags,
|
||||
includeTags,
|
||||
excludeTags,
|
||||
});
|
||||
logger.info(
|
||||
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
|
||||
`Running ${executable} (attempt ${attempt + 1}/${totalAttempts}): ${executable} ${maestroArgs.join(' ')}`
|
||||
);
|
||||
|
||||
// The runner output directory is deterministic and can survive a prior run; clear it
|
||||
// best-effort so a crash before it writes fresh output can't resurrect stale results.
|
||||
if (backend === 'maestro-runner') {
|
||||
try {
|
||||
await fs.rm(runnerOutputDirectory, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Failed to clear ${runnerOutputDirectory} before the attempt.`);
|
||||
}
|
||||
}
|
||||
|
||||
const attemptStartedAtMs = Date.now();
|
||||
|
||||
try {
|
||||
await spawn('maestro', maestroArgs, {
|
||||
await spawn(executable, maestroArgs, {
|
||||
cwd: stepCtx.workingDirectory,
|
||||
env: spawnEnv,
|
||||
logger,
|
||||
@@ -311,56 +387,108 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
lastAttemptExitCode = 0;
|
||||
} catch (err: any) {
|
||||
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
|
||||
throw new SystemError('Failed to invoke maestro', { cause: err });
|
||||
throw new SystemError(`Failed to invoke ${executable}`, { cause: err });
|
||||
}
|
||||
if (err && typeof err.status === 'number') {
|
||||
lastAttemptExitCode = err.status;
|
||||
} else {
|
||||
throw new SystemError('Unexpected spawn failure invoking maestro', { cause: err });
|
||||
throw new SystemError(`Unexpected spawn failure invoking ${executable}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// maestro-runner writes a report directory. Copy its JUnit file into the existing
|
||||
// per-attempt directory so retry parsing and final report merging remain shared.
|
||||
if (backend === 'maestro-runner' && outputPath) {
|
||||
try {
|
||||
await fs.copyFile(path.join(runnerOutputDirectory, 'junit-report.xml'), outputPath);
|
||||
} catch (err: any) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Failed to collect the maestro-runner JUnit report for attempt ${attempt + 1}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (backend === 'maestro-runner') {
|
||||
reportDirectories.push(runnerOutputDirectory);
|
||||
}
|
||||
|
||||
// Harvest this attempt's failure screenshots before any retry subsetting. Gated on
|
||||
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
|
||||
// runs, so harvesting other formats would just create orphan artifacts the website hides.
|
||||
if (outputFormat === 'junit') {
|
||||
const failedFlowNames = outputPath
|
||||
? await parseFailedFlowNamesFromJUnitFile(outputPath)
|
||||
: new Set<string>();
|
||||
harvested.push(
|
||||
...(await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: attemptStartedAtMs,
|
||||
attemptIndex: attempt,
|
||||
failedFlowNames,
|
||||
logger,
|
||||
}))
|
||||
);
|
||||
let screenshots: HarvestedScreenshot[];
|
||||
switch (backend) {
|
||||
case 'maestro': {
|
||||
const failedFlowNames = outputPath
|
||||
? await parseFailedFlowNamesFromJUnitFile(outputPath)
|
||||
: new Set<string>();
|
||||
screenshots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: attemptStartedAtMs,
|
||||
attemptIndex: attempt,
|
||||
failedFlowNames,
|
||||
logger,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'maestro-runner':
|
||||
screenshots = await harvestMaestroRunnerFailureScreenshotsAsync({
|
||||
reportDirectory: runnerOutputDirectory,
|
||||
capturedSinceMs: attemptStartedAtMs,
|
||||
attemptIndex: attempt,
|
||||
logger,
|
||||
});
|
||||
break;
|
||||
}
|
||||
harvested.push(...screenshots);
|
||||
}
|
||||
|
||||
if (lastAttemptExitCode === 0 || attempt === retries) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
|
||||
if (
|
||||
retryFailedOnly &&
|
||||
(backend === 'maestro-runner' || (outputFormat === 'junit' && outputPath))
|
||||
) {
|
||||
let failed: string[] | null;
|
||||
if (await junitFileHasFileAttrs(outputPath)) {
|
||||
failed = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: outputPath,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
});
|
||||
} else {
|
||||
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
|
||||
// paths via the flow-file scan. DELETE this arm once the fleet is
|
||||
// on >= 2.6.0.
|
||||
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
}));
|
||||
failed = nameToPath
|
||||
? await parseFailedFlowsFromJUnit({ junitFile: outputPath, nameToPath })
|
||||
: null;
|
||||
switch (backend) {
|
||||
case 'maestro-runner':
|
||||
failed = await parseFailedFlowsFromMaestroRunnerReport({
|
||||
reportDirectory: runnerOutputDirectory,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
});
|
||||
break;
|
||||
case 'maestro':
|
||||
if (!outputPath) {
|
||||
failed = null;
|
||||
break;
|
||||
}
|
||||
if (await junitFileHasFileAttrs(outputPath)) {
|
||||
failed = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: outputPath,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
});
|
||||
break;
|
||||
}
|
||||
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
|
||||
// paths via the flow-file scan. DELETE this arm once the fleet is
|
||||
// on >= 2.6.0.
|
||||
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
}));
|
||||
failed = nameToPath
|
||||
? await parseFailedFlowsFromJUnit({
|
||||
junitFile: outputPath,
|
||||
nameToPath,
|
||||
})
|
||||
: null;
|
||||
break;
|
||||
}
|
||||
if (failed !== null && failed.length > 0) {
|
||||
flowsToRun = failed;
|
||||
@@ -413,7 +541,13 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
|
||||
// screenshots most) still upload. Harvest only ran for junit, so guard the same way.
|
||||
if (outputFormat === 'junit') {
|
||||
await uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger });
|
||||
await uploadFailureScreenshotsAsync({
|
||||
harvested,
|
||||
backend,
|
||||
reportDirectories,
|
||||
ctx,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
// The retry loop exits via success (0), numeric status (retryable),
|
||||
@@ -434,12 +568,14 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// the maestro test result. Caller guards on junit (harvest only runs for junit).
|
||||
async function uploadFailureScreenshotsAsync({
|
||||
harvested,
|
||||
junitReportDirectory,
|
||||
backend,
|
||||
reportDirectories,
|
||||
ctx,
|
||||
logger,
|
||||
}: {
|
||||
harvested: HarvestedScreenshot[];
|
||||
junitReportDirectory: string;
|
||||
backend: MaestroBackend;
|
||||
reportDirectories: string[];
|
||||
ctx: CustomBuildContext;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
@@ -449,9 +585,25 @@ async function uploadFailureScreenshotsAsync({
|
||||
// test verdict (the whole step is verdict-neutral for screenshots).
|
||||
let selected: HarvestedScreenshot[];
|
||||
try {
|
||||
const pureFailureFlowNames = computePureFailureFlowNames(
|
||||
await parseJUnitTestCases(junitReportDirectory)
|
||||
);
|
||||
let flowResults: { name: string; status: 'passed' | 'failed' }[];
|
||||
switch (backend) {
|
||||
case 'maestro':
|
||||
flowResults = (
|
||||
await Promise.all(reportDirectories.map(directory => parseJUnitTestCases(directory)))
|
||||
).flat();
|
||||
break;
|
||||
case 'maestro-runner': {
|
||||
const results = await Promise.all(
|
||||
reportDirectories.map(directory => parseMaestroRunnerReport(directory))
|
||||
);
|
||||
// Use whichever reports parsed. An unreadable report (e.g. a final retry that crashed
|
||||
// before writing report.json) contributes no flows rather than discarding screenshots
|
||||
// harvested from the attempts that did report — mirroring the maestro path above.
|
||||
flowResults = results.flatMap(result => result?.flows ?? []);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const pureFailureFlowNames = computePureFailureFlowNames(flowResults);
|
||||
selected = selectFailureScreenshots(harvested, pureFailureFlowNames);
|
||||
} catch (err: any) {
|
||||
logger.warn({ err }, 'Failed to classify failure screenshots; skipping screenshot upload.');
|
||||
|
||||
@@ -20,12 +20,6 @@ import {
|
||||
getCcachePath,
|
||||
} from '../../utils/cacheKey';
|
||||
import { Datadog } from '../../datadog';
|
||||
import {
|
||||
XCODE_COMPILATION_CACHE_ENV,
|
||||
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
|
||||
decompressXcodeCompilationCacheAsync,
|
||||
generateXcodeCompilationCacheKeyAsync,
|
||||
} from '../../ios/compilationCache';
|
||||
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
@@ -54,91 +48,26 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
restoreCcacheAsync({
|
||||
await restoreCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
platform,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
|
||||
if (platform === Platform.ANDROID) {
|
||||
await restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
platform,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
}),
|
||||
platform === Platform.IOS
|
||||
? restoreXcodeCompilationCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
})
|
||||
: restoreGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreXcodeCompilationCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
const buildCacheEnabled =
|
||||
env.EAS_RESTORE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_RESTORE_CACHE !== '0');
|
||||
if (!buildCacheEnabled || env[XCODE_COMPILATION_CACHE_ENV] !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const { key, keyPrefix, xcodeVersion } = await generateXcodeCompilationCacheKeyAsync({
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(`Restoring Xcode compilation cache for ${xcodeVersion} with key: ${key}`);
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
|
||||
key,
|
||||
keyPrefixes: [keyPrefix],
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
await decompressXcodeCompilationCacheAsync({
|
||||
archivePath,
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(
|
||||
`Xcode compilation cache restored ${matchedKey === key ? '(direct hit)' : '(prefix match)'}`
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof TurtleFetchError && err.response?.status === 404) {
|
||||
logger.info('No Xcode compilation cache found for this key');
|
||||
} else {
|
||||
logger.warn({ err }, 'Failed to restore Xcode compilation cache');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createCacheStatsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
@@ -329,13 +258,14 @@ export async function restoreGradleCacheAsync({
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
const journalPath = path.join(gradleCachesPath, 'journal-1');
|
||||
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths: [buildCachePath],
|
||||
paths: [buildCachePath, journalPath],
|
||||
key: cacheKey,
|
||||
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
|
||||
platform: Platform.ANDROID,
|
||||
|
||||
@@ -14,12 +14,6 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
|
||||
import {
|
||||
XCODE_COMPILATION_CACHE_ENV,
|
||||
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
|
||||
compressXcodeCompilationCacheAsync,
|
||||
generateXcodeCompilationCacheKeyAsync,
|
||||
} from '../../ios/compilationCache';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
|
||||
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
|
||||
@@ -49,87 +43,27 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
saveCcacheAsync({
|
||||
await saveCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
platform,
|
||||
evictUsedBefore,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
});
|
||||
|
||||
if (platform === Platform.ANDROID) {
|
||||
await saveGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
platform,
|
||||
evictUsedBefore,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
}),
|
||||
platform === Platform.IOS
|
||||
? saveXcodeCompilationCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
})
|
||||
: saveGradleCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets: stepCtx.global.staticContext.job.secrets,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveXcodeCompilationCacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
env,
|
||||
secrets,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
workingDirectory: string;
|
||||
env: Record<string, string | undefined>;
|
||||
secrets?: { robotAccessToken?: string };
|
||||
}): Promise<void> {
|
||||
const buildCacheEnabled =
|
||||
env.EAS_SAVE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_SAVE_CACHE !== '0');
|
||||
if (!buildCacheEnabled || env[XCODE_COMPILATION_CACHE_ENV] !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { key, xcodeVersion } = await generateXcodeCompilationCacheKeyAsync({
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
logger.info(`Saving Xcode compilation cache for ${xcodeVersion} with key: ${key}`);
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
secrets?.robotAccessToken,
|
||||
'Robot access token is required for cache operations'
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const { archivePath } = await compressXcodeCompilationCacheAsync({
|
||||
workingDirectory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
logger.info(`Xcode compilation cache archive size: ${formatBytes(size)}`);
|
||||
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key,
|
||||
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
|
||||
size,
|
||||
platform: Platform.IOS,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to save Xcode compilation cache');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCcacheAsync({
|
||||
logger,
|
||||
workingDirectory,
|
||||
@@ -233,6 +167,7 @@ export async function saveGradleCacheAsync({
|
||||
|
||||
const gradleCachesPath = path.join(os.homedir(), '.gradle', 'caches');
|
||||
const buildCachePath = path.join(gradleCachesPath, 'build-cache-1');
|
||||
const journalPath = path.join(gradleCachesPath, 'journal-1');
|
||||
|
||||
try {
|
||||
await fs.promises.access(buildCachePath);
|
||||
@@ -252,9 +187,11 @@ export async function saveGradleCacheAsync({
|
||||
);
|
||||
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
|
||||
await fs.promises.mkdir(journalPath, { recursive: true });
|
||||
|
||||
logger.info('Compressing Gradle build cache...');
|
||||
const { archivePath } = await compressCacheAsync({
|
||||
paths: [buildCachePath],
|
||||
paths: [buildCachePath, journalPath],
|
||||
workingDirectory: gradleCachesPath,
|
||||
verbose: env.EXPO_DEBUG === '1',
|
||||
logger,
|
||||
@@ -270,7 +207,7 @@ export async function saveGradleCacheAsync({
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key: cacheKey,
|
||||
paths: [buildCachePath],
|
||||
paths: [buildCachePath, journalPath],
|
||||
size,
|
||||
platform: Platform.ANDROID,
|
||||
});
|
||||
|
||||
@@ -348,7 +348,10 @@ export async function compressCacheAsync({
|
||||
for (const { absolutePath, archivePath: targetRelativePath } of allFiles) {
|
||||
const targetPath = path.join(tempDir, targetRelativePath);
|
||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
// We want to keep source timestamps since Gradle may check them when pruning cache.
|
||||
const { atime, mtime } = await fs.promises.stat(absolutePath);
|
||||
await fs.promises.copyFile(absolutePath, targetPath);
|
||||
await fs.promises.utimes(targetPath, atime, mtime);
|
||||
|
||||
if (verbose) {
|
||||
logger.info(`- ${targetRelativePath}`);
|
||||
|
||||
@@ -60,6 +60,11 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
@@ -73,6 +78,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
@@ -146,6 +152,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
type BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const APPIUM_HOST = '127.0.0.1';
|
||||
const APPIUM_PORT = 4723;
|
||||
const APPIUM_STARTUP_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_APPIUM_VERSION = '^3';
|
||||
|
||||
const AppiumInstalledDriversSchema = z.record(
|
||||
z.string(),
|
||||
z.object({ installed: z.boolean().optional() }).passthrough()
|
||||
);
|
||||
|
||||
export function createStartAppiumRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_appium_remote_session',
|
||||
name: 'Start Appium remote session',
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_idle_time_minutes',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = await installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
const appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
args: [
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
});
|
||||
try {
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger });
|
||||
} catch (error) {
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl: `http://${APPIUM_HOST}:${APPIUM_PORT}/`,
|
||||
logger,
|
||||
});
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let serveSim: Awaited<ReturnType<typeof startServeSimWithTunnelAsync>> | undefined;
|
||||
try {
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
subdomainPrefix: 'appium',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger,
|
||||
});
|
||||
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
break;
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
break;
|
||||
}
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
appiumUrl: appiumTunnel.url,
|
||||
capabilities: {
|
||||
platformName: device.platformName,
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
...(serveSim ? { webPreviewUrl: serveSim.previewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (serveSim) {
|
||||
await serveSim.stopAsync();
|
||||
}
|
||||
if (appiumTunnel) {
|
||||
await appiumTunnel.stopAsync();
|
||||
}
|
||||
await eventCollection.stopAsync();
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAppium3VersionSpec(packageVersion: string | undefined): string {
|
||||
const versionSpec = packageVersion ?? DEFAULT_APPIUM_VERSION;
|
||||
const range = semver.validRange(versionSpec);
|
||||
if (!range || !semver.subset(range, '>=3.0.0 <4.0.0-0')) {
|
||||
throw new SystemError(
|
||||
`Appium 3 is required for EAS Simulator sessions. Received package version "${versionSpec}".`
|
||||
);
|
||||
}
|
||||
return versionSpec;
|
||||
}
|
||||
|
||||
type AppiumDevice = {
|
||||
platformName: 'iOS' | 'Android';
|
||||
automationName: 'XCUITest' | 'UiAutomator2';
|
||||
driverName: 'xcuitest' | 'uiautomator2';
|
||||
udid: string;
|
||||
};
|
||||
|
||||
export async function resolveAppiumDeviceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<AppiumDevice> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN: {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
const [bootedDevice] = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'booted',
|
||||
});
|
||||
if (!bootedDevice) {
|
||||
throw new SystemError('Could not find a booted iOS simulator for the Appium session.');
|
||||
}
|
||||
return {
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: bootedDevice.udid,
|
||||
};
|
||||
}
|
||||
case BuildRuntimePlatform.LINUX: {
|
||||
const attachedDevices = await AndroidEmulatorUtils.getAttachedDevicesAsync({ env });
|
||||
const bootedDevice = attachedDevices.find(device => device.state === 'device');
|
||||
if (!bootedDevice) {
|
||||
throw new SystemError('Could not find a booted Android emulator for the Appium session.');
|
||||
}
|
||||
return {
|
||||
platformName: 'Android',
|
||||
automationName: 'UiAutomator2',
|
||||
driverName: 'uiautomator2',
|
||||
udid: bootedDevice.serialId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
versionSpec: string;
|
||||
driverName: AppiumDevice['driverName'];
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
|
||||
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
|
||||
await fs.promises.writeFile(
|
||||
path.join(appiumHome, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
|
||||
logger.info(`Installing appium@${versionSpec}.`);
|
||||
await spawn('npm', ['install', '--prefix', appiumHome, `appium@${versionSpec}`], {
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
const { stdout } = await spawn(appiumBinPath, ['driver', 'list', '--installed', '--json'], {
|
||||
env: appiumEnv,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
|
||||
if (installedDrivers[driverName]?.installed) {
|
||||
logger.info(`Updating the installed Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger });
|
||||
} else {
|
||||
logger.info(`Installing the Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger });
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
}
|
||||
|
||||
async function waitForAppiumReadyAsync({
|
||||
appiumProcess,
|
||||
logger,
|
||||
}: {
|
||||
appiumProcess: { getOutput: () => string };
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
|
||||
timeout: 2_000,
|
||||
retries: 0,
|
||||
logger,
|
||||
});
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
const output = appiumProcess.getOutput();
|
||||
throw new SystemError(
|
||||
`Timed out waiting for Appium to become ready.${output ? `\nAppium output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
@@ -73,6 +73,11 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
@@ -86,6 +91,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
@@ -221,6 +227,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
@@ -21,9 +26,17 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
name: 'Start serve-sim remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env, signal }) => {
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env, signal }) => {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
|
||||
logger.info('Starting serve-sim remote session.');
|
||||
|
||||
@@ -49,6 +62,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../appiumEvents';
|
||||
import { startDeviceRunSessionEventCollectionAsync } from '../deviceRunSessionEvents';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../deviceRunSessionEvents', () => ({
|
||||
startDeviceRunSessionEventCollectionAsync: jest.fn(async () => ({
|
||||
getLastEventObservedAt: () => undefined,
|
||||
stopAsync: async () => undefined,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe(startAppiumEventCollectionAsync, () => {
|
||||
it('writes new Appium Event Timings commands to a normal event file', async () => {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(response({ value: [{ id: 'appium-session-id' }] }))
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
value: {
|
||||
commands: [
|
||||
{ cmd: 'getOrientation', startTime: 1_786_693_000_000, endTime: 1_786_693_000_025 },
|
||||
{ cmd: 'getLogEvents', startTime: 1_786_693_000_026, endTime: 1_786_693_000_027 },
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(response({ value: [] }));
|
||||
|
||||
const collection = await startAppiumEventCollectionAsync({
|
||||
ctx: {} as never,
|
||||
deviceRunSessionId: 'device-session-id',
|
||||
appiumUrl: 'http://127.0.0.1:4723/',
|
||||
logger: { warn: jest.fn() } as never,
|
||||
pollIntervalMs: 60_000,
|
||||
});
|
||||
const source = jest.mocked(startDeviceRunSessionEventCollectionAsync).mock.calls[0][0].source;
|
||||
const [eventFile] = await source.findEventFilesAsync();
|
||||
let contents = '';
|
||||
await waitForAsync(async () => {
|
||||
contents = await fs.promises.readFile(eventFile, 'utf8');
|
||||
expect(contents).toContain('getOrientation');
|
||||
});
|
||||
const lines = contents.trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
const [line] = lines;
|
||||
expect(
|
||||
source.parseLine({
|
||||
line,
|
||||
sourceKey: source.sourceKeyForFile(eventFile),
|
||||
sequenceNumber: 1,
|
||||
deviceRunSessionId: 'device-session-id',
|
||||
})
|
||||
).toMatchObject({
|
||||
event: {
|
||||
producer: 'appium',
|
||||
type: 'operation.completed',
|
||||
durationMs: 25,
|
||||
summary: 'getOrientation',
|
||||
},
|
||||
});
|
||||
|
||||
// Appium 3 removed GET /sessions; session discovery must use /appium/sessions.
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:4723/appium/sessions',
|
||||
'GET',
|
||||
expect.anything()
|
||||
);
|
||||
expect(turtleFetch).toHaveBeenCalledWith(
|
||||
'http://127.0.0.1:4723/session/appium-session-id/appium/events',
|
||||
'POST',
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
await collection.stopAsync();
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForAsync(assertion: () => Promise<void>): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
try {
|
||||
await assertion();
|
||||
return;
|
||||
} catch {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
await assertion();
|
||||
}
|
||||
|
||||
function response(value: unknown): Awaited<ReturnType<typeof turtleFetch>> {
|
||||
return { json: async () => value } as never;
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import {
|
||||
clearTimeout as clearTimeoutCallback,
|
||||
setTimeout as setTimeoutCallback,
|
||||
} from 'node:timers';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('node:timers');
|
||||
jest.mock('node:timers/promises');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
|
||||
@@ -216,14 +221,14 @@ describe(startNgrokTunnelAsync, () => {
|
||||
it('uses a 128-bit capability hostname and exposes explicit cleanup', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://serve-sim.example.test',
|
||||
url: () => 'https://web-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port: 4321,
|
||||
subdomainPrefix: 'serve-sim',
|
||||
baseDomain: 'tunnel.example.test',
|
||||
subdomainPrefix: 'web-preview',
|
||||
baseDomain: 'eas-simulator.ngrok.dev',
|
||||
authtoken: 'token',
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
@@ -232,10 +237,10 @@ describe(startNgrokTunnelAsync, () => {
|
||||
expect.objectContaining({
|
||||
addr: 4321,
|
||||
authtoken: 'token',
|
||||
domain: expect.stringMatching(/^serve-sim-[a-f0-9]{32}\.tunnel\.example\.test$/),
|
||||
domain: expect.stringMatching(/^web-preview-[a-f0-9]{32}\.eas-simulator\.ngrok\.dev$/),
|
||||
})
|
||||
);
|
||||
expect(tunnel.url).toBe('https://serve-sim.example.test');
|
||||
expect(tunnel.url).toBe('https://web-preview.example.test');
|
||||
await tunnel.stopAsync();
|
||||
await tunnel.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
@@ -360,8 +365,13 @@ describe(fetchServeSimTurnArgsAsync, () => {
|
||||
});
|
||||
|
||||
describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
const durationTimeout = {} as NodeJS.Timeout;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(Sentry).capture.mockReset();
|
||||
jest.mocked(setTimeoutCallback).mockReset();
|
||||
jest.mocked(setTimeoutCallback).mockReturnValue(durationTimeout);
|
||||
jest.mocked(clearTimeoutCallback).mockReset();
|
||||
jest.mocked(setTimeoutAsync).mockReset();
|
||||
jest.mocked(setTimeoutAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -378,6 +388,57 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
|
||||
expect(setTimeoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns normally when the maximum duration elapses', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
|
||||
const logger = createLoggerMock();
|
||||
const waitPromise = waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
maxDurationSeconds: 1,
|
||||
});
|
||||
|
||||
expect(setTimeoutCallback).toHaveBeenCalledWith(expect.any(Function), 1_000);
|
||||
const durationTimeoutCallback = jest.mocked(setTimeoutCallback).mock.calls[0][0];
|
||||
durationTimeoutCallback();
|
||||
await waitPromise;
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Device run session drs-id reached its maximum duration.'
|
||||
);
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('clears the duration timeout when the session stops first', async () => {
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx: createStatusCtxMock([{ status: 'STOPPED' }]),
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
});
|
||||
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('does not poll when the build step is already aborted', async () => {
|
||||
const ctx = createStatusCtxMock([]);
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(setTimeoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the device run session errors', async () => {
|
||||
@@ -388,8 +449,28 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
})
|
||||
).rejects.toThrow('Device run session drs-id errored.');
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('clears the duration timeout when the build step is aborted', async () => {
|
||||
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
|
||||
const abortController = new AbortController();
|
||||
const waitPromise = waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger: createLoggerMock(),
|
||||
maxDurationSeconds: 30,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
abortController.abort();
|
||||
await waitPromise;
|
||||
|
||||
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
|
||||
});
|
||||
|
||||
it('logs and retries transient polling errors', async () => {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import {
|
||||
type DeviceRunSessionEventCollection,
|
||||
type DeviceRunSessionEventSource,
|
||||
startDeviceRunSessionEventCollectionAsync,
|
||||
} from './deviceRunSessionEvents';
|
||||
|
||||
const APPIUM_REQUEST_TIMEOUT_MS = 10_000;
|
||||
const POLL_INTERVAL_MS = 1_000;
|
||||
const GET_LOG_EVENTS_COMMAND = 'getLogEvents';
|
||||
|
||||
const AppiumSessionsResponseSchema = z.object({
|
||||
value: z.array(z.object({ id: z.string() }).passthrough()),
|
||||
});
|
||||
|
||||
const AppiumCommandSchema = z.object({
|
||||
appiumSessionId: z.string(),
|
||||
cmd: z.string(),
|
||||
startTime: z.number(),
|
||||
endTime: z.number(),
|
||||
});
|
||||
|
||||
const AppiumEventsResponseSchema = z.object({
|
||||
value: z.object({
|
||||
commands: z.array(AppiumCommandSchema.omit({ appiumSessionId: true })),
|
||||
}),
|
||||
});
|
||||
|
||||
type AppiumCommand = z.infer<typeof AppiumCommandSchema>;
|
||||
|
||||
export async function startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
appiumUrl,
|
||||
logger,
|
||||
pollIntervalMs = POLL_INTERVAL_MS,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
appiumUrl: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): Promise<DeviceRunSessionEventCollection> {
|
||||
const eventDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-events-'));
|
||||
const eventFile = path.join(eventDirectory, 'commands.ndjson');
|
||||
await fs.promises.writeFile(eventFile, '');
|
||||
|
||||
let eventCollection: DeviceRunSessionEventCollection;
|
||||
try {
|
||||
eventCollection = await startDeviceRunSessionEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
pollIntervalMs,
|
||||
source: createAppiumEventSource(eventFile),
|
||||
});
|
||||
} catch (error) {
|
||||
await fs.promises.rm(eventDirectory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const observedCommandKeys = new Set<string>();
|
||||
const controller = new AbortController();
|
||||
let didReportCollectionFailure = false;
|
||||
const collectSafelyAsync = async (): Promise<void> => {
|
||||
try {
|
||||
await collectAppiumCommandsAsync({
|
||||
appiumUrl,
|
||||
eventFile,
|
||||
observedCommandKeys,
|
||||
logger,
|
||||
});
|
||||
didReportCollectionFailure = false;
|
||||
} catch (error) {
|
||||
if (!didReportCollectionFailure) {
|
||||
didReportCollectionFailure = true;
|
||||
logger.warn({ err: error }, 'Could not collect Appium events.');
|
||||
}
|
||||
}
|
||||
};
|
||||
const pollingPromise = (async () => {
|
||||
while (!controller.signal.aborted) {
|
||||
await collectSafelyAsync();
|
||||
try {
|
||||
await setTimeoutAsync(pollIntervalMs, undefined, { signal: controller.signal });
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
})().catch(error => {
|
||||
logger.warn({ err: error }, 'Appium event collection poller failed.');
|
||||
});
|
||||
|
||||
return {
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
stopAsync: async () => {
|
||||
controller.abort();
|
||||
await pollingPromise;
|
||||
await collectSafelyAsync();
|
||||
try {
|
||||
await eventCollection.stopAsync();
|
||||
} finally {
|
||||
await fs.promises.rm(eventDirectory, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createAppiumEventSource(eventFile: string): DeviceRunSessionEventSource {
|
||||
return {
|
||||
producer: 'appium',
|
||||
findEventFilesAsync: async () => [eventFile],
|
||||
sourceKeyForFile: file => path.basename(file, path.extname(file)),
|
||||
parseLine: ({ line, sourceKey, sequenceNumber, deviceRunSessionId }) => {
|
||||
if (!line.trim()) {
|
||||
return {};
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch {
|
||||
return { failure: 'invalid-json' };
|
||||
}
|
||||
const result = AppiumCommandSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return { failure: 'invalid-event' };
|
||||
}
|
||||
const command = result.data;
|
||||
const operationId = `${sourceKey}:${sequenceNumber}`;
|
||||
return {
|
||||
event: {
|
||||
v: 1,
|
||||
eventId: `appium:${deviceRunSessionId}:${operationId}`,
|
||||
ts: new Date(command.endTime).toISOString(),
|
||||
producer: 'appium',
|
||||
type: 'operation.completed',
|
||||
operationId,
|
||||
durationMs: Math.max(0, command.endTime - command.startTime),
|
||||
summary: command.cmd,
|
||||
data: {
|
||||
command: command.cmd,
|
||||
appiumSessionId: command.appiumSessionId,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function collectAppiumCommandsAsync({
|
||||
appiumUrl,
|
||||
eventFile,
|
||||
observedCommandKeys,
|
||||
logger,
|
||||
}: {
|
||||
appiumUrl: string;
|
||||
eventFile: string;
|
||||
observedCommandKeys: Set<string>;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const response = await turtleFetch(new URL('appium/sessions', appiumUrl).toString(), 'GET', {
|
||||
timeout: APPIUM_REQUEST_TIMEOUT_MS,
|
||||
retries: 0,
|
||||
logger,
|
||||
});
|
||||
const sessions = AppiumSessionsResponseSchema.parse(await response.json()).value;
|
||||
const newCommands: { key: string; command: AppiumCommand }[] = [];
|
||||
const pendingCommandKeys = new Set<string>();
|
||||
|
||||
for (const { id: appiumSessionId } of sessions) {
|
||||
const eventsResponse = await turtleFetch(
|
||||
new URL(`session/${encodeURIComponent(appiumSessionId)}/appium/events`, appiumUrl).toString(),
|
||||
'POST',
|
||||
{
|
||||
json: {},
|
||||
timeout: APPIUM_REQUEST_TIMEOUT_MS,
|
||||
retries: 0,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
const commands = AppiumEventsResponseSchema.parse(await eventsResponse.json()).value.commands;
|
||||
for (const command of commands) {
|
||||
const key = [appiumSessionId, command.cmd, command.startTime, command.endTime].join(':');
|
||||
if (
|
||||
command.cmd === GET_LOG_EVENTS_COMMAND ||
|
||||
observedCommandKeys.has(key) ||
|
||||
pendingCommandKeys.has(key)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
pendingCommandKeys.add(key);
|
||||
newCommands.push({ key, command: { appiumSessionId, ...command } });
|
||||
}
|
||||
}
|
||||
|
||||
if (newCommands.length === 0) {
|
||||
return;
|
||||
}
|
||||
await fs.promises.appendFile(
|
||||
eventFile,
|
||||
`${newCommands.map(({ command }) => JSON.stringify(command)).join('\n')}\n`
|
||||
);
|
||||
for (const { key } of newCommands) {
|
||||
observedCommandKeys.add(key);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import path from 'path';
|
||||
|
||||
import { COMMON_FASTLANE_ENV } from '../../../common/fastlane';
|
||||
import { XcodeBuildLogger } from '../../../common/xcpretty';
|
||||
import * as CompilationCache from '../../../ios/compilationCache';
|
||||
|
||||
export async function runFastlaneGym({
|
||||
workingDir,
|
||||
@@ -20,23 +19,14 @@ export async function runFastlaneGym({
|
||||
env: BuildStepEnv;
|
||||
extraEnv?: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
const workspacePath = path.join(workingDir, 'ios');
|
||||
const derivedDataPath = path.join(workspacePath, 'build');
|
||||
// When caching is enabled, this also sets GYM_DERIVED_DATA_PATH so Fastlane
|
||||
// and the cache restore/save operations use this exact directory.
|
||||
const compilationCacheEnv = await CompilationCache.prepareXcodeCompilationCacheEnvAsync({
|
||||
derivedDataPath,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
const buildLogger = new XcodeBuildLogger(logger, workingDir);
|
||||
void buildLogger.watchLogFiles(buildLogsDirectory);
|
||||
try {
|
||||
await runFastlane(['gym'], {
|
||||
cwd: workspacePath,
|
||||
cwd: path.join(workingDir, 'ios'),
|
||||
logger,
|
||||
env,
|
||||
extraEnv: { ...extraEnv, ...compilationCacheEnv },
|
||||
extraEnv,
|
||||
});
|
||||
} finally {
|
||||
await buildLogger.flush();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { clearTimeout, setTimeout } from 'node:timers';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
@@ -144,77 +145,107 @@ export async function waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
maxDurationSeconds,
|
||||
signal: cancelSignal,
|
||||
idleTimeout,
|
||||
}: {
|
||||
ctx: CustomBuildContext;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
maxDurationSeconds?: number;
|
||||
signal?: AbortSignal;
|
||||
idleTimeout?: DeviceRunSessionIdleTimeout;
|
||||
}): Promise<void> {
|
||||
logger.info(
|
||||
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
|
||||
);
|
||||
if (idleTimeout) {
|
||||
logger.info(
|
||||
`The session stops automatically after ${idleTimeout.maxIdleTimeMinutes} minute(s) without activity.`
|
||||
);
|
||||
const durationAbortController = new AbortController();
|
||||
const signal = cancelSignal
|
||||
? AbortSignal.any([cancelSignal, durationAbortController.signal])
|
||||
: durationAbortController.signal;
|
||||
// Nothing to wait for if the step was already aborted before we started;
|
||||
// return before logging so we don't claim to be polling a session we never poll.
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
let pollErrorCount = 0;
|
||||
let lastActivityAt = new Date();
|
||||
const durationTimeout =
|
||||
maxDurationSeconds === undefined
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
logger.info(`Device run session ${deviceRunSessionId} reached its maximum duration.`);
|
||||
durationAbortController.abort();
|
||||
}, maxDurationSeconds * 1_000);
|
||||
|
||||
while (!signal?.aborted) {
|
||||
try {
|
||||
logger.info(
|
||||
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
|
||||
);
|
||||
if (durationTimeout !== undefined) {
|
||||
logger.info(
|
||||
`The device run session will stop automatically after ${maxDurationSeconds} seconds.`
|
||||
);
|
||||
}
|
||||
if (idleTimeout) {
|
||||
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
|
||||
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
|
||||
lastActivityAt = lastEventObservedAt;
|
||||
}
|
||||
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
|
||||
logger.info(
|
||||
`Device run session ${deviceRunSessionId} had no activity for ` +
|
||||
`${idleTimeout.maxIdleTimeMinutes} minute(s) (max idle time). Stopping the session.`
|
||||
);
|
||||
await ensureDeviceRunSessionStoppedSafelyAsync({ ctx, deviceRunSessionId, logger });
|
||||
return;
|
||||
}
|
||||
logger.info(
|
||||
`The session stops automatically after ${idleTimeout.maxIdleTimeMinutes} minute(s) without activity.`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
let pollErrorCount = 0;
|
||||
let lastActivityAt = new Date();
|
||||
|
||||
const status = result.data?.deviceRunSessions?.byId?.status;
|
||||
if (!status) {
|
||||
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
|
||||
}
|
||||
pollErrorCount = 0;
|
||||
if (status === 'STOPPED') {
|
||||
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
|
||||
return;
|
||||
}
|
||||
if (status === 'ERRORED') {
|
||||
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SystemError) {
|
||||
throw err;
|
||||
while (!signal.aborted) {
|
||||
if (idleTimeout) {
|
||||
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
|
||||
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
|
||||
lastActivityAt = lastEventObservedAt;
|
||||
}
|
||||
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
|
||||
logger.info(
|
||||
`Device run session ${deviceRunSessionId} had no activity for ` +
|
||||
`${idleTimeout.maxIdleTimeMinutes} minute(s) (max idle time). Stopping the session.`
|
||||
);
|
||||
await ensureDeviceRunSessionStoppedSafelyAsync({ ctx, deviceRunSessionId, logger });
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
pollErrorCount += 1;
|
||||
if (pollErrorCount === 1 || pollErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not poll device run session status', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error, failedStatusPollCount: pollErrorCount },
|
||||
'Could not poll device run session status; will retry.'
|
||||
);
|
||||
const status = result.data?.deviceRunSessions?.byId?.status;
|
||||
if (!status) {
|
||||
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
|
||||
}
|
||||
pollErrorCount = 0;
|
||||
if (status === 'STOPPED') {
|
||||
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
|
||||
return;
|
||||
}
|
||||
if (status === 'ERRORED') {
|
||||
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SystemError) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
pollErrorCount += 1;
|
||||
if (pollErrorCount === 1 || pollErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not poll device run session status', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error, failedStatusPollCount: pollErrorCount },
|
||||
'Could not poll device run session status; will retry.'
|
||||
);
|
||||
}
|
||||
}
|
||||
await sleepUntilAbortedAsync(DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS, signal);
|
||||
}
|
||||
} finally {
|
||||
if (durationTimeout !== undefined) {
|
||||
clearTimeout(durationTimeout);
|
||||
}
|
||||
await sleepUntilAbortedAsync(DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS, signal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,7 +722,7 @@ export async function startServeSimWithTunnelAsync(
|
||||
await waitForServeSimReadyAsync({ serveSim, port, timeoutMs });
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'serve-sim',
|
||||
subdomainPrefix: 'web-preview',
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
|
||||
@@ -6,11 +6,13 @@ import assert from 'assert';
|
||||
import FastGlob from 'fast-glob';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
import { retryAsync } from './retry';
|
||||
|
||||
/** Android Virtual Device is the device we run. */
|
||||
@@ -305,21 +307,23 @@ export namespace AndroidEmulatorUtils {
|
||||
emulatorPromise: SpawnPromise<SpawnResult>;
|
||||
serialId: AndroidDeviceSerialId;
|
||||
logcatOutputPath: string;
|
||||
emulatorOutputPath: string;
|
||||
}> {
|
||||
let logcatOutputPath: string;
|
||||
let emulatorOutputPath: string;
|
||||
try {
|
||||
await fs.promises.mkdir(logcatDirectory, { recursive: true });
|
||||
const safeDeviceName = deviceName.replace(/[^a-zA-Z0-9_.-]/g, '_');
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
.toString(16)
|
||||
.padStart(8, '0');
|
||||
logcatOutputPath = path.join(
|
||||
logcatDirectory,
|
||||
`${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}.log`
|
||||
);
|
||||
const outputName = `${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}`;
|
||||
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
|
||||
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
|
||||
await fs.promises.writeFile(logcatOutputPath, '');
|
||||
await fs.promises.writeFile(emulatorOutputPath, '');
|
||||
} catch (err) {
|
||||
throw new SystemError(`Failed to prepare Android emulator logcat output for ${deviceName}.`, {
|
||||
throw new SystemError(`Failed to prepare Android emulator output for ${deviceName}.`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
@@ -346,7 +350,8 @@ export namespace AndroidEmulatorUtils {
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
env: {
|
||||
...env,
|
||||
// We don't need to wait for emulator to exit gracefully.
|
||||
@@ -354,6 +359,43 @@ export namespace AndroidEmulatorUtils {
|
||||
},
|
||||
}
|
||||
);
|
||||
const emulatorOutputStream = fs.createWriteStream(emulatorOutputPath, { flags: 'a' });
|
||||
let reportedEmulatorOutputError = false;
|
||||
emulatorOutputStream.on('error', err => {
|
||||
process.stderr.write(
|
||||
`Failed to write Android emulator output to ${emulatorOutputPath}: ${err}\n`
|
||||
);
|
||||
if (!reportedEmulatorOutputError) {
|
||||
reportedEmulatorOutputError = true;
|
||||
Sentry.capture('Failed to write Android emulator process output', err, {
|
||||
level: 'warning',
|
||||
tags: {
|
||||
errorCode: (err as NodeJS.ErrnoException).code ?? 'unknown',
|
||||
},
|
||||
extras: {
|
||||
deviceName,
|
||||
emulatorOutputPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
// Only into the log file -- this process' stdout/stderr is not watched or uploaded.
|
||||
emulatorPromise.child.stdout?.pipe(emulatorOutputStream, { end: false });
|
||||
emulatorPromise.child.stderr?.pipe(emulatorOutputStream, { end: false });
|
||||
emulatorPromise.child.once('close', () => {
|
||||
emulatorOutputStream.end();
|
||||
});
|
||||
// Piped stdio creates socket handles in this process which, unlike inherited
|
||||
// file descriptors, keep the event loop alive for as long as the emulator runs.
|
||||
// We never stop the emulator explicitly, so without unref-ing these the process
|
||||
// would never exit on its own -- defeating the `detached` + `unref()` below.
|
||||
// Output is still captured for as long as this process lives.
|
||||
if (emulatorPromise.child.stdout instanceof Socket) {
|
||||
emulatorPromise.child.stdout.unref();
|
||||
}
|
||||
if (emulatorPromise.child.stderr instanceof Socket) {
|
||||
emulatorPromise.child.stderr.unref();
|
||||
}
|
||||
// If emulator fails to start, throw its error.
|
||||
if (!emulatorPromise.child.pid) {
|
||||
await emulatorPromise;
|
||||
@@ -382,7 +424,7 @@ export namespace AndroidEmulatorUtils {
|
||||
|
||||
// We don't want to await the SpawnPromise here.
|
||||
// eslint-disable-next-line @typescript-eslint/return-await
|
||||
return { emulatorPromise, serialId, logcatOutputPath };
|
||||
return { emulatorPromise, serialId, logcatOutputPath, emulatorOutputPath };
|
||||
}
|
||||
|
||||
export async function waitForReadyAsync({
|
||||
|
||||
@@ -273,7 +273,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
expect(attemptCounter).toBe(2);
|
||||
}, 360_000);
|
||||
|
||||
it('captures native logcat output across an ADB server restart', async () => {
|
||||
it('captures emulator and native logcat output across an ADB server restart', async () => {
|
||||
const testLogcatDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'android-emulator-logcat-e2e-')
|
||||
);
|
||||
@@ -302,6 +302,21 @@ describe('AndroidEmulatorUtils', () => {
|
||||
emulatorPromise = asyncResult(startResult.emulatorPromise);
|
||||
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({ serialId, env: process.env });
|
||||
await retryAsync(
|
||||
async () => {
|
||||
const contents = await fs.promises.readFile(startResult.emulatorOutputPath, 'utf-8');
|
||||
if (!contents.includes('Android emulator version')) {
|
||||
throw new Error('Did not find the emulator version in emulator process output yet.');
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: 10,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
await spawn('adb', ['kill-server'], { env: process.env });
|
||||
await spawn('adb', ['start-server'], { env: process.env });
|
||||
await spawn('adb', ['-s', serialId, 'shell', 'log', '-t', 'EAS_CLI_TEST', marker], {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import { Socket } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { AndroidEmulatorUtils, AndroidVirtualDeviceName } from '../AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../retry';
|
||||
|
||||
@@ -17,6 +20,12 @@ jest.mock('../retry', () => ({
|
||||
retryAsync: jest.fn(async fn => await fn(0)),
|
||||
}));
|
||||
|
||||
jest.mock('../../sentry', () => ({
|
||||
Sentry: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
|
||||
@@ -25,11 +34,13 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
temporaryDirectories = [];
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedRetryAsync.mockImplementation(async fn => await fn(0));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
await Promise.all(
|
||||
temporaryDirectories.map(async temporaryDirectory => {
|
||||
await fs.promises.rm(temporaryDirectory, { force: true, recursive: true });
|
||||
@@ -39,7 +50,18 @@ describe('AndroidEmulatorUtils', () => {
|
||||
|
||||
describe(AndroidEmulatorUtils.startAsync, () => {
|
||||
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
|
||||
const child = { pid: 1234, unref: jest.fn() };
|
||||
// Under `stdio: 'pipe'` these are net.Sockets, which is what startAsync
|
||||
// narrows on before unref-ing them.
|
||||
const stdout = new Socket();
|
||||
const stderr = new Socket();
|
||||
jest.spyOn(stdout, 'unref').mockReturnValue(stdout);
|
||||
jest.spyOn(stderr, 'unref').mockReturnValue(stderr);
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
pid: 1234,
|
||||
unref: jest.fn(),
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
const spawnPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
|
||||
spawnPromise.child = child;
|
||||
mockedSpawn.mockImplementation(((command: string, args: string[]) => {
|
||||
@@ -57,11 +79,12 @@ describe('AndroidEmulatorUtils', () => {
|
||||
return { child };
|
||||
}
|
||||
|
||||
it('captures logcat through the emulator process', async () => {
|
||||
it('captures logcat and emulator process output', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
const { child } = mockSuccessfulStart(deviceName);
|
||||
const createWriteStreamSpy = jest.spyOn(fs, 'createWriteStream');
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
@@ -70,13 +93,21 @@ describe('AndroidEmulatorUtils', () => {
|
||||
});
|
||||
|
||||
expect(result.logcatOutputPath).toMatch(
|
||||
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}\\.log$`)
|
||||
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-logcat\\.log$`)
|
||||
);
|
||||
expect(result.emulatorOutputPath).toMatch(
|
||||
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}-emulator\\.log$`)
|
||||
);
|
||||
await expect(fs.promises.access(result.logcatOutputPath)).resolves.toBeUndefined();
|
||||
await expect(fs.promises.access(result.emulatorOutputPath)).resolves.toBeUndefined();
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\/emulator\/emulator$/),
|
||||
expect.arrayContaining(['-logcat', '*:v', '-logcat-output', result.logcatOutputPath]),
|
||||
expect.objectContaining({ detached: true, stdio: 'inherit' })
|
||||
expect.objectContaining({
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
})
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
@@ -84,6 +115,32 @@ describe('AndroidEmulatorUtils', () => {
|
||||
expect.anything()
|
||||
);
|
||||
expect(child.unref).toHaveBeenCalled();
|
||||
// Piped stdio would otherwise keep this process' event loop alive
|
||||
// for as long as the emulator runs.
|
||||
expect(child.stdout.unref).toHaveBeenCalled();
|
||||
expect(child.stderr.unref).toHaveBeenCalled();
|
||||
const emulatorOutputStream = createWriteStreamSpy.mock.results[0].value;
|
||||
const stderrWriteSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const writeError = Object.assign(new Error('disk full'), { code: 'ENOSPC' });
|
||||
emulatorOutputStream.emit('error', writeError);
|
||||
emulatorOutputStream.emit('error', writeError);
|
||||
expect(stderrWriteSpy).toHaveBeenCalledTimes(2);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to write Android emulator process output',
|
||||
writeError,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: { errorCode: 'ENOSPC' },
|
||||
extras: {
|
||||
deviceName,
|
||||
emulatorOutputPath: result.emulatorOutputPath,
|
||||
},
|
||||
}
|
||||
);
|
||||
const emulatorOutputStreamClosed = once(emulatorOutputStream, 'close');
|
||||
child.emit('close');
|
||||
await emulatorOutputStreamClosed;
|
||||
});
|
||||
|
||||
it('throws a SystemError when the staging directory cannot be prepared', async () => {
|
||||
@@ -100,7 +157,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
logcatDirectory: outputDir,
|
||||
})
|
||||
).rejects.toEqual(
|
||||
new SystemError('Failed to prepare Android emulator logcat output for eas-simulator.', {
|
||||
new SystemError('Failed to prepare Android emulator output for eas-simulator.', {
|
||||
cause: mkdirError,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import resolveFrom from 'resolve-from';
|
||||
import spawnAsync from '@expo/turtle-spawn';
|
||||
|
||||
import { expoCommandAsync, ExpoCLIModuleNotFoundError } from '../expoCli';
|
||||
import { ExpoCLIModuleNotFoundError, expoCommandAsync } from '../expoCli';
|
||||
|
||||
jest.mock('resolve-from', () => ({
|
||||
__esModule: true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "21.7.1",
|
||||
"version": "22.2.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"prepack": "yarn run clean && yarn run build",
|
||||
"lint": "oxlint --type-aware --tsconfig ../../tsconfig.oxlint.json .",
|
||||
"lint": "oxlint --type-aware --max-warnings=0 --tsconfig ../../tsconfig.oxlint.json .",
|
||||
"test": "echo 'No tests yet.'",
|
||||
"watch": "yarn run build:dev -w",
|
||||
"build:dev": "ncc build ./src/index.ts -o build/",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "21.7.1"
|
||||
"@expo/steps": "22.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "21.0.0",
|
||||
"version": "22.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "21.0.0",
|
||||
"version": "22.0.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "21.6.0",
|
||||
"version": "22.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "21.0.0",
|
||||
"@expo/turtle-spawn": "22.0.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
+205
-182
File diff suppressed because it is too large.
Load diff
Generated
+1848
-61
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "21.8.0",
|
||||
"version": "22.2.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,11 +48,11 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "21.6.0",
|
||||
"@expo/eas-json": "21.6.0",
|
||||
"@expo/eas-build-job": "22.0.0",
|
||||
"@expo/eas-json": "22.0.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "21.0.0",
|
||||
"@expo/logger": "22.0.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "21.7.1",
|
||||
"@expo/steps": "22.1.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -72,7 +72,7 @@
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "4.0.11",
|
||||
"@urql/exchange-retry": "1.2.0",
|
||||
"agent-cli-detector": "0.1.2",
|
||||
"agent-cli-detector": "0.1.6",
|
||||
"ajv": "8.11.0",
|
||||
"ajv-formats": "2.1.1",
|
||||
"better-opn": "3.0.2",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { ensureActorHasPrimaryAccount } from '../../user/actions';
|
||||
import { detectProjectSdkVersionAsync } from '../../commands/go';
|
||||
import { detectProjectSdkVersionAsync, toRepackTargetSdkVersion } from '../../commands/go';
|
||||
import { mockTestCommand } from './utils';
|
||||
|
||||
jest.mock('@expo/config', () => ({
|
||||
@@ -81,6 +81,25 @@ describe('detectProjectSdkVersionAsync', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('toRepackTargetSdkVersion', () => {
|
||||
it.each([
|
||||
['57', '57.0.0'],
|
||||
['57.0.0', '57.0.0'],
|
||||
['57.0.9', '57.0.0'],
|
||||
['57.0.0-canary-20260101', '57.0.0'],
|
||||
])('resolves %s to the %s repack target', (sdkVersion, expected) => {
|
||||
expect(toRepackTargetSdkVersion(sdkVersion)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns undefined when no version is requested', () => {
|
||||
expect(toRepackTargetSdkVersion(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the requested version unchanged when it holds no version number', () => {
|
||||
expect(toRepackTargetSdkVersion('latest')).toBe('latest');
|
||||
});
|
||||
});
|
||||
|
||||
const mockAccount = { id: 'account-id', name: 'testuser' };
|
||||
const mockActor = {
|
||||
__typename: 'User' as const,
|
||||
@@ -112,7 +131,7 @@ describe('Go command', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeCmd(argv: string[] = []) {
|
||||
function makeCmd(argv: string[] = []): Go {
|
||||
const ctx = {
|
||||
loggedIn: { actor: mockActor as any, graphqlClient: {} as any },
|
||||
analytics: {} as any,
|
||||
@@ -149,6 +168,17 @@ describe('Go command', () => {
|
||||
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('Auto-selected'));
|
||||
});
|
||||
|
||||
it('requests the repack configuration with the repack target SDK version', async () => {
|
||||
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
|
||||
await makeCmd(['--sdk-version', '57']).run();
|
||||
|
||||
expect(WorkflowRunQuery.expoGoRepackConfigurationAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ sdkVersion: '57.0.0' })
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for SDK version when no project config is found', async () => {
|
||||
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
jest.mocked(WorkflowRunQuery.expoGoSupportedSdkVersionsAsync).mockResolvedValue([
|
||||
|
||||
@@ -38,14 +38,9 @@ export function getMockAppFragment(): AppFragment {
|
||||
ownerAccount: {
|
||||
id: 'test-account-id',
|
||||
name: 'testuser',
|
||||
users: [
|
||||
{
|
||||
role: Role.Owner,
|
||||
actor: {
|
||||
id: 'test-user-id',
|
||||
},
|
||||
},
|
||||
],
|
||||
viewerUserPermission: {
|
||||
role: Role.Owner,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ describe(WorkflowLogView, () => {
|
||||
createdAt: '2022-01-01T00:00:00.000Z',
|
||||
updatedAt: '2022-01-01T00:00:00.000Z',
|
||||
isForIosSimulator: false,
|
||||
project: {
|
||||
app: {
|
||||
id: mockProjectId,
|
||||
__typename: 'App',
|
||||
name: 'App',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { computeFileMd5Base64Async } from '../uploads';
|
||||
|
||||
describe(computeFileMd5Base64Async, () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-cli-uploads-test-'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.remove(tmpDir);
|
||||
});
|
||||
|
||||
it('computes the base64 MD5 checksum of a small file', async () => {
|
||||
const file = path.join(tmpDir, 'small.txt');
|
||||
await fs.writeFile(file, 'abc');
|
||||
// md5("abc") = 900150983cd24fb0d6963f7d28e17f72
|
||||
expect(await computeFileMd5Base64Async(file)).toBe('kAFQmDzST7DWlj99KOF/cg==');
|
||||
});
|
||||
|
||||
it('computes the same checksum as a one-shot hash for a file larger than one stream chunk', async () => {
|
||||
const file = path.join(tmpDir, 'large.bin');
|
||||
const data = Buffer.alloc(256 * 1024);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
data[i] = i % 251;
|
||||
}
|
||||
await fs.writeFile(file, data);
|
||||
const expected = crypto.createHash('md5').update(data).digest('base64');
|
||||
expect(await computeFileMd5Base64Async(file)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,7 @@ function createMockBuildFragment({
|
||||
id: buildId ?? uuid(),
|
||||
logFiles: [],
|
||||
priority: BuildPriority.Normal,
|
||||
project: {
|
||||
app: {
|
||||
__typename: 'App',
|
||||
slug: 'test-project',
|
||||
id: projectId,
|
||||
|
||||
@@ -74,7 +74,7 @@ function createMockBuildFragment({
|
||||
platform: AppPlatform.Android,
|
||||
id: buildId ?? uuid(),
|
||||
priority: BuildPriority.Normal,
|
||||
project: {
|
||||
app: {
|
||||
__typename: 'App',
|
||||
slug: 'test-project',
|
||||
id: projectId,
|
||||
|
||||
@@ -76,7 +76,6 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
customWorkflowName: ctx.customBuildConfigMetadata?.workflowName,
|
||||
developmentClient: ctx.developmentClient,
|
||||
requiredPackageManager: ctx.requiredPackageManager ?? undefined,
|
||||
selectedImage: ctx.buildProfile.image,
|
||||
customNodeVersion: ctx.buildProfile.node,
|
||||
simulator: 'simulator' in ctx.buildProfile && ctx.buildProfile.simulator,
|
||||
};
|
||||
|
||||
@@ -597,7 +597,7 @@ export async function downloadAndRunAsync(
|
||||
simulator?: SimulatorRunTarget
|
||||
): Promise<void> {
|
||||
assert(build.artifacts?.applicationArchiveUrl);
|
||||
const cachedAppPath = getEasBuildRunCachedAppPath(build.project.id, build.id, build.platform);
|
||||
const cachedAppPath = getEasBuildRunCachedAppPath(build.app.id, build.id, build.platform);
|
||||
|
||||
if (await pathExists(cachedAppPath)) {
|
||||
Log.newLine();
|
||||
|
||||
@@ -93,7 +93,7 @@ export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
},
|
||||
{
|
||||
label: 'Channel',
|
||||
value: build.channel,
|
||||
value: build.updateChannel?.name,
|
||||
},
|
||||
{
|
||||
label: 'SDK Version',
|
||||
@@ -101,7 +101,7 @@ export function formatGraphQLBuild(build: BuildFragment): string {
|
||||
},
|
||||
{
|
||||
label: 'Runtime Version',
|
||||
value: build.runtimeVersion,
|
||||
value: build.runtime?.version,
|
||||
},
|
||||
{
|
||||
label: 'Version',
|
||||
|
||||
@@ -19,13 +19,10 @@ export function getProjectPageUrl(
|
||||
}
|
||||
|
||||
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
|
||||
const { project } = build;
|
||||
const url =
|
||||
project.__typename !== 'App'
|
||||
? `/builds/${build.id}`
|
||||
: `/accounts/${project.ownerAccount.name}/projects/${project.slug}/builds/${build.id}${
|
||||
hash ? `#${hash}` : ''
|
||||
}`;
|
||||
const { app } = build;
|
||||
const url = `/accounts/${app.ownerAccount.name}/projects/${app.slug}/builds/${build.id}${
|
||||
hash ? `#${hash}` : ''
|
||||
}`;
|
||||
|
||||
return new URL(url, getExpoWebsiteBaseUrl()).toString();
|
||||
}
|
||||
@@ -37,7 +34,7 @@ export function getArtifactUrl(artifactId: string): string {
|
||||
export function getInternalDistributionInstallUrl(build: BuildFragment): string {
|
||||
if (build.platform === AppPlatform.Ios) {
|
||||
return `itms-services://?action=download-manifest;url=${getExpoApiBaseUrl()}/v2/projects/${
|
||||
build.project.id
|
||||
build.app.id
|
||||
}/builds/${build.id}/manifest.plist`;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export const testUpdateBranch1: UpdateBranchObject = {
|
||||
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
|
||||
message: 'fix bug',
|
||||
createdAt: '2023-07-17T22:48:59.278Z',
|
||||
runtimeVersion: 'exposdk:48.0.0',
|
||||
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
|
||||
platform: 'ios',
|
||||
manifestFragment:
|
||||
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"3d0ac172fe41ab941a8c68cd7e3a17ee","fileSHA256":"jjnar-ZPrb_g0Bx5tcFfjJrP3v8HdeAlXMk__xN7I1U","storageKey":"bimf-UXq5IFH5nNHCIHdcQ2MyJwTnwDVBb-P8hr2CZw","contentType":"application/javascript","fileExtension":".bundle"}}',
|
||||
@@ -37,7 +37,7 @@ export const testUpdateBranch1: UpdateBranchObject = {
|
||||
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
|
||||
message: 'fix bug',
|
||||
createdAt: '2023-07-17T22:48:59.278Z',
|
||||
runtimeVersion: 'exposdk:48.0.0',
|
||||
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
|
||||
platform: 'android',
|
||||
manifestFragment:
|
||||
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"4cc2ee49988e438ac066827b809721de","fileSHA256":"ctfK72f8EdS1IQmDvJLya38RMSma16beJcMoQToZIe0","storageKey":"QdEoKuCQPvVPA71gY52wqJ9vmtV4ObnCcPE2FP4SIdo","contentType":"application/javascript","fileExtension":".bundle"}}',
|
||||
@@ -73,7 +73,7 @@ export const testUpdateBranch2: UpdateBranchObject = {
|
||||
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
|
||||
message: 'fix bug',
|
||||
createdAt: '2023-06-23T23:37:10.004Z',
|
||||
runtimeVersion: 'exposdk:48.0.0',
|
||||
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
|
||||
platform: 'ios',
|
||||
manifestFragment:
|
||||
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"76ec49faddaef30ba78fe50edc5ab3d6","fileSHA256":"OLUdgP-fZI2shwGS5qlIk7nWnybL_13RJl5xR85CDBU","storageKey":"OCvI0-34zApbdo3eSGfIaQUbWdZeLeKH-nJSeedKTFs","contentType":"application/javascript","fileExtension":".bundle"}}',
|
||||
@@ -99,7 +99,7 @@ export const testUpdateBranch2: UpdateBranchObject = {
|
||||
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
|
||||
message: 'fix bug',
|
||||
createdAt: '2023-06-23T23:37:10.004Z',
|
||||
runtimeVersion: 'exposdk:48.0.0',
|
||||
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
|
||||
platform: 'android',
|
||||
manifestFragment:
|
||||
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"f4c6981626dbabcf51078d07a1611f4e","fileSHA256":"q-k1hSWl49aXpSg4jOSaVVnYFSFL6xf7HVl_TQE-ObM","storageKey":"FwK1PaZqtpL0QZIgaHsxU1AV93JhMdu9K1wZizXROAc","contentType":"application/javascript","fileExtension":".bundle"}}',
|
||||
|
||||
@@ -46,7 +46,11 @@ function makeInsights(
|
||||
id: u.id,
|
||||
group: u.group,
|
||||
message: u.message,
|
||||
runtimeVersion: u.runtimeVersion,
|
||||
runtime: {
|
||||
__typename: 'Runtime' as const,
|
||||
id: `${u.id}-runtime`,
|
||||
version: u.runtimeVersion,
|
||||
},
|
||||
platform: u.platform,
|
||||
insights: {
|
||||
__typename: 'UpdateInsights' as const,
|
||||
|
||||
+8
-8
@@ -48,18 +48,18 @@ describe(getProjectIdAsync, () => {
|
||||
primaryAccount: {
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
{
|
||||
id: 'account_id_2',
|
||||
name: 'dominik',
|
||||
users: [{ role: Role.ViewOnly, actor: { id: 'user_id' } }],
|
||||
viewerUserPermission: { role: Role.ViewOnly },
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
@@ -198,12 +198,12 @@ describe(getProjectIdAsync, () => {
|
||||
{
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Admin, actor: { id: 'robot_id' } }],
|
||||
viewerUserPermission: { role: Role.Admin },
|
||||
},
|
||||
{
|
||||
id: 'account_id_2',
|
||||
name: 'dominik',
|
||||
users: [{ role: Role.ViewOnly, actor: { id: 'robot_id' } }],
|
||||
viewerUserPermission: { role: Role.ViewOnly },
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
@@ -423,13 +423,13 @@ describe(getProjectIdAsync, () => {
|
||||
primaryAccount: {
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
@@ -465,7 +465,7 @@ describe(getProjectIdAsync, () => {
|
||||
{
|
||||
id: 'account_id_1',
|
||||
name: 'notnotbrent',
|
||||
users: [{ role: Role.Admin, actor: { id: 'robot_id' } }],
|
||||
viewerUserPermission: { role: Role.Admin },
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ExpoConfig } from '@expo/config';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import { getEASUpdateURL } from '../../../api';
|
||||
import { AppFragment } from '../../../graphql/generated';
|
||||
import { AppFragment, Role } from '../../../graphql/generated';
|
||||
import { easCliVersion } from '../../../utils/easCli';
|
||||
import {
|
||||
cleanAndPrefix,
|
||||
@@ -62,7 +62,7 @@ describe('projectFiles', () => {
|
||||
ownerAccount: {
|
||||
id: 'testowner-account-id',
|
||||
name: 'testowner',
|
||||
users: [],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
@@ -126,7 +126,7 @@ describe('projectFiles', () => {
|
||||
ownerAccount: {
|
||||
id: 'test-owner-account-id',
|
||||
name: 'test-owner',
|
||||
users: [],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -151,7 +151,7 @@ describe('projectFiles', () => {
|
||||
ownerAccount: {
|
||||
id: 'test-owner-123-account-id',
|
||||
name: 'test-owner-123',
|
||||
users: [],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -80,8 +80,7 @@ function getAccountPermissionsMap(actor: Actor): Map<string, boolean> {
|
||||
const permissionsMap = new Map<string, boolean>();
|
||||
|
||||
for (const account of actor.accounts) {
|
||||
const hasPermission =
|
||||
account.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly;
|
||||
const hasPermission = account.viewerUserPermission.role !== Role.ViewOnly;
|
||||
permissionsMap.set(account.name, hasPermission);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { Role } from '../../graphql/generated';
|
||||
@@ -52,7 +51,7 @@ export default class AccountView extends EasCommand {
|
||||
return Role.Owner;
|
||||
}
|
||||
|
||||
return nullthrows(account.users.find(user => user.actor.id === actor.id)?.role);
|
||||
return account.viewerUserPermission.role;
|
||||
}
|
||||
|
||||
private static getLabelForRole(role: Role): string {
|
||||
|
||||
@@ -27,8 +27,8 @@ async function cancelBuildAsync(
|
||||
.mutation<CancelBuildMutation, CancelBuildMutationVariables>(
|
||||
gql`
|
||||
mutation CancelBuildMutation($buildId: ID!) {
|
||||
build(buildId: $buildId) {
|
||||
cancel {
|
||||
build {
|
||||
cancelBuild(buildId: $buildId) {
|
||||
id
|
||||
status
|
||||
}
|
||||
@@ -39,7 +39,7 @@ async function cancelBuildAsync(
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.build.cancel;
|
||||
return data.build.cancelBuild;
|
||||
}
|
||||
|
||||
export async function selectBuildToCancelAsync(
|
||||
|
||||
@@ -235,7 +235,7 @@ export default class Download extends EasCommand {
|
||||
|
||||
async getPathToBuildArtifactAsync(build: BuildFragment, platform: AppPlatform): Promise<string> {
|
||||
const cachedBuildArtifactPath = getEasBuildRunCachedAppPath(
|
||||
build.project.id,
|
||||
build.app.id,
|
||||
build.id,
|
||||
platform
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ const insightsResponse = {
|
||||
id: 'u1',
|
||||
group: 'group-a',
|
||||
message: 'first',
|
||||
runtimeVersion: '1.0.0',
|
||||
runtime: { __typename: 'Runtime' as const, id: 'u1-runtime', version: '1.0.0' },
|
||||
platform: 'ios',
|
||||
insights: { __typename: 'UpdateInsights' as const, id: 'u1-i', totalUniqueUsers: 200 },
|
||||
},
|
||||
|
||||
@@ -54,6 +54,18 @@ type FingerprintOrigin = {
|
||||
update?: UpdateFragment;
|
||||
};
|
||||
|
||||
type FingerprintInfo = {
|
||||
fingerprint: Fingerprint;
|
||||
platforms?: AppPlatform[];
|
||||
origin: FingerprintOrigin;
|
||||
/**
|
||||
* Whether the uploaded fingerprint source file could not be downloaded or parsed
|
||||
* (e.g. it expired in GCS or is zero-length). The hash is still valid in that case,
|
||||
* but `fingerprint.sources` is empty and a detailed diff cannot be computed.
|
||||
*/
|
||||
isFingerprintSourceUnavailable: boolean;
|
||||
};
|
||||
|
||||
export default class FingerprintCompare extends EasCommand {
|
||||
static override description = 'compare fingerprints of the current project, builds, and updates';
|
||||
static override strict = false;
|
||||
@@ -170,7 +182,15 @@ export default class FingerprintCompare extends EasCommand {
|
||||
const { fingerprint: secondFingerprint, origin: secondFingerprintOrigin } =
|
||||
secondFingerprintInfo;
|
||||
|
||||
const unavailableSourceHashes = [
|
||||
firstFingerprintInfo.isFingerprintSourceUnavailable ? firstFingerprint.hash : null,
|
||||
secondFingerprintInfo.isFingerprintSourceUnavailable ? secondFingerprint.hash : null,
|
||||
].filter((hash): hash is string => hash !== null);
|
||||
|
||||
if (json) {
|
||||
if (unavailableSourceHashes.length > 0) {
|
||||
throw new Error(getUnavailableSourceMessage(unavailableSourceHashes));
|
||||
}
|
||||
printJsonOnlyOutput({ fingerprint1: firstFingerprint, fingerprint2: secondFingerprint });
|
||||
return;
|
||||
}
|
||||
@@ -190,55 +210,62 @@ export default class FingerprintCompare extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
const fingerprintDiffs = diffFingerprint(projectDir, firstFingerprint, secondFingerprint);
|
||||
if (!fingerprintDiffs) {
|
||||
Log.error('Fingerprint diffs can only be computed for projects with SDK 52 or higher');
|
||||
return;
|
||||
}
|
||||
|
||||
const filePathDiffs = fingerprintDiffs.filter(diff => {
|
||||
let sourceType;
|
||||
if (diff.op === 'added') {
|
||||
sourceType = diff.addedSource.type;
|
||||
} else if (diff.op === 'removed') {
|
||||
sourceType = diff.removedSource.type;
|
||||
} else if (diff.op === 'changed') {
|
||||
sourceType = diff.beforeSource.type;
|
||||
}
|
||||
return sourceType === 'dir' || sourceType === 'file';
|
||||
});
|
||||
if (filePathDiffs.length > 0) {
|
||||
if (unavailableSourceHashes.length > 0) {
|
||||
Log.newLine();
|
||||
Log.log('📁 Paths with native dependencies:');
|
||||
}
|
||||
const fields = [];
|
||||
for (const diff of filePathDiffs) {
|
||||
const field = getDiffFilePathFields(diff);
|
||||
if (!field) {
|
||||
throw new Error(`Unsupported diff: ${JSON.stringify(diff)}`);
|
||||
Log.warn(
|
||||
`A detailed diff cannot be shown. ${getUnavailableSourceMessage(unavailableSourceHashes)}`
|
||||
);
|
||||
} else {
|
||||
const fingerprintDiffs = diffFingerprint(projectDir, firstFingerprint, secondFingerprint);
|
||||
if (!fingerprintDiffs) {
|
||||
Log.error('Fingerprint diffs can only be computed for projects with SDK 52 or higher');
|
||||
return;
|
||||
}
|
||||
fields.push(field);
|
||||
}
|
||||
Log.log(
|
||||
formatFields(fields, {
|
||||
labelFormat: label => ` ${chalk.dim(label)}:`,
|
||||
})
|
||||
);
|
||||
|
||||
const contentDiffs = fingerprintDiffs.filter(diff => {
|
||||
let sourceType;
|
||||
if (diff.op === 'added') {
|
||||
sourceType = diff.addedSource.type;
|
||||
} else if (diff.op === 'removed') {
|
||||
sourceType = diff.removedSource.type;
|
||||
} else if (diff.op === 'changed') {
|
||||
sourceType = diff.beforeSource.type;
|
||||
const filePathDiffs = fingerprintDiffs.filter(diff => {
|
||||
let sourceType;
|
||||
if (diff.op === 'added') {
|
||||
sourceType = diff.addedSource.type;
|
||||
} else if (diff.op === 'removed') {
|
||||
sourceType = diff.removedSource.type;
|
||||
} else if (diff.op === 'changed') {
|
||||
sourceType = diff.beforeSource.type;
|
||||
}
|
||||
return sourceType === 'dir' || sourceType === 'file';
|
||||
});
|
||||
if (filePathDiffs.length > 0) {
|
||||
Log.newLine();
|
||||
Log.log('📁 Paths with native dependencies:');
|
||||
}
|
||||
return sourceType === 'contents';
|
||||
});
|
||||
const fields = [];
|
||||
for (const diff of filePathDiffs) {
|
||||
const field = getDiffFilePathFields(diff);
|
||||
if (!field) {
|
||||
throw new Error(`Unsupported diff: ${JSON.stringify(diff)}`);
|
||||
}
|
||||
fields.push(field);
|
||||
}
|
||||
Log.log(
|
||||
formatFields(fields, {
|
||||
labelFormat: label => ` ${chalk.dim(label)}:`,
|
||||
})
|
||||
);
|
||||
|
||||
for (const diff of contentDiffs) {
|
||||
printContentDiff(diff);
|
||||
const contentDiffs = fingerprintDiffs.filter(diff => {
|
||||
let sourceType;
|
||||
if (diff.op === 'added') {
|
||||
sourceType = diff.addedSource.type;
|
||||
} else if (diff.op === 'removed') {
|
||||
sourceType = diff.removedSource.type;
|
||||
} else if (diff.op === 'changed') {
|
||||
sourceType = diff.beforeSource.type;
|
||||
}
|
||||
return sourceType === 'contents';
|
||||
});
|
||||
|
||||
for (const diff of contentDiffs) {
|
||||
printContentDiff(diff);
|
||||
}
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
@@ -285,12 +312,8 @@ async function getFingerprintInfoAsync(
|
||||
environmentForProjectFingerprint?: string;
|
||||
nonInteractive: boolean;
|
||||
},
|
||||
firstFingerprintInfo?: {
|
||||
fingerprint: Fingerprint;
|
||||
platforms?: AppPlatform[];
|
||||
origin: FingerprintOrigin;
|
||||
}
|
||||
): Promise<{ fingerprint: Fingerprint; origin: FingerprintOrigin }> {
|
||||
firstFingerprintInfo?: FingerprintInfo
|
||||
): Promise<FingerprintInfo> {
|
||||
if (hash) {
|
||||
return await getFingerprintInfoFromHashAsync(graphqlClient, projectId, hash);
|
||||
} else if (updateId) {
|
||||
@@ -349,13 +372,9 @@ async function getFingerprintInfoInteractiveAsync({
|
||||
projectId: string;
|
||||
vcsClient: Client;
|
||||
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn;
|
||||
firstFingerprintInfo?: {
|
||||
fingerprint: Fingerprint;
|
||||
platforms?: AppPlatform[];
|
||||
origin: FingerprintOrigin;
|
||||
};
|
||||
firstFingerprintInfo?: FingerprintInfo;
|
||||
environmentForProjectFingerprint?: string;
|
||||
}): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
}): Promise<FingerprintInfo> {
|
||||
const prompt = firstFingerprintInfo
|
||||
? 'Select the second fingerprint to compare against'
|
||||
: 'Select a reference fingerprint for comparison';
|
||||
@@ -449,13 +468,9 @@ async function getFingerprintInfoFromLocalProjectAsync({
|
||||
projectId: string;
|
||||
vcsClient: Client;
|
||||
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn;
|
||||
firstFingerprintInfo: {
|
||||
fingerprint: Fingerprint;
|
||||
platforms?: AppPlatform[];
|
||||
origin: FingerprintOrigin;
|
||||
};
|
||||
firstFingerprintInfo: FingerprintInfo;
|
||||
environment?: string;
|
||||
}): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
}): Promise<FingerprintInfo> {
|
||||
const firstFingerprintPlatforms = firstFingerprintInfo.platforms;
|
||||
if (!firstFingerprintPlatforms || firstFingerprintPlatforms.length === 0) {
|
||||
throw new Error(
|
||||
@@ -477,22 +492,27 @@ async function getFingerprintInfoFromLocalProjectAsync({
|
||||
firstFingerprintPlatforms,
|
||||
{ env }
|
||||
);
|
||||
return { fingerprint, origin: { type: FingerprintOriginType.Project } };
|
||||
return {
|
||||
fingerprint,
|
||||
origin: { type: FingerprintOriginType.Project },
|
||||
isFingerprintSourceUnavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function getFingerprintFromUpdateFragmentAsync(
|
||||
updateWithFingerprint: UpdateFragment
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
): Promise<FingerprintInfo> {
|
||||
if (!updateWithFingerprint.fingerprint) {
|
||||
throw new Error(`Fingerprint for update ${updateWithFingerprint.id} was not computed.`);
|
||||
} else if (!updateWithFingerprint.fingerprint.debugInfoUrl) {
|
||||
throw new Error(`Fingerprint source for update ${updateWithFingerprint.id} was not computed.`);
|
||||
}
|
||||
|
||||
const { fingerprint, isFingerprintSourceUnavailable } =
|
||||
await getFingerprintFromFingerprintFragmentAsync(updateWithFingerprint.fingerprint);
|
||||
return {
|
||||
fingerprint: await getFingerprintFromFingerprintFragmentAsync(
|
||||
updateWithFingerprint.fingerprint
|
||||
),
|
||||
fingerprint,
|
||||
isFingerprintSourceUnavailable,
|
||||
platforms: [stringToAppPlatform(updateWithFingerprint.platform)],
|
||||
origin: {
|
||||
type: FingerprintOriginType.Update,
|
||||
@@ -505,13 +525,14 @@ async function getFingerprintInfoFromHashAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
hash: string
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
): Promise<FingerprintInfo> {
|
||||
const fingerprintFragment = await getFingerprintFragmentFromHashAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
hash
|
||||
);
|
||||
const fingerprint = await getFingerprintFromFingerprintFragmentAsync(fingerprintFragment);
|
||||
const { fingerprint, isFingerprintSourceUnavailable } =
|
||||
await getFingerprintFromFingerprintFragmentAsync(fingerprintFragment);
|
||||
let platforms;
|
||||
const fingerprintBuilds = fingerprintFragment.builds?.edges.map(edge => edge.node) ?? [];
|
||||
const fingerprintUpdates = fingerprintFragment.updates?.edges.map(edge => edge.node) ?? [];
|
||||
@@ -522,6 +543,7 @@ async function getFingerprintInfoFromHashAsync(
|
||||
}
|
||||
return {
|
||||
fingerprint,
|
||||
isFingerprintSourceUnavailable,
|
||||
platforms,
|
||||
origin: {
|
||||
type: FingerprintOriginType.Hash,
|
||||
@@ -534,7 +556,7 @@ async function getFingerprintInfoFromUpdateGroupIdOrUpdateIdAsync(
|
||||
projectId: string,
|
||||
nonInteractive: boolean,
|
||||
updateGroupIdOrUpdateId: string
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
): Promise<FingerprintInfo> {
|
||||
// Some people may pass in update group id instead of update id, so add interactive support for that
|
||||
try {
|
||||
const maybeUpdateGroupId = updateGroupIdOrUpdateId;
|
||||
@@ -578,15 +600,18 @@ async function getFingerprintInfoFromUpdateGroupIdOrUpdateIdAsync(
|
||||
async function getFingerprintInfoFromBuildIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
buildId: string
|
||||
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
|
||||
): Promise<FingerprintInfo> {
|
||||
const buildWithFingerprint = await BuildQuery.withFingerprintByIdAsync(graphqlClient, buildId);
|
||||
if (!buildWithFingerprint.fingerprint) {
|
||||
throw new Error(`Fingerprint for build ${buildId} was not computed.`);
|
||||
} else if (!buildWithFingerprint.fingerprint.debugInfoUrl) {
|
||||
throw new Error(`Fingerprint source for build ${buildId} was not computed.`);
|
||||
}
|
||||
const { fingerprint, isFingerprintSourceUnavailable } =
|
||||
await getFingerprintFromFingerprintFragmentAsync(buildWithFingerprint.fingerprint);
|
||||
return {
|
||||
fingerprint: await getFingerprintFromFingerprintFragmentAsync(buildWithFingerprint.fingerprint),
|
||||
fingerprint,
|
||||
isFingerprintSourceUnavailable,
|
||||
platforms: [buildWithFingerprint.platform],
|
||||
origin: {
|
||||
type: FingerprintOriginType.Build,
|
||||
@@ -611,17 +636,48 @@ async function getFingerprintFragmentFromHashAsync(
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
async function fetchFingerprintSourceAsync(fingerprintDebugUrl: string): Promise<Fingerprint> {
|
||||
const fingerprintResponse = await fetch(fingerprintDebugUrl);
|
||||
if (!fingerprintResponse.ok) {
|
||||
throw new Error(`Fingerprint source request failed: ${fingerprintResponse.status}`);
|
||||
}
|
||||
const fingerprintSource = (await fingerprintResponse.json()) as Partial<Fingerprint> | null;
|
||||
if (!Array.isArray(fingerprintSource?.sources)) {
|
||||
throw new Error('Fingerprint source file does not contain a list of sources');
|
||||
}
|
||||
return fingerprintSource as Fingerprint;
|
||||
}
|
||||
|
||||
async function getFingerprintFromFingerprintFragmentAsync(
|
||||
fingerprintFragment: Pick<FingerprintFragment, 'debugInfoUrl' | 'hash'>
|
||||
): Promise<Fingerprint> {
|
||||
): Promise<{ fingerprint: Fingerprint; isFingerprintSourceUnavailable: boolean }> {
|
||||
const fingerprintDebugUrl = fingerprintFragment.debugInfoUrl;
|
||||
if (!fingerprintDebugUrl) {
|
||||
throw new Error(
|
||||
`The source for fingerprint hash ${fingerprintFragment.hash} was not computed.`
|
||||
);
|
||||
}
|
||||
const fingerprintResponse = await fetch(fingerprintDebugUrl);
|
||||
return (await fingerprintResponse.json()) as Fingerprint;
|
||||
try {
|
||||
return {
|
||||
fingerprint: await fetchFingerprintSourceAsync(fingerprintDebugUrl),
|
||||
isFingerprintSourceUnavailable: false,
|
||||
};
|
||||
} catch (error) {
|
||||
// Uploaded source files expire after a period of time or may be malformed
|
||||
// (e.g. zero-length). The hash from the API is still valid, so fall back to a
|
||||
// sourceless fingerprint instead of failing the whole comparison.
|
||||
Log.debug(`Failed to fetch source for fingerprint hash ${fingerprintFragment.hash}`, error);
|
||||
return {
|
||||
fingerprint: { hash: fingerprintFragment.hash, sources: [] },
|
||||
isFingerprintSourceUnavailable: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getUnavailableSourceMessage(hashes: string[]): string {
|
||||
return hashes.length === 1
|
||||
? `The uploaded source file for fingerprint ${hashes[0]} has expired or is invalid.`
|
||||
: `The uploaded source files for fingerprints ${hashes.join(' and ')} have expired or are invalid.`;
|
||||
}
|
||||
|
||||
function printContentDiff(diff: FingerprintDiffItem): void {
|
||||
|
||||
@@ -5,6 +5,7 @@ import chalk from 'chalk';
|
||||
import * as fs from 'fs-extra';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import semver from 'semver';
|
||||
import { Analytics } from '../analytics/AnalyticsManager';
|
||||
import EasCommand from '../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
@@ -61,6 +62,11 @@ export async function detectProjectSdkVersionAsync(
|
||||
}
|
||||
}
|
||||
|
||||
export function toRepackTargetSdkVersion(sdkVersion: string | undefined): string | undefined {
|
||||
const coerced = semver.coerce(sdkVersion);
|
||||
return coerced ? `${coerced.major}.0.0` : sdkVersion;
|
||||
}
|
||||
|
||||
async function setupTestFlightAsync(ascApp: App): Promise<void> {
|
||||
await ensureTestFlightGroupExistsAsync(ascApp);
|
||||
}
|
||||
@@ -120,7 +126,8 @@ export default class Go extends EasCommand {
|
||||
default: 'My Expo Go',
|
||||
}),
|
||||
'sdk-version': Flags.string({
|
||||
description: 'Expo Go SDK version to prepare (default: latest)',
|
||||
description:
|
||||
'Expo Go SDK version to prepare, for example 57 (default: the SDK version of the current project)',
|
||||
required: false,
|
||||
}),
|
||||
credentials: Flags.boolean({
|
||||
@@ -157,7 +164,7 @@ export default class Go extends EasCommand {
|
||||
`Current project using SDK ${detectedSdkVersion.split('.')[0]}. Auto-selected same version. To use a different version, pass --sdk-version.`
|
||||
);
|
||||
}
|
||||
let sdkVersion = flags['sdk-version'] ?? detectedSdkVersion;
|
||||
let sdkVersion = toRepackTargetSdkVersion(flags['sdk-version'] ?? detectedSdkVersion);
|
||||
if (!sdkVersion) {
|
||||
({ sdkVersion } = await this.selectSdkVersionAsync(graphqlClient));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppStoreConnectApiKeyQuery } from '../../../../credentials/ios/api/grap
|
||||
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
|
||||
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
|
||||
import IntegrationsAscConnect from '../connect';
|
||||
import { Role } from '../../../../graphql/generated';
|
||||
|
||||
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
|
||||
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
|
||||
@@ -18,7 +19,12 @@ const testProjectId = 'test-project-id';
|
||||
const mockMetadataConnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
ownerAccount: {
|
||||
id: 'account-id',
|
||||
name: 'testuser',
|
||||
ownerUserActor: null,
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
appStoreConnectApp: {
|
||||
id: 'asc-app-link-id',
|
||||
ascAppIdentifier: '1234567890',
|
||||
@@ -34,7 +40,12 @@ const mockMetadataConnected = {
|
||||
const mockMetadataDisconnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
ownerAccount: {
|
||||
id: 'account-id',
|
||||
name: 'testuser',
|
||||
ownerUserActor: null,
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../../log';
|
||||
import { toggleConfirmAsync } from '../../../../prompts';
|
||||
import IntegrationsAscDisconnect from '../disconnect';
|
||||
import { Role } from '../../../../graphql/generated';
|
||||
|
||||
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
|
||||
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
|
||||
@@ -19,7 +20,12 @@ const testProjectId = 'test-project-id';
|
||||
const mockMetadataConnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
ownerAccount: {
|
||||
id: 'account-id',
|
||||
name: 'testuser',
|
||||
ownerUserActor: null,
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
appStoreConnectApp: {
|
||||
id: 'asc-app-link-id',
|
||||
ascAppIdentifier: '1234567890',
|
||||
@@ -35,7 +41,12 @@ const mockMetadataConnected = {
|
||||
const mockMetadataDisconnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
ownerAccount: {
|
||||
id: 'account-id',
|
||||
name: 'testuser',
|
||||
ownerUserActor: null,
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils
|
||||
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
|
||||
import Log from '../../../../log';
|
||||
import IntegrationsAscStatus from '../status';
|
||||
import { Role } from '../../../../graphql/generated';
|
||||
|
||||
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
|
||||
jest.mock('../../../../log');
|
||||
@@ -15,7 +16,12 @@ const testProjectId = 'test-project-id';
|
||||
const mockMetadataConnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
ownerAccount: {
|
||||
id: 'account-id',
|
||||
name: 'testuser',
|
||||
ownerUserActor: null,
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
appStoreConnectApp: {
|
||||
id: 'asc-app-link-id',
|
||||
ascAppIdentifier: '1234567890',
|
||||
@@ -31,7 +37,12 @@ const mockMetadataConnected = {
|
||||
const mockMetadataDisconnected = {
|
||||
id: testProjectId,
|
||||
fullName: '@testuser/testapp',
|
||||
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
|
||||
ownerAccount: {
|
||||
id: 'account-id',
|
||||
name: 'testuser',
|
||||
ownerUserActor: null,
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
appStoreConnectApp: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
Role,
|
||||
} from '../../../../graphql/generated';
|
||||
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
|
||||
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
|
||||
@@ -59,7 +60,7 @@ describe(IntegrationsConvexConnect, () => {
|
||||
id: testAccountId,
|
||||
name: testAccountName,
|
||||
ownerUserActor: null,
|
||||
users: [],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
@@ -76,7 +77,7 @@ describe(IntegrationsConvexConnect, () => {
|
||||
id: testAccountId,
|
||||
name: testAccountName,
|
||||
ownerUserActor: { id: 'test-user-id', username: testAccountName },
|
||||
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
|
||||
viewerUserPermission: { role: 'OWNER' as any },
|
||||
};
|
||||
|
||||
const mockConnection: ConvexTeamConnectionData = {
|
||||
|
||||
@@ -32,7 +32,7 @@ describe(IntegrationsConvexTeamDelete, () => {
|
||||
id: testAccountId,
|
||||
name: testAccountName,
|
||||
ownerUserActor: { id: 'test-user-id', username: testAccountName },
|
||||
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
|
||||
viewerUserPermission: { role: 'OWNER' as any },
|
||||
};
|
||||
|
||||
const mockConnection: ConvexTeamConnectionData = {
|
||||
|
||||
@@ -41,7 +41,7 @@ describe(IntegrationsConvexTeamInvite, () => {
|
||||
id: testAccountId,
|
||||
name: testAccountName,
|
||||
ownerUserActor: { id: 'test-user-id', username: testAccountName },
|
||||
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
|
||||
viewerUserPermission: { role: 'OWNER' as any },
|
||||
};
|
||||
|
||||
const mockConnection: ConvexTeamConnectionData = {
|
||||
|
||||
@@ -21,7 +21,7 @@ describe(IntegrationsConvexTeam, () => {
|
||||
id: testAccountId,
|
||||
name: testAccountName,
|
||||
ownerUserActor: { id: 'test-user-id', username: testAccountName },
|
||||
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
|
||||
viewerUserPermission: { role: 'OWNER' as any },
|
||||
};
|
||||
|
||||
const mockConnection: ConvexTeamConnectionData = {
|
||||
|
||||
@@ -62,7 +62,12 @@ describe(IntegrationsPostHogConnect, () => {
|
||||
email: '[email protected]',
|
||||
featureGates: {},
|
||||
isExpoAdmin: false,
|
||||
primaryAccount: { id: testAccountId, name: testAccountName, ownerUserActor: null, users: [] },
|
||||
primaryAccount: {
|
||||
id: testAccountId,
|
||||
name: testAccountName,
|
||||
ownerUserActor: null,
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
@@ -70,7 +75,7 @@ describe(IntegrationsPostHogConnect, () => {
|
||||
id: testAccountId,
|
||||
name: testAccountName,
|
||||
ownerUserActor: { id: 'test-user-id', username: testAccountName },
|
||||
users: [{ role: Role.Owner, actor: { id: 'test-user-id' } }],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
};
|
||||
|
||||
const mockConnection: PostHogOrganizationConnectionData = {
|
||||
|
||||
@@ -99,7 +99,7 @@ describe(IntegrationsSupabaseDashboard, () => {
|
||||
|
||||
it('fails the spinner when the browser cannot be opened', async () => {
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(false as never);
|
||||
const { ora } = jest.requireMock('../../../../ora') as { ora: jest.Mock };
|
||||
const { ora } = jest.requireMock('../../../../ora');
|
||||
const spinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
@@ -116,7 +116,7 @@ describe(IntegrationsSupabaseDashboard, () => {
|
||||
|
||||
it('fails the spinner and rethrows when opening the browser throws', async () => {
|
||||
jest.mocked(openBrowserAsync).mockRejectedValue(new Error('no browser'));
|
||||
const { ora } = jest.requireMock('../../../../ora') as { ora: jest.Mock };
|
||||
const { ora } = jest.requireMock('../../../../ora');
|
||||
const spinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
|
||||
@@ -52,12 +52,15 @@ describe(SimulatorAvailability, () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): SimulatorAvailability {
|
||||
function createCommand(
|
||||
argv: string[],
|
||||
{ isExpoAdmin = false }: { isExpoAdmin?: boolean } = {}
|
||||
): SimulatorAvailability {
|
||||
const command = new SimulatorAvailability(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
loggedIn: { actor: { isExpoAdmin }, graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
@@ -101,6 +104,19 @@ describe(SimulatorAvailability, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('emits JSON with available true for Expo admins when the account is gated', async () => {
|
||||
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
|
||||
|
||||
const command = createCommand(['--json'], { isExpoAdmin: true });
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByAppIdAsync).toHaveBeenCalledWith(graphqlClient, projectId);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
available: true,
|
||||
accountName: 'testuser',
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a graceful message pointing at the waitlist when not enabled', async () => {
|
||||
mockByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: false });
|
||||
|
||||
|
||||
@@ -252,15 +252,15 @@ describe(Simulator, () => {
|
||||
expect(fs.writeFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
|
||||
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER +
|
||||
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
|
||||
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
|
||||
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
|
||||
"AGENT_DEVICE_DAEMON_BASE_URL='https://agent.example.com'\n" +
|
||||
"AGENT_DEVICE_DAEMON_AUTH_TOKEN='token-123'\n" +
|
||||
`${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
|
||||
);
|
||||
expect(jest.mocked(fs.writeFile).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockByIdAsync.mock.invocationCallOrder[0]
|
||||
@@ -282,6 +282,51 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the Appium environment and capabilities to the simulator dotenv', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type: DeviceRunSessionType.Appium,
|
||||
platform: AppPlatform.Ios,
|
||||
remoteConfig: {
|
||||
__typename: 'AppiumRunSessionRemoteConfig',
|
||||
appiumUrl: 'https://appium.example.test',
|
||||
capabilities: {
|
||||
platformName: 'iOS',
|
||||
'appium:automationName': 'XCUITest',
|
||||
'appium:udid': 'simulator-id',
|
||||
},
|
||||
webPreviewUrl: 'https://preview.example.test',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--type',
|
||||
'appium',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ type: DeviceRunSessionType.Appium })
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
expect.stringContaining(
|
||||
`APPIUM_CAPS='{"platformName":"iOS","appium:automationName":"XCUITest","appium:udid":"simulator-id"}'`
|
||||
)
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('eas simulator:exec <appium-client> [args...]')
|
||||
);
|
||||
expect(Log.log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('https://appium.example.test')
|
||||
);
|
||||
});
|
||||
|
||||
it('overwrites .env.eas-simulator when outputting dotenv and the file exists', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
@@ -295,15 +340,15 @@ describe(Simulator, () => {
|
||||
expect(fs.writeFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
|
||||
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER +
|
||||
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
|
||||
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
|
||||
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
|
||||
"AGENT_DEVICE_DAEMON_BASE_URL='https://agent.example.com'\n" +
|
||||
"AGENT_DEVICE_DAEMON_AUTH_TOKEN='token-123'\n" +
|
||||
`${EAS_SIMULATOR_SESSION_ID}='session-123'\n`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -366,7 +411,7 @@ describe(Simulator, () => {
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
});
|
||||
|
||||
it('forwards --name to the create mutation', async () => {
|
||||
@@ -411,6 +456,48 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --device to the create mutation as deviceIdentifier', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--device',
|
||||
'iPhone 16 Pro',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ deviceIdentifier: 'iPhone 16 Pro' })
|
||||
);
|
||||
});
|
||||
|
||||
it('trims --device before sending it', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--device',
|
||||
' iPhone 16 Pro ',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ deviceIdentifier: 'iPhone 16 Pro' })
|
||||
);
|
||||
});
|
||||
|
||||
it('omits a blank --device', async () => {
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--device', ' ']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ deviceIdentifier: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the simulator session when interrupted before the session is ready', async () => {
|
||||
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
@@ -442,7 +529,7 @@ describe(Simulator, () => {
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(process.listeners('SIGINT')).toEqual([...existingSigintListeners]);
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -161,7 +161,7 @@ describe(SimulatorList, () => {
|
||||
'--status',
|
||||
'new',
|
||||
'--type',
|
||||
'argent',
|
||||
'appium',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--name',
|
||||
@@ -179,7 +179,7 @@ describe(SimulatorList, () => {
|
||||
after: 'page-cursor',
|
||||
filter: {
|
||||
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
|
||||
types: [DeviceRunSessionType.Argent],
|
||||
types: [DeviceRunSessionType.Appium],
|
||||
platforms: [AppPlatform.Ios],
|
||||
name: 'checkout',
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorStop from '../stop';
|
||||
@@ -18,6 +19,7 @@ jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
resetSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
@@ -40,6 +42,7 @@ const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked(
|
||||
);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeStoppedDeviceRunSession(
|
||||
@@ -70,6 +73,7 @@ describe(SimulatorStop, () => {
|
||||
jest.clearAllMocks();
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue(makeStoppedDeviceRunSession());
|
||||
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
|
||||
mockResetSimulatorEnvAsync.mockResolvedValue();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): {
|
||||
@@ -98,6 +102,7 @@ describe(SimulatorStop, () => {
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
status: DeviceRunSessionStatus.Stopped,
|
||||
@@ -120,6 +125,7 @@ describe(SimulatorStop, () => {
|
||||
graphqlClient,
|
||||
'session-from-env'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-from-env');
|
||||
} finally {
|
||||
if (previousDeviceRunSessionId === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
|
||||
@@ -37,7 +37,7 @@ export default class SimulatorAvailability extends EasCommand {
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
loggedIn: { actor, graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorAvailability, {
|
||||
nonInteractive,
|
||||
});
|
||||
@@ -50,6 +50,7 @@ export default class SimulatorAvailability extends EasCommand {
|
||||
graphqlClient,
|
||||
projectId
|
||||
));
|
||||
available ||= actor.isExpoAdmin;
|
||||
fetchSpinner?.stop();
|
||||
} catch (err) {
|
||||
fetchSpinner?.fail('Failed to check EAS Simulator availability');
|
||||
|
||||
@@ -68,6 +68,10 @@ export default class Simulator extends EasCommand {
|
||||
description:
|
||||
'Human-readable name for the simulator session, shown in eas simulator:list and on expo.dev. Defaults to unnamed.',
|
||||
}),
|
||||
device: Flags.string({
|
||||
description:
|
||||
'Virtual device to start for the session. On iOS, a Simulator device name or UDID (e.g. "iPhone 16 Pro"). On Android, an AVD hardware profile id (e.g. "pixel_7"). Defaults to a device chosen by the runner.',
|
||||
}),
|
||||
type: Flags.option({
|
||||
description: 'Type of simulator session to create',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
@@ -134,6 +138,7 @@ export default class Simulator extends EasCommand {
|
||||
// The server rejects blank names, so trim here and treat a whitespace-only
|
||||
// --name as if it had been omitted rather than surfacing a validation error.
|
||||
const name = flags.name?.trim() || undefined;
|
||||
const deviceIdentifier = flags.device?.trim() || undefined;
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const existingDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
@@ -165,6 +170,7 @@ export default class Simulator extends EasCommand {
|
||||
platform,
|
||||
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
|
||||
packageVersion: flags['package-version'],
|
||||
deviceIdentifier,
|
||||
maxRunTimeMinutes: flags['max-duration-minutes'],
|
||||
});
|
||||
deviceRunSessionId = session.id;
|
||||
@@ -400,7 +406,7 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
jobRunStatus === JobRunStatus.Finished
|
||||
) {
|
||||
spinner.succeed(`Simulator session ended. ${link(deviceRunSessionUrl)}`);
|
||||
await resetSimulatorEnvVerboseAsync(projectDir);
|
||||
await resetSimulatorEnvVerboseAsync(projectDir, deviceRunSessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -414,7 +420,7 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
);
|
||||
if (stopped) {
|
||||
spinner.succeed('Simulator session stopped');
|
||||
await resetSimulatorEnvVerboseAsync(projectDir);
|
||||
await resetSimulatorEnvVerboseAsync(projectDir, deviceRunSessionId);
|
||||
} else {
|
||||
spinner.fail(
|
||||
`Could not confirm the simulator session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
|
||||
@@ -484,7 +490,7 @@ async function stopDeviceRunSessionAfterInterruptAsync({
|
||||
);
|
||||
if (stopped) {
|
||||
spinner.succeed('Simulator session stopped');
|
||||
await resetSimulatorEnvVerboseAsync(projectDir);
|
||||
await resetSimulatorEnvVerboseAsync(projectDir, deviceRunSessionId);
|
||||
} else {
|
||||
spinner.fail(
|
||||
`Could not confirm the simulator session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
|
||||
@@ -496,9 +502,12 @@ async function stopDeviceRunSessionAfterInterruptAsync({
|
||||
process.exit(130);
|
||||
}
|
||||
|
||||
async function resetSimulatorEnvVerboseAsync(projectDir: string): Promise<void> {
|
||||
async function resetSimulatorEnvVerboseAsync(
|
||||
projectDir: string,
|
||||
deviceRunSessionId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await resetSimulatorEnvAsync(projectDir);
|
||||
await resetSimulatorEnvAsync(projectDir, deviceRunSessionId);
|
||||
} catch (err) {
|
||||
Log.error(`Failed to clean up ${SIMULATOR_DOTENV_FILE_NAME}`);
|
||||
throw err;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
@@ -68,6 +69,8 @@ export default class SimulatorStop extends EasCommand {
|
||||
throw err;
|
||||
}
|
||||
|
||||
await resetSimulatorEnvAsync(projectDir, flagId);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ id: session.id, status: session.status });
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const updateStub: UpdateFragment = {
|
||||
group: 'group-1234',
|
||||
branch: { id: 'branch-1234', name: 'main' },
|
||||
message: 'test message',
|
||||
runtimeVersion: 'exposdk:47.0.0',
|
||||
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
|
||||
platform: 'ios',
|
||||
gitCommitHash: 'commit',
|
||||
isGitWorkingTreeDirty: false,
|
||||
|
||||
@@ -30,7 +30,7 @@ const updateStub: UpdateFragment = {
|
||||
group: 'group-1234',
|
||||
branch: { id: 'branch-1234', name: 'main' },
|
||||
message: 'test message',
|
||||
runtimeVersion: 'exposdk:47.0.0',
|
||||
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
|
||||
platform: 'ios',
|
||||
gitCommitHash: 'commit',
|
||||
isGitWorkingTreeDirty: false,
|
||||
@@ -154,7 +154,7 @@ describe(UpdateRepublish.name, () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
branchId: updateStub.branch.id,
|
||||
runtimeVersion: updateStub.runtimeVersion,
|
||||
runtimeVersion: updateStub.runtime.version,
|
||||
updateInfoGroup: expect.objectContaining({
|
||||
ios: expect.any(Object),
|
||||
}),
|
||||
@@ -225,7 +225,7 @@ describe(UpdateRepublish.name, () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
branchId: updateStub.branch.id,
|
||||
runtimeVersion: updateStub.runtimeVersion,
|
||||
runtimeVersion: updateStub.runtime.version,
|
||||
updateInfoGroup: expect.objectContaining({
|
||||
ios: expect.any(Object),
|
||||
}),
|
||||
@@ -270,7 +270,7 @@ describe(UpdateRepublish.name, () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
branchId: updateStub.branch.id,
|
||||
runtimeVersion: updateStub.runtimeVersion,
|
||||
runtimeVersion: updateStub.runtime.version,
|
||||
updateInfoGroup: expect.objectContaining({
|
||||
ios: expect.any(Object),
|
||||
}),
|
||||
@@ -313,7 +313,7 @@ describe(UpdateRepublish.name, () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
branchId: updateStub.branch.id,
|
||||
runtimeVersion: updateStub.runtimeVersion,
|
||||
runtimeVersion: updateStub.runtime.version,
|
||||
updateInfoGroup: expect.objectContaining({
|
||||
ios: expect.any(Object),
|
||||
}),
|
||||
@@ -357,7 +357,7 @@ describe(UpdateRepublish.name, () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
branchId: 'other-id',
|
||||
runtimeVersion: updateStub.runtimeVersion,
|
||||
runtimeVersion: updateStub.runtime.version,
|
||||
updateInfoGroup: expect.objectContaining({
|
||||
ios: expect.any(Object),
|
||||
}),
|
||||
@@ -401,7 +401,7 @@ describe(UpdateRepublish.name, () => {
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
branchId: 'blah-id',
|
||||
runtimeVersion: updateStub.runtimeVersion,
|
||||
runtimeVersion: updateStub.runtime.version,
|
||||
updateInfoGroup: expect.objectContaining({
|
||||
ios: expect.any(Object),
|
||||
}),
|
||||
|
||||
@@ -30,7 +30,7 @@ const updateStub: UpdateFragment = {
|
||||
group: 'group-1234',
|
||||
branch: { id: 'branch-1234', name: 'main' },
|
||||
message: 'test message',
|
||||
runtimeVersion: 'exposdk:47.0.0',
|
||||
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
|
||||
platform: 'ios',
|
||||
gitCommitHash: 'commit',
|
||||
isGitWorkingTreeDirty: false,
|
||||
|
||||
@@ -24,7 +24,7 @@ const updateStub: UpdateFragment = {
|
||||
group: 'group-source',
|
||||
branch: { id: 'branch-1234', name: 'main' },
|
||||
message: 'source message',
|
||||
runtimeVersion: 'exposdk:47.0.0',
|
||||
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
|
||||
platform: 'ios',
|
||||
gitCommitHash: 'commit',
|
||||
isGitWorkingTreeDirty: false,
|
||||
|
||||
@@ -33,7 +33,7 @@ const updateGroup = [
|
||||
actor: null,
|
||||
createdAt: '2026-04-09T00:00:00.000Z',
|
||||
message: 'first',
|
||||
runtimeVersion: '1.0.0',
|
||||
runtime: { __typename: 'Runtime' as const, id: 'runtime-1', version: '1.0.0' },
|
||||
platform: 'ios',
|
||||
manifestPermalink: 'https://expo.dev/manifest/u1',
|
||||
isRollBackToEmbedded: false,
|
||||
|
||||
@@ -260,7 +260,7 @@ describe(UpdateEmbeddedList, () => {
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
|
||||
});
|
||||
await createCommand(['--non-interactive']).run();
|
||||
expect(mockLogLog.mock.calls.some(c => /ago/.test(String(c[0])))).toBe(true);
|
||||
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('ago'))).toBe(true);
|
||||
});
|
||||
|
||||
it('puts "All channels" first in the prompt, then channels in order', async () => {
|
||||
|
||||
@@ -670,7 +670,7 @@ export default class UpdatePublish extends EasCommand {
|
||||
version => version.runtimeVersion
|
||||
)) {
|
||||
const newUpdatesForRuntimeVersion = newUpdates.filter(
|
||||
update => update.runtimeVersion === runtime.runtimeVersion
|
||||
update => update.runtime.version === runtime.runtimeVersion
|
||||
);
|
||||
if (newUpdatesForRuntimeVersion.length === 0) {
|
||||
throw new Error(
|
||||
|
||||
@@ -322,7 +322,7 @@ export default class UpdateRevertUpdateRollout extends EasCommand {
|
||||
});
|
||||
|
||||
const platforms = rolloutUpdateGroup.map(update => update.platform) as UpdatePublishPlatform[];
|
||||
const runtimeVersion = rolloutUpdateGroup[0].runtimeVersion;
|
||||
const runtimeVersion = rolloutUpdateGroup[0].runtime.version;
|
||||
const targetBranch = {
|
||||
name: rolloutUpdateGroup[0].branchName,
|
||||
id: rolloutUpdateGroup[0].branchId,
|
||||
|
||||
@@ -147,7 +147,7 @@ async function getSourceUpdateGroupAsync(
|
||||
return {
|
||||
groupId,
|
||||
branchName: arbitraryUpdate.branch.name,
|
||||
runtimeVersion: arbitraryUpdate.runtimeVersion,
|
||||
runtimeVersion: arbitraryUpdate.runtime.version,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -60,14 +60,7 @@ export const testAppFragment: AppFragment = {
|
||||
ownerAccount: {
|
||||
id: 'test-account-id',
|
||||
name: 'testuser',
|
||||
users: [
|
||||
{
|
||||
role: Role.Owner,
|
||||
actor: {
|
||||
id: 'test-user-id',
|
||||
},
|
||||
},
|
||||
],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -11,18 +11,18 @@ export const jester: Actor = {
|
||||
primaryAccount: {
|
||||
id: 'jester-account-id',
|
||||
name: 'jester',
|
||||
users: [{ role: Role.Admin, actor: { id: 'jester-id' } }],
|
||||
viewerUserPermission: { role: Role.Admin },
|
||||
},
|
||||
accounts: [
|
||||
{
|
||||
id: 'jester-account-id',
|
||||
name: 'jester',
|
||||
users: [{ role: Role.Admin, actor: { id: 'jester-id' } }],
|
||||
viewerUserPermission: { role: Role.Admin },
|
||||
},
|
||||
{
|
||||
id: 'other-account-id',
|
||||
name: 'other',
|
||||
users: [{ role: Role.ViewOnly, actor: { id: 'jester-id' } }],
|
||||
viewerUserPermission: { role: Role.ViewOnly },
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
@@ -37,7 +37,7 @@ export const jester2 = {
|
||||
{
|
||||
id: 'jester2-account-id',
|
||||
name: 'jester2',
|
||||
users: [{ role: Role.Admin, actor: { id: 'jester2-id' } }],
|
||||
viewerUserPermission: { role: Role.Admin },
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
@@ -52,7 +52,7 @@ export const robot: Actor = {
|
||||
{
|
||||
id: 'robot-account-id',
|
||||
name: 'robot-account',
|
||||
users: [],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
],
|
||||
isExpoAdmin: false,
|
||||
|
||||
@@ -60,14 +60,7 @@ export const testAppFragment: AppFragment = {
|
||||
ownerAccount: {
|
||||
id: 'test-account-id',
|
||||
name: 'testuser',
|
||||
users: [
|
||||
{
|
||||
role: Role.Owner,
|
||||
actor: {
|
||||
id: 'test-user-id',
|
||||
},
|
||||
},
|
||||
],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { IosAppBuildCredentialsFragment } from '../../../graphql/generated';
|
||||
import { IosAppBuildCredentialsFragment, Role } from '../../../graphql/generated';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { findApplicationTarget } from '../../../project/ios/target';
|
||||
import { getAppstoreMock } from '../../__tests__/fixtures-appstore';
|
||||
@@ -58,7 +58,7 @@ describe(IosCredentialsProvider, () => {
|
||||
account: {
|
||||
id: `id-${appLookupParams.account.name}`,
|
||||
name: appLookupParams.account.name,
|
||||
users: [],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
projectName: appLookupParams.projectName,
|
||||
},
|
||||
@@ -102,7 +102,7 @@ describe(IosCredentialsProvider, () => {
|
||||
account: {
|
||||
id: `id-${appLookupParams.account.name}`,
|
||||
name: appLookupParams.account.name,
|
||||
users: [],
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
projectName: appLookupParams.projectName,
|
||||
},
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from '../BuildC
|
||||
import { chooseDevicesAsync } from '../DeviceUtils';
|
||||
import { SetUpAdhocProvisioningProfile, doUDIDsMatch } from '../SetUpAdhocProvisioningProfile';
|
||||
import { getAscApiKeyForAppSubmissionsAsync } from '../../api/GraphqlClient';
|
||||
import { AuthenticationMode, AppleTeamType } from '../../appstore/authenticateTypes';
|
||||
import { AppleTeamType, AuthenticationMode } from '../../appstore/authenticateTypes';
|
||||
import { hasAscEnvVars } from '../../appstore/resolveCredentials';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
|
||||
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user