Compare commits

..
155 changed files with 2416 additions and 6274 deletions

No files matched your search

@@ -27,9 +27,6 @@ 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
@@ -64,7 +61,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=$(eas workflow:run build-worker.yml \
WORKFLOW_OUTPUT=$(npx -y eas-cli@latest workflow:run build-worker.yml \
--ref ${{ github.sha }} \
--input upload_artifact=$SHOULD_UPLOAD_ARTIFACT \
--non-interactive \
@@ -99,7 +96,7 @@ jobs:
SHOULD_UPLOAD_ARTIFACT="${{ inputs.environment != '' && steps.check_worker_exists.outputs.worker_exists == 'false' }}"
while true; do
VIEW_OUTPUT=$(eas workflow:view "$WORKFLOW_RUN_ID" \
VIEW_OUTPUT=$(npx -y eas-cli@latest workflow:view "$WORKFLOW_RUN_ID" \
--non-interactive \
--json)
STATUS=$(echo "$VIEW_OUTPUT" | jq -r '.status')
@@ -184,7 +181,7 @@ jobs:
WORKFLOW_RUN_ID="${{ steps.eas_workflow.outputs.workflow_run_id }}"
if [ -n "$WORKFLOW_RUN_ID" ]; then
eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
npx -y eas-cli@latest workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive
fi
- name: Upload worker tarballs to GCP
+10 -13
View File
@@ -15,15 +15,12 @@ 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: eas init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
run: npx -y eas-cli@latest init --id 6ae8028d-4e6d-4912-8eb2-87d4e6122880 --force --non-interactive
- name: Add bundle identifiers to app.json
working-directory: ${{ runner.temp }}/test-app
@@ -33,7 +30,7 @@ jobs:
- name: Configure EAS Build
working-directory: ${{ runner.temp }}/test-app
run: eas build:configure --platform all
run: npx -y eas-cli@latest build:configure --platform all
- name: Add system-test profile to eas.json
working-directory: ${{ runner.temp }}/test-app
@@ -52,7 +49,7 @@ jobs:
run: |
set -euo pipefail
BUILD_JSON=$(eas build --platform ios --profile system-test --non-interactive --no-wait --json)
BUILD_JSON=$(npx -y eas-cli@latest 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"
@@ -62,7 +59,7 @@ jobs:
echo "BUILD_ID=$BUILD_ID" >> "$GITHUB_ENV"
echo "Resolved BUILD_ID=$BUILD_ID"
WORKFLOW_JSON=$(eas workflow:run build-android.yml --non-interactive --no-wait --json)
WORKFLOW_JSON=$(npx -y eas-cli@latest 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"
@@ -98,8 +95,8 @@ jobs:
exit 1
fi
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')
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')
echo "iOS build status: $BUILD_STATUS"
echo "Android workflow status: $WORKFLOW_STATUS"
@@ -133,13 +130,13 @@ jobs:
if [ -n "${BUILD_ID:-}" ]; then
echo "Canceling EAS build: $BUILD_ID"
BUILD_CANCEL_OUTPUT=$(eas build:cancel "$BUILD_ID" --non-interactive 2>&1 || true)
BUILD_CANCEL_OUTPUT=$(npx -y eas-cli@latest 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=$(eas build:view "$BUILD_ID" --json 2>/dev/null | jq -r '.status // empty' || true)
BUILD_STATUS=$(npx -y eas-cli@latest 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
@@ -148,13 +145,13 @@ jobs:
fi
if [ -n "${WORKFLOW_RUN_ID:-}" ]; then
echo "Canceling EAS workflow run: $WORKFLOW_RUN_ID"
WORKFLOW_CANCEL_OUTPUT=$(eas workflow:cancel "$WORKFLOW_RUN_ID" --non-interactive 2>&1 || true)
WORKFLOW_CANCEL_OUTPUT=$(npx -y eas-cli@latest 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=$(eas workflow:view "$WORKFLOW_RUN_ID" --non-interactive --json 2>/dev/null | jq -r '.status // empty' || true)
WORKFLOW_STATUS=$(npx -y eas-cli@latest 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
+2 -25
View File
@@ -8,38 +8,16 @@ 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
@@ -101,7 +79,6 @@ 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
View File
@@ -1,4 +1,4 @@
{
"STAGING": "22.0.0",
"STAGING": "21.8.0",
"PRODUCTION": "21.5.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "22.2.0",
"version": "21.8.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1 -1
View File
@@ -12,7 +12,7 @@
"watch": "yarn start",
"eas": "packages/eas-cli/bin/run",
"actionlint": "actionlint",
"lint": "oxlint --type-aware --import-plugin --max-warnings=0 --tsconfig ./tsconfig.oxlint.json .",
"lint": "oxlint --type-aware --import-plugin --tsconfig ./tsconfig.oxlint.json .",
"fmt": "oxfmt .",
"fmt:check": "oxfmt --check .",
"lint-changelog": "./scripts/bin/run lint-changelog",
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "22.2.0",
"version": "21.8.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": "22.0.0",
"@expo/eas-build-job": "22.0.0",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.6.0",
"@expo/env": "^0.4.0",
"@expo/logger": "22.0.0",
"@expo/logger": "21.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": "22.1.0",
"@expo/template-file": "22.0.0",
"@expo/turtle-spawn": "22.0.0",
"@expo/steps": "21.7.1",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -0,0 +1,296 @@
#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);
}
+37 -17
View File
@@ -18,8 +18,12 @@ 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 } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import {
cacheStatsAsync,
restoreCcacheAsync,
restoreXcodeCompilationCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveXcodeCompilationCacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
@@ -100,13 +104,21 @@ async function buildInnerAsync(
return;
}
await ctx.cacheManager?.restoreCache(ctx);
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
env: ctx.env,
secrets: ctx.job.secrets,
});
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 ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
@@ -242,14 +254,22 @@ async function buildInnerAsync(
return;
}
await ctx.cacheManager?.saveCache(ctx);
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
});
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 ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
@@ -0,0 +1,161 @@
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,6 +6,7 @@ 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';
@@ -23,13 +24,19 @@ const WORKING_DIR = '/workingdir';
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
function makeIosBuildContext({
simulator,
env = {},
}: {
simulator: boolean;
env?: Record<string, string>;
}): 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: { __API_SERVER_URL: 'http://api.expo.test', ...env },
uploadArtifact: jest.fn(),
});
}
@@ -71,6 +78,10 @@ describe(runFastlaneGym, () => {
(spawn as jest.Mock).mockClear();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe.each([
{
name: 'archive',
@@ -125,4 +136,68 @@ 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')
);
});
});
@@ -0,0 +1,194 @@
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 -1
View File
@@ -7,6 +7,7 @@ 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';
@@ -46,13 +47,18 @@ 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 },
env: { ...ctx.env, ...extraEnv, ...compilationCacheEnv },
});
} finally {
await buildLogger.flush();
@@ -45,7 +45,6 @@ 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';
@@ -95,7 +94,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAppiumRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -1,6 +1,6 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { createInstallMaestroBuildFunction, installIdbFromBrew } from '../installMaestro';
import { createInstallMaestroBuildFunction } from '../installMaestro';
jest.unmock('fs');
jest.unmock('@expo/logger');
@@ -57,14 +57,3 @@ 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,6 +1,4 @@
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';
@@ -181,7 +179,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$/),
}),
})
);
@@ -191,217 +189,6 @@ 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,92 +10,11 @@ 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({
@@ -534,20 +453,6 @@ 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);
});
@@ -573,41 +478,6 @@ 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,160 +3,14 @@ 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,14 +27,10 @@ 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 {
@@ -93,8 +89,6 @@ describe('createMaestroTestsBuildFunction', () => {
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
mockedHarvest.mockReset();
mockedHarvest.mockResolvedValue([]);
mockedRunnerHarvest.mockReset();
mockedRunnerHarvest.mockResolvedValue([]);
mockUploadArtifact.mockReset();
mockUploadArtifact.mockResolvedValue({ artifactId: 'artifact-1' });
});
@@ -143,171 +137,6 @@ 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');
@@ -624,36 +453,6 @@ 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())
@@ -1215,29 +1014,6 @@ 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);
@@ -1,83 +0,0 @@
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));
});
});
@@ -1,86 +0,0 @@
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();
});
});
@@ -0,0 +1,110 @@
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,4 +1,3 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -15,7 +14,6 @@ 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';
@@ -122,64 +120,19 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
await installIdbFromBrew({ logger, env });
}
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)
) {
// 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':
await installMaestro({ version: requestedVersion, global, logger, env });
}
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;
break;
case 'maestro-runner':
await installMaestroRunner({ version: requestedVersion, global, logger, env });
break;
}
}
@@ -201,21 +154,6 @@ 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,
@@ -273,10 +211,10 @@ async function installMaestroRunner({
const binDir = path.join(env.HOME, '.maestro-runner', 'bin');
global.updateEnv({
...global.env,
PATH: `${binDir}:${global.env.PATH}`,
PATH: `${global.env.PATH}:${binDir}`,
});
env.PATH = `${binDir}:${env.PATH}`;
process.env.PATH = `${binDir}:${process.env.PATH}`;
env.PATH = `${env.PATH}:${binDir}`;
process.env.PATH = `${process.env.PATH}:${binDir}`;
} finally {
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
}
@@ -323,10 +261,10 @@ async function installMaestro({
const maestroBinDir = path.join(maestroDir, 'bin');
global.updateEnv({
...global.env,
PATH: `${maestroBinDir}:${global.env.PATH}`,
PATH: `${global.env.PATH}:${maestroBinDir}`,
});
env.PATH = `${maestroBinDir}:${env.PATH}`;
process.env.PATH = `${maestroBinDir}:${process.env.PATH}`;
env.PATH = `${env.PATH}:${maestroBinDir}`;
process.env.PATH = `${process.env.PATH}:${maestroBinDir}`;
} finally {
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
}
@@ -334,64 +272,37 @@ async function installMaestro({
async function isIdbInstalled({ env }: { env: BuildStepEnv }): Promise<boolean> {
try {
await spawn('idb_companion', ['--version'], { ignoreStdio: true, env });
await spawn('idb', ['-h'], { ignoreStdio: true, env });
return true;
} catch {
return false;
}
}
export async function installIdbFromBrew({
async function installIdbFromBrew({
logger,
env,
}: {
logger: bunyan;
env: BuildStepEnv;
}): Promise<void> {
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',
};
// 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',
};
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,
});
}
await spawn(brewPath, ['tap', 'facebook/fb'], {
env: localEnv,
logger,
});
await spawn(brewPath, ['install', 'idb-companion'], {
env: localEnv,
logger,
});
}
async function isJavaInstalled({ env }: { env: BuildStepEnv }): Promise<boolean> {
@@ -2,7 +2,6 @@ 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;
@@ -35,16 +34,10 @@ const xmlParser = new XMLParser({
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
// Official Maestro writes the flow path as a `file=` testcase attribute. maestro-runner writes
// the same value as a `<property name="file" value="..."/>` child.
// A `file=` attribute counts as present only when it is a non-empty string.
function fileAttrOf(tc: any): string | undefined {
const f = tc?.['@_file'];
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;
return typeof f === 'string' && f.length > 0 ? f : undefined;
}
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
@@ -69,46 +62,26 @@ 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;
// 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 status: 'passed' | 'failed' = tc['@_status'] === 'SUCCESS' ? 'passed' : 'failed';
const failureText =
tc.failure != null
? typeof tc.failure === 'string'
? tc.failure
: (tc.failure?.['@_message'] ?? tc.failure?.['#text'] ?? null)
: (tc.failure?.['#text'] ?? null)
: null;
const errorText =
tc.error != null
? typeof tc.error === 'string'
? tc.error
: (tc.error?.['@_message'] ?? tc.error?.['#text'] ?? null)
: (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 ?? [];
@@ -203,69 +176,6 @@ 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,9 +2,6 @@ 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;
@@ -136,156 +133,6 @@ 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,7 +13,6 @@ import os from 'os';
import path from 'path';
import { z } from 'zod';
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
@@ -22,15 +21,12 @@ import {
parseFailedFlowNamesFromJUnitFile,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
parseFailedFlowsFromMaestroRunnerReport,
parseJUnitTestCases,
parseMaestroRunnerReport,
} from './maestroResultParser';
import {
type HarvestedScreenshot,
computePureFailureFlowNames,
harvestFailureScreenshotsAsync,
harvestMaestroRunnerFailureScreenshotsAsync,
selectFailureScreenshots,
} from './maestroScreenshots';
import { CustomBuildContext } from '../../customBuildContext';
@@ -66,58 +62,35 @@ function isFilesystemError(err: any): boolean {
);
}
function buildMaestroArgs({
backend,
platform,
flowPaths,
output,
outputFormat,
shards,
includeTags,
excludeTags,
}: {
backend: MaestroBackend;
platform: 'ios' | 'android';
flowPaths: string[];
output: string | null;
outputFormat: string | undefined;
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
// other declared formats pass an explicit path so downstream upload steps
// know where to find the result.
function buildMaestroArgs(args: {
flow_path: string[];
outputPath: string | null;
output_format: string | undefined;
shards: number | undefined;
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 };
}
include_tags: string | undefined;
exclude_tags: string | undefined;
}): string[] {
const out: string[] = ['test'];
if (args.output_format) {
out.push(`--format=${args.output_format.toUpperCase()}`);
}
if (args.outputPath) {
out.push(`--output=${args.outputPath}`);
}
if (args.shards !== undefined) {
out.push(`--shard-split=${args.shards}`);
}
if (args.include_tags) {
out.push(`--include-tags=${args.include_tags}`);
}
if (args.exclude_tags) {
out.push(`--exclude-tags=${args.exclude_tags}`);
}
out.push(...args.flow_path);
return out;
}
export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildFunction {
@@ -176,11 +149,6 @@ 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 }),
@@ -229,14 +197,6 @@ 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,
@@ -259,18 +219,6 @@ 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 {
@@ -279,7 +227,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
throw new SystemError('Failed to create JUnit report directory', { cause: err });
}
// Official Maestro legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
// 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;
@@ -289,23 +237,15 @@ 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: 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).
// 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).
let flowsToRun: string[] = flowPaths;
let lastAttemptExitCode: number | null = null;
const harvested: HarvestedScreenshot[] = [];
const reportDirectories = backend === 'maestro' ? [junitReportDirectory] : [];
const totalAttempts = retries + 1;
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') {
if (platform === 'android' && androidConnectionMode === 'dadb') {
try {
const adbOverrideDirectoryPath = await fs.mkdtemp(
path.join(os.tmpdir(), 'maestro-tests-adb-override-')
@@ -340,45 +280,29 @@ 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`)
: backend === 'maestro' && outputFormat
: outputFormat
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
const { executable, args: maestroArgs } = buildMaestroArgs({
backend,
platform,
flowPaths: flowsToRun,
output: backend === 'maestro-runner' ? runnerOutputDirectory : outputPath,
outputFormat,
const maestroArgs = buildMaestroArgs({
flow_path: flowsToRun,
outputPath,
output_format: outputFormat,
shards,
includeTags,
excludeTags,
include_tags: includeTags,
exclude_tags: excludeTags,
});
logger.info(
`Running ${executable} (attempt ${attempt + 1}/${totalAttempts}): ${executable} ${maestroArgs.join(' ')}`
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${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(executable, maestroArgs, {
await spawn('maestro', maestroArgs, {
cwd: stepCtx.workingDirectory,
env: spawnEnv,
logger,
@@ -387,108 +311,56 @@ 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 ${executable}`, { cause: err });
throw new SystemError('Failed to invoke maestro', { cause: err });
}
if (err && typeof err.status === 'number') {
lastAttemptExitCode = err.status;
} else {
throw new SystemError(`Unexpected spawn failure invoking ${executable}`, {
cause: err,
});
throw new SystemError('Unexpected spawn failure invoking maestro', { 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') {
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);
const failedFlowNames = outputPath
? await parseFailedFlowNamesFromJUnitFile(outputPath)
: new Set<string>();
harvested.push(
...(await harvestFailureScreenshotsAsync({
testsDirectory,
capturedSinceMs: attemptStartedAtMs,
attemptIndex: attempt,
failedFlowNames,
logger,
}))
);
}
if (lastAttemptExitCode === 0 || attempt === retries) {
break;
}
if (
retryFailedOnly &&
(backend === 'maestro-runner' || (outputFormat === 'junit' && outputPath))
) {
if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
let failed: string[] | 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 (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;
}
if (failed !== null && failed.length > 0) {
flowsToRun = failed;
@@ -541,13 +413,7 @@ 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,
backend,
reportDirectories,
ctx,
logger,
});
await uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger });
}
// The retry loop exits via success (0), numeric status (retryable),
@@ -568,14 +434,12 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
// the maestro test result. Caller guards on junit (harvest only runs for junit).
async function uploadFailureScreenshotsAsync({
harvested,
backend,
reportDirectories,
junitReportDirectory,
ctx,
logger,
}: {
harvested: HarvestedScreenshot[];
backend: MaestroBackend;
reportDirectories: string[];
junitReportDirectory: string;
ctx: CustomBuildContext;
logger: bunyan;
}): Promise<void> {
@@ -585,25 +449,9 @@ async function uploadFailureScreenshotsAsync({
// test verdict (the whole step is verdict-neutral for screenshots).
let selected: HarvestedScreenshot[];
try {
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);
const pureFailureFlowNames = computePureFailureFlowNames(
await parseJUnitTestCases(junitReportDirectory)
);
selected = selectFailureScreenshots(harvested, pureFailureFlowNames);
} catch (err: any) {
logger.warn({ err }, 'Failed to classify failure screenshots; skipping screenshot upload.');
@@ -20,6 +20,12 @@ 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';
@@ -48,26 +54,91 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
);
}
await restoreCcacheAsync({
logger,
workingDirectory,
platform,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await restoreGradleCacheAsync({
await Promise.all([
restoreCcacheAsync({
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',
@@ -258,14 +329,13 @@ 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, journalPath],
paths: [buildCachePath],
key: cacheKey,
keyPrefixes: [GRADLE_CACHE_KEY_PREFIX],
platform: Platform.ANDROID,
@@ -14,6 +14,12 @@ 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';
@@ -43,27 +49,87 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
);
}
await saveCcacheAsync({
logger,
workingDirectory,
platform,
evictUsedBefore,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await saveGradleCacheAsync({
await Promise.all([
saveCcacheAsync({
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,
@@ -167,7 +233,6 @@ 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);
@@ -187,11 +252,9 @@ 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, journalPath],
paths: [buildCachePath],
workingDirectory: gradleCachesPath,
verbose: env.EXPO_DEBUG === '1',
logger,
@@ -207,7 +270,7 @@ export async function saveGradleCacheAsync({
robotAccessToken,
archivePath,
key: cacheKey,
paths: [buildCachePath, journalPath],
paths: [buildCachePath],
size,
platform: Platform.ANDROID,
});
@@ -348,10 +348,7 @@ 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,11 +60,6 @@ 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
@@ -78,7 +73,6 @@ 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}).`
@@ -152,7 +146,6 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
@@ -1,311 +0,0 @@
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,11 +73,6 @@ 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
@@ -91,7 +86,6 @@ 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;
@@ -227,7 +221,6 @@ export function createStartArgentRemoteSessionBuildFunction(
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
@@ -1,9 +1,4 @@
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
import { CustomBuildContext } from '../../customBuildContext';
import {
@@ -26,17 +21,9 @@ export function createStartServeSimRemoteSessionBuildFunction(
name: 'Start serve-sim remote session',
__metricsId: 'eas/start_serve_sim_remote_session',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
inputProviders: [
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger }, { inputs, env, signal }) => {
fn: async ({ logger }, { 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.');
@@ -62,7 +49,6 @@ export function createStartServeSimRemoteSessionBuildFunction(
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
});
} finally {
@@ -1,95 +0,0 @@
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,10 +2,6 @@ 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';
@@ -24,7 +20,6 @@ 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() }));
@@ -221,14 +216,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://web-preview.example.test',
url: () => 'https://serve-sim.example.test',
close,
} as never);
const tunnel = await startNgrokTunnelAsync({
port: 4321,
subdomainPrefix: 'web-preview',
baseDomain: 'eas-simulator.ngrok.dev',
subdomainPrefix: 'serve-sim',
baseDomain: 'tunnel.example.test',
authtoken: 'token',
logger: createLoggerMock(),
});
@@ -237,10 +232,10 @@ describe(startNgrokTunnelAsync, () => {
expect.objectContaining({
addr: 4321,
authtoken: 'token',
domain: expect.stringMatching(/^web-preview-[a-f0-9]{32}\.eas-simulator\.ngrok\.dev$/),
domain: expect.stringMatching(/^serve-sim-[a-f0-9]{32}\.tunnel\.example\.test$/),
})
);
expect(tunnel.url).toBe('https://web-preview.example.test');
expect(tunnel.url).toBe('https://serve-sim.example.test');
await tunnel.stopAsync();
await tunnel.stopAsync();
expect(close).toHaveBeenCalledTimes(1);
@@ -365,13 +360,8 @@ 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);
});
@@ -388,57 +378,6 @@ 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 () => {
@@ -449,28 +388,8 @@ 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 () => {
@@ -1,216 +0,0 @@
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,6 +5,7 @@ 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,
@@ -19,14 +20,23 @@ 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: path.join(workingDir, 'ios'),
cwd: workspacePath,
logger,
env,
extraEnv,
extraEnv: { ...extraEnv, ...compilationCacheEnv },
});
} finally {
await buildLogger.flush();
@@ -10,7 +10,6 @@ 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';
@@ -145,107 +144,77 @@ export async function waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal: cancelSignal,
signal,
idleTimeout,
}: {
ctx: CustomBuildContext;
deviceRunSessionId: string;
logger: bunyan;
maxDurationSeconds?: number;
signal?: AbortSignal;
idleTimeout?: DeviceRunSessionIdleTimeout;
}): Promise<void> {
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;
}
const durationTimeout =
maxDurationSeconds === undefined
? undefined
: setTimeout(() => {
logger.info(`Device run session ${deviceRunSessionId} reached its maximum duration.`);
durationAbortController.abort();
}, maxDurationSeconds * 1_000);
try {
logger.info(
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
);
if (idleTimeout) {
logger.info(
`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`
`The session stops automatically after ${idleTimeout.maxIdleTimeMinutes} minute(s) without activity.`
);
if (durationTimeout !== undefined) {
logger.info(
`The device run session will stop automatically after ${maxDurationSeconds} seconds.`
);
}
}
let pollErrorCount = 0;
let lastActivityAt = new Date();
while (!signal?.aborted) {
if (idleTimeout) {
logger.info(
`The session stops automatically after ${idleTimeout.maxIdleTimeMinutes} minute(s) without activity.`
);
}
let pollErrorCount = 0;
let lastActivityAt = new Date();
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;
}
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
lastActivityAt = lastEventObservedAt;
}
try {
const result = await ctx.graphqlClient
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
.toPromise();
if (result.error) {
throw result.error;
}
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.'
);
}
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;
}
await sleepUntilAbortedAsync(DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS, signal);
}
} finally {
if (durationTimeout !== undefined) {
clearTimeout(durationTimeout);
try {
const result = await ctx.graphqlClient
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
.toPromise();
if (result.error) {
throw result.error;
}
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);
}
}
@@ -722,7 +691,7 @@ export async function startServeSimWithTunnelAsync(
await waitForServeSimReadyAsync({ serveSim, port, timeoutMs });
const tunnel = await startNgrokTunnelAsync({
port,
subdomainPrefix: 'web-preview',
subdomainPrefix: 'serve-sim',
baseDomain,
authtoken: getNgrokAuthtokenOrThrow(env),
logger,
@@ -6,13 +6,11 @@ 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. */
@@ -307,23 +305,21 @@ 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');
const outputName = `${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}`;
logcatOutputPath = path.join(logcatDirectory, `${outputName}-logcat.log`);
emulatorOutputPath = path.join(logcatDirectory, `${outputName}-emulator.log`);
logcatOutputPath = path.join(
logcatDirectory,
`${safeDeviceName}-${timestamp}-${randomBytes(2).toString('hex')}.log`
);
await fs.promises.writeFile(logcatOutputPath, '');
await fs.promises.writeFile(emulatorOutputPath, '');
} catch (err) {
throw new SystemError(`Failed to prepare Android emulator output for ${deviceName}.`, {
throw new SystemError(`Failed to prepare Android emulator logcat output for ${deviceName}.`, {
cause: err,
});
}
@@ -350,8 +346,7 @@ export namespace AndroidEmulatorUtils {
],
{
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
ignoreStdio: true,
stdio: 'inherit',
env: {
...env,
// We don't need to wait for emulator to exit gracefully.
@@ -359,43 +354,6 @@ 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;
@@ -424,7 +382,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, emulatorOutputPath };
return { emulatorPromise, serialId, logcatOutputPath };
}
export async function waitForReadyAsync({
@@ -273,7 +273,7 @@ describe('AndroidEmulatorUtils', () => {
expect(attemptCounter).toBe(2);
}, 360_000);
it('captures emulator and native logcat output across an ADB server restart', async () => {
it('captures native logcat output across an ADB server restart', async () => {
const testLogcatDirectory = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'android-emulator-logcat-e2e-')
);
@@ -302,21 +302,6 @@ 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,13 +1,10 @@
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';
@@ -20,12 +17,6 @@ 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);
@@ -34,13 +25,11 @@ 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 });
@@ -50,18 +39,7 @@ describe('AndroidEmulatorUtils', () => {
describe(AndroidEmulatorUtils.startAsync, () => {
function mockSuccessfulStart(deviceName: AndroidVirtualDeviceName) {
// 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 child = { pid: 1234, unref: jest.fn() };
const spawnPromise = Promise.resolve({ stdout: '', stderr: '' }) as any;
spawnPromise.child = child;
mockedSpawn.mockImplementation(((command: string, args: string[]) => {
@@ -79,12 +57,11 @@ describe('AndroidEmulatorUtils', () => {
return { child };
}
it('captures logcat and emulator process output', async () => {
it('captures logcat through the emulator process', 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,
@@ -93,21 +70,13 @@ describe('AndroidEmulatorUtils', () => {
});
expect(result.logcatOutputPath).toMatch(
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$`)
new RegExp(`^${outputDir}/eas-simulator-[a-f0-9]{8}-[a-f0-9]{4}\\.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: ['ignore', 'pipe', 'pipe'],
ignoreStdio: true,
})
expect.objectContaining({ detached: true, stdio: 'inherit' })
);
expect(mockedSpawn).not.toHaveBeenCalledWith(
'adb',
@@ -115,32 +84,6 @@ 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 () => {
@@ -157,7 +100,7 @@ describe('AndroidEmulatorUtils', () => {
logcatDirectory: outputDir,
})
).rejects.toEqual(
new SystemError('Failed to prepare Android emulator output for eas-simulator.', {
new SystemError('Failed to prepare Android emulator logcat output for eas-simulator.', {
cause: mkdirError,
})
);
@@ -1,7 +1,7 @@
import resolveFrom from 'resolve-from';
import spawnAsync from '@expo/turtle-spawn';
import { ExpoCLIModuleNotFoundError, expoCommandAsync } from '../expoCli';
import { expoCommandAsync, ExpoCLIModuleNotFoundError } from '../expoCli';
jest.mock('resolve-from', () => ({
__esModule: true,
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "22.2.0",
"version": "21.7.1",
"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 --max-warnings=0 --tsconfig ../../tsconfig.oxlint.json .",
"lint": "oxlint --type-aware --tsconfig ../../tsconfig.oxlint.json .",
"test": "echo 'No tests yet.'",
"watch": "yarn run build:dev -w",
"build:dev": "ncc build ./src/index.ts -o build/",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "22.1.0"
"@expo/steps": "21.7.1"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "22.0.0",
"version": "21.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": "22.0.0",
"version": "21.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",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "22.0.0",
"version": "21.6.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": "22.0.0",
"@expo/logger": "21.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "22.0.0",
"@expo/turtle-spawn": "21.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+182 -205
View File
File diff suppressed because it is too large. Load diff
+61 -1848
View File
File diff suppressed because it is too large. Load diff
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "22.2.0",
"version": "21.8.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": "22.0.0",
"@expo/eas-json": "22.0.0",
"@expo/eas-build-job": "21.6.0",
"@expo/eas-json": "21.6.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "22.0.0",
"@expo/logger": "21.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": "22.1.0",
"@expo/steps": "21.7.1",
"@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.6",
"agent-cli-detector": "0.1.2",
"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, toRepackTargetSdkVersion } from '../../commands/go';
import { detectProjectSdkVersionAsync } from '../../commands/go';
import { mockTestCommand } from './utils';
jest.mock('@expo/config', () => ({
@@ -81,25 +81,6 @@ 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,
@@ -131,7 +112,7 @@ describe('Go command', () => {
jest.clearAllMocks();
});
function makeCmd(argv: string[] = []): Go {
function makeCmd(argv: string[] = []) {
const ctx = {
loggedIn: { actor: mockActor as any, graphqlClient: {} as any },
analytics: {} as any,
@@ -168,17 +149,6 @@ 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,9 +38,14 @@ export function getMockAppFragment(): AppFragment {
ownerAccount: {
id: 'test-account-id',
name: 'testuser',
viewerUserPermission: {
role: Role.Owner,
},
users: [
{
role: Role.Owner,
actor: {
id: 'test-user-id',
},
},
],
},
};
}
@@ -180,7 +180,7 @@ describe(WorkflowLogView, () => {
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
isForIosSimulator: false,
app: {
project: {
id: mockProjectId,
__typename: 'App',
name: 'App',
@@ -1,37 +0,0 @@
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,
app: {
project: {
__typename: 'App',
slug: 'test-project',
id: projectId,
@@ -74,7 +74,7 @@ function createMockBuildFragment({
platform: AppPlatform.Android,
id: buildId ?? uuid(),
priority: BuildPriority.Normal,
app: {
project: {
__typename: 'App',
slug: 'test-project',
id: projectId,
+1
View File
@@ -76,6 +76,7 @@ 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.app.id, build.id, build.platform);
const cachedAppPath = getEasBuildRunCachedAppPath(build.project.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.updateChannel?.name,
value: build.channel,
},
{
label: 'SDK Version',
@@ -101,7 +101,7 @@ export function formatGraphQLBuild(build: BuildFragment): string {
},
{
label: 'Runtime Version',
value: build.runtime?.version,
value: build.runtimeVersion,
},
{
label: 'Version',
+8 -5
View File
@@ -19,10 +19,13 @@ export function getProjectPageUrl(
}
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
const { app } = build;
const url = `/accounts/${app.ownerAccount.name}/projects/${app.slug}/builds/${build.id}${
hash ? `#${hash}` : ''
}`;
const { project } = build;
const url =
project.__typename !== 'App'
? `/builds/${build.id}`
: `/accounts/${project.ownerAccount.name}/projects/${project.slug}/builds/${build.id}${
hash ? `#${hash}` : ''
}`;
return new URL(url, getExpoWebsiteBaseUrl()).toString();
}
@@ -34,7 +37,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.app.id
build.project.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',
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
runtimeVersion: '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',
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
runtimeVersion: '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',
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
runtimeVersion: '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',
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
runtimeVersion: '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,11 +46,7 @@ function makeInsights(
id: u.id,
group: u.group,
message: u.message,
runtime: {
__typename: 'Runtime' as const,
id: `${u.id}-runtime`,
version: u.runtimeVersion,
},
runtimeVersion: u.runtimeVersion,
platform: u.platform,
insights: {
__typename: 'UpdateInsights' as const,
@@ -48,18 +48,18 @@ describe(getProjectIdAsync, () => {
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Owner },
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Owner },
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
{
id: 'account_id_2',
name: 'dominik',
viewerUserPermission: { role: Role.ViewOnly },
users: [{ role: Role.ViewOnly, actor: { id: 'user_id' } }],
},
],
isExpoAdmin: false,
@@ -198,12 +198,12 @@ describe(getProjectIdAsync, () => {
{
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Admin },
users: [{ role: Role.Admin, actor: { id: 'robot_id' } }],
},
{
id: 'account_id_2',
name: 'dominik',
viewerUserPermission: { role: Role.ViewOnly },
users: [{ role: Role.ViewOnly, actor: { id: 'robot_id' } }],
},
],
isExpoAdmin: false,
@@ -423,13 +423,13 @@ describe(getProjectIdAsync, () => {
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Owner },
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Owner },
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
],
isExpoAdmin: false,
@@ -465,7 +465,7 @@ describe(getProjectIdAsync, () => {
{
id: 'account_id_1',
name: 'notnotbrent',
viewerUserPermission: { role: Role.Admin },
users: [{ role: Role.Admin, actor: { id: 'robot_id' } }],
},
],
isExpoAdmin: false,
@@ -2,7 +2,7 @@ import { ExpoConfig } from '@expo/config';
import fs from 'fs-extra';
import { getEASUpdateURL } from '../../../api';
import { AppFragment, Role } from '../../../graphql/generated';
import { AppFragment } from '../../../graphql/generated';
import { easCliVersion } from '../../../utils/easCli';
import {
cleanAndPrefix,
@@ -62,7 +62,7 @@ describe('projectFiles', () => {
ownerAccount: {
id: 'testowner-account-id',
name: 'testowner',
viewerUserPermission: { role: Role.Owner },
users: [],
},
...overrides,
});
@@ -126,7 +126,7 @@ describe('projectFiles', () => {
ownerAccount: {
id: 'test-owner-account-id',
name: 'test-owner',
viewerUserPermission: { role: Role.Owner },
users: [],
},
});
@@ -151,7 +151,7 @@ describe('projectFiles', () => {
ownerAccount: {
id: 'test-owner-123-account-id',
name: 'test-owner-123',
viewerUserPermission: { role: Role.Owner },
users: [],
},
});
@@ -80,7 +80,8 @@ function getAccountPermissionsMap(actor: Actor): Map<string, boolean> {
const permissionsMap = new Map<string, boolean>();
for (const account of actor.accounts) {
const hasPermission = account.viewerUserPermission.role !== Role.ViewOnly;
const hasPermission =
account.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly;
permissionsMap.set(account.name, hasPermission);
}
@@ -1,4 +1,5 @@
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import EasCommand from '../../commandUtils/EasCommand';
import { Role } from '../../graphql/generated';
@@ -51,7 +52,7 @@ export default class AccountView extends EasCommand {
return Role.Owner;
}
return account.viewerUserPermission.role;
return nullthrows(account.users.find(user => user.actor.id === actor.id)?.role);
}
private static getLabelForRole(role: Role): string {
@@ -27,8 +27,8 @@ async function cancelBuildAsync(
.mutation<CancelBuildMutation, CancelBuildMutationVariables>(
gql`
mutation CancelBuildMutation($buildId: ID!) {
build {
cancelBuild(buildId: $buildId) {
build(buildId: $buildId) {
cancel {
id
status
}
@@ -39,7 +39,7 @@ async function cancelBuildAsync(
)
.toPromise()
);
return data.build.cancelBuild;
return data.build.cancel;
}
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.app.id,
build.project.id,
build.id,
platform
);
@@ -26,7 +26,7 @@ const insightsResponse = {
id: 'u1',
group: 'group-a',
message: 'first',
runtime: { __typename: 'Runtime' as const, id: 'u1-runtime', version: '1.0.0' },
runtimeVersion: '1.0.0',
platform: 'ios',
insights: { __typename: 'UpdateInsights' as const, id: 'u1-i', totalUniqueUsers: 200 },
},
@@ -54,18 +54,6 @@ 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;
@@ -182,15 +170,7 @@ 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;
}
@@ -210,62 +190,55 @@ export default class FingerprintCompare extends EasCommand {
);
}
if (unavailableSourceHashes.length > 0) {
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) {
Log.newLine();
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;
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)}`);
}
fields.push(field);
}
Log.log(
formatFields(fields, {
labelFormat: label => ` ${chalk.dim(label)}:`,
})
);
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:');
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 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)}:`,
})
);
return sourceType === 'contents';
});
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);
}
for (const diff of contentDiffs) {
printContentDiff(diff);
}
if (nonInteractive) {
@@ -312,8 +285,12 @@ async function getFingerprintInfoAsync(
environmentForProjectFingerprint?: string;
nonInteractive: boolean;
},
firstFingerprintInfo?: FingerprintInfo
): Promise<FingerprintInfo> {
firstFingerprintInfo?: {
fingerprint: Fingerprint;
platforms?: AppPlatform[];
origin: FingerprintOrigin;
}
): Promise<{ fingerprint: Fingerprint; origin: FingerprintOrigin }> {
if (hash) {
return await getFingerprintInfoFromHashAsync(graphqlClient, projectId, hash);
} else if (updateId) {
@@ -372,9 +349,13 @@ async function getFingerprintInfoInteractiveAsync({
projectId: string;
vcsClient: Client;
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn;
firstFingerprintInfo?: FingerprintInfo;
firstFingerprintInfo?: {
fingerprint: Fingerprint;
platforms?: AppPlatform[];
origin: FingerprintOrigin;
};
environmentForProjectFingerprint?: string;
}): Promise<FingerprintInfo> {
}): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
const prompt = firstFingerprintInfo
? 'Select the second fingerprint to compare against'
: 'Select a reference fingerprint for comparison';
@@ -468,9 +449,13 @@ async function getFingerprintInfoFromLocalProjectAsync({
projectId: string;
vcsClient: Client;
getServerSideEnvironmentVariablesAsync: GetServerSideEnvironmentVariablesFn;
firstFingerprintInfo: FingerprintInfo;
firstFingerprintInfo: {
fingerprint: Fingerprint;
platforms?: AppPlatform[];
origin: FingerprintOrigin;
};
environment?: string;
}): Promise<FingerprintInfo> {
}): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
const firstFingerprintPlatforms = firstFingerprintInfo.platforms;
if (!firstFingerprintPlatforms || firstFingerprintPlatforms.length === 0) {
throw new Error(
@@ -492,27 +477,22 @@ async function getFingerprintInfoFromLocalProjectAsync({
firstFingerprintPlatforms,
{ env }
);
return {
fingerprint,
origin: { type: FingerprintOriginType.Project },
isFingerprintSourceUnavailable: false,
};
return { fingerprint, origin: { type: FingerprintOriginType.Project } };
}
async function getFingerprintFromUpdateFragmentAsync(
updateWithFingerprint: UpdateFragment
): Promise<FingerprintInfo> {
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
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,
isFingerprintSourceUnavailable,
fingerprint: await getFingerprintFromFingerprintFragmentAsync(
updateWithFingerprint.fingerprint
),
platforms: [stringToAppPlatform(updateWithFingerprint.platform)],
origin: {
type: FingerprintOriginType.Update,
@@ -525,14 +505,13 @@ async function getFingerprintInfoFromHashAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string,
hash: string
): Promise<FingerprintInfo> {
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
const fingerprintFragment = await getFingerprintFragmentFromHashAsync(
graphqlClient,
projectId,
hash
);
const { fingerprint, isFingerprintSourceUnavailable } =
await getFingerprintFromFingerprintFragmentAsync(fingerprintFragment);
const fingerprint = await getFingerprintFromFingerprintFragmentAsync(fingerprintFragment);
let platforms;
const fingerprintBuilds = fingerprintFragment.builds?.edges.map(edge => edge.node) ?? [];
const fingerprintUpdates = fingerprintFragment.updates?.edges.map(edge => edge.node) ?? [];
@@ -543,7 +522,6 @@ async function getFingerprintInfoFromHashAsync(
}
return {
fingerprint,
isFingerprintSourceUnavailable,
platforms,
origin: {
type: FingerprintOriginType.Hash,
@@ -556,7 +534,7 @@ async function getFingerprintInfoFromUpdateGroupIdOrUpdateIdAsync(
projectId: string,
nonInteractive: boolean,
updateGroupIdOrUpdateId: string
): Promise<FingerprintInfo> {
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
// Some people may pass in update group id instead of update id, so add interactive support for that
try {
const maybeUpdateGroupId = updateGroupIdOrUpdateId;
@@ -600,18 +578,15 @@ async function getFingerprintInfoFromUpdateGroupIdOrUpdateIdAsync(
async function getFingerprintInfoFromBuildIdAsync(
graphqlClient: ExpoGraphqlClient,
buildId: string
): Promise<FingerprintInfo> {
): Promise<{ fingerprint: Fingerprint; platforms?: AppPlatform[]; origin: FingerprintOrigin }> {
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,
isFingerprintSourceUnavailable,
fingerprint: await getFingerprintFromFingerprintFragmentAsync(buildWithFingerprint.fingerprint),
platforms: [buildWithFingerprint.platform],
origin: {
type: FingerprintOriginType.Build,
@@ -636,48 +611,17 @@ 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: Fingerprint; isFingerprintSourceUnavailable: boolean }> {
): Promise<Fingerprint> {
const fingerprintDebugUrl = fingerprintFragment.debugInfoUrl;
if (!fingerprintDebugUrl) {
throw new Error(
`The source for fingerprint hash ${fingerprintFragment.hash} was not computed.`
);
}
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.`;
const fingerprintResponse = await fetch(fingerprintDebugUrl);
return (await fingerprintResponse.json()) as Fingerprint;
}
function printContentDiff(diff: FingerprintDiffItem): void {
+2 -9
View File
@@ -5,7 +5,6 @@ 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';
@@ -62,11 +61,6 @@ 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);
}
@@ -126,8 +120,7 @@ export default class Go extends EasCommand {
default: 'My Expo Go',
}),
'sdk-version': Flags.string({
description:
'Expo Go SDK version to prepare, for example 57 (default: the SDK version of the current project)',
description: 'Expo Go SDK version to prepare (default: latest)',
required: false,
}),
credentials: Flags.boolean({
@@ -164,7 +157,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 = toRepackTargetSdkVersion(flags['sdk-version'] ?? detectedSdkVersion);
let sdkVersion = flags['sdk-version'] ?? detectedSdkVersion;
if (!sdkVersion) {
({ sdkVersion } = await this.selectSdkVersionAsync(graphqlClient));
}
@@ -6,7 +6,6 @@ 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');
@@ -19,12 +18,7 @@ const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
@@ -40,12 +34,7 @@ const mockMetadataConnected = {
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
@@ -8,7 +8,6 @@ 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');
@@ -20,12 +19,7 @@ const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
@@ -41,12 +35,7 @@ const mockMetadataConnected = {
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
@@ -6,7 +6,6 @@ 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');
@@ -16,12 +15,7 @@ const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
@@ -37,12 +31,7 @@ const mockMetadataConnected = {
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
appStoreConnectApp: null,
};
@@ -9,7 +9,6 @@ import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
Role,
} from '../../../../graphql/generated';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
@@ -60,7 +59,7 @@ describe(IntegrationsConvexConnect, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
users: [],
},
accounts: [],
};
@@ -77,7 +76,7 @@ describe(IntegrationsConvexConnect, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
viewerUserPermission: { role: 'OWNER' as any },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
@@ -32,7 +32,7 @@ describe(IntegrationsConvexTeamDelete, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
viewerUserPermission: { role: 'OWNER' as any },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
@@ -41,7 +41,7 @@ describe(IntegrationsConvexTeamInvite, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
viewerUserPermission: { role: 'OWNER' as any },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
@@ -21,7 +21,7 @@ describe(IntegrationsConvexTeam, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
viewerUserPermission: { role: 'OWNER' as any },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
@@ -62,12 +62,7 @@ describe(IntegrationsPostHogConnect, () => {
email: '[email protected]',
featureGates: {},
isExpoAdmin: false,
primaryAccount: {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
primaryAccount: { id: testAccountId, name: testAccountName, ownerUserActor: null, users: [] },
accounts: [],
};
@@ -75,7 +70,7 @@ describe(IntegrationsPostHogConnect, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
viewerUserPermission: { role: Role.Owner },
users: [{ role: Role.Owner, actor: { id: 'test-user-id' } }],
};
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');
const { ora } = jest.requireMock('../../../../ora') as { ora: jest.Mock };
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');
const { ora } = jest.requireMock('../../../../ora') as { ora: jest.Mock };
const spinner = {
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
@@ -52,15 +52,12 @@ describe(SimulatorAvailability, () => {
jest.clearAllMocks();
});
function createCommand(
argv: string[],
{ isExpoAdmin = false }: { isExpoAdmin?: boolean } = {}
): SimulatorAvailability {
function createCommand(argv: string[]): SimulatorAvailability {
const command = new SimulatorAvailability(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId,
loggedIn: { actor: { isExpoAdmin }, graphqlClient },
loggedIn: { graphqlClient },
});
return command;
}
@@ -104,19 +101,6 @@ 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,51 +282,6 @@ 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',
@@ -340,15 +295,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`
);
});
@@ -411,7 +366,7 @@ describe(Simulator, () => {
const { command } = createCommand(['--platform', 'ios']);
await command.runAsync();
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
});
it('forwards --name to the create mutation', async () => {
@@ -456,48 +411,6 @@ 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})`);
@@ -529,7 +442,7 @@ describe(Simulator, () => {
graphqlClient,
'session-123'
);
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
expect(process.listeners('SIGINT')).toEqual([...existingSigintListeners]);
processExitSpy.mockRestore();
});
@@ -161,7 +161,7 @@ describe(SimulatorList, () => {
'--status',
'new',
'--type',
'appium',
'argent',
'--platform',
'ios',
'--name',
@@ -179,7 +179,7 @@ describe(SimulatorList, () => {
after: 'page-cursor',
filter: {
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
types: [DeviceRunSessionType.Appium],
types: [DeviceRunSessionType.Argent],
platforms: [AppPlatform.Ios],
name: 'checkout',
},
@@ -10,7 +10,6 @@ import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
resetSimulatorEnvAsync,
} from '../../../simulator/env';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorStop from '../stop';
@@ -19,7 +18,6 @@ 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(() => {
@@ -42,7 +40,6 @@ 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(
@@ -73,7 +70,6 @@ describe(SimulatorStop, () => {
jest.clearAllMocks();
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue(makeStoppedDeviceRunSession());
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
mockResetSimulatorEnvAsync.mockResolvedValue();
});
function createCommand(argv: string[]): {
@@ -102,7 +98,6 @@ describe(SimulatorStop, () => {
graphqlClient,
'session-123'
);
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
id: 'session-123',
status: DeviceRunSessionStatus.Stopped,
@@ -125,7 +120,6 @@ 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: { actor, graphqlClient },
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorAvailability, {
nonInteractive,
});
@@ -50,7 +50,6 @@ export default class SimulatorAvailability extends EasCommand {
graphqlClient,
projectId
));
available ||= actor.isExpoAdmin;
fetchSpinner?.stop();
} catch (err) {
fetchSpinner?.fail('Failed to check EAS Simulator availability');
@@ -68,10 +68,6 @@ 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),
@@ -138,7 +134,6 @@ 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];
@@ -170,7 +165,6 @@ 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;
@@ -406,7 +400,7 @@ async function waitForSessionEndOrInterruptAsync({
jobRunStatus === JobRunStatus.Finished
) {
spinner.succeed(`Simulator session ended. ${link(deviceRunSessionUrl)}`);
await resetSimulatorEnvVerboseAsync(projectDir, deviceRunSessionId);
await resetSimulatorEnvVerboseAsync(projectDir);
return;
}
@@ -420,7 +414,7 @@ async function waitForSessionEndOrInterruptAsync({
);
if (stopped) {
spinner.succeed('Simulator session stopped');
await resetSimulatorEnvVerboseAsync(projectDir, deviceRunSessionId);
await resetSimulatorEnvVerboseAsync(projectDir);
} else {
spinner.fail(
`Could not confirm the simulator session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
@@ -490,7 +484,7 @@ async function stopDeviceRunSessionAfterInterruptAsync({
);
if (stopped) {
spinner.succeed('Simulator session stopped');
await resetSimulatorEnvVerboseAsync(projectDir, deviceRunSessionId);
await resetSimulatorEnvVerboseAsync(projectDir);
} else {
spinner.fail(
`Could not confirm the simulator session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
@@ -502,12 +496,9 @@ async function stopDeviceRunSessionAfterInterruptAsync({
process.exit(130);
}
async function resetSimulatorEnvVerboseAsync(
projectDir: string,
deviceRunSessionId: string
): Promise<void> {
async function resetSimulatorEnvVerboseAsync(projectDir: string): Promise<void> {
try {
await resetSimulatorEnvAsync(projectDir, deviceRunSessionId);
await resetSimulatorEnvAsync(projectDir);
} catch (err) {
Log.error(`Failed to clean up ${SIMULATOR_DOTENV_FILE_NAME}`);
throw err;
@@ -11,7 +11,6 @@ import {
EAS_SIMULATOR_SESSION_ID,
SIMULATOR_DOTENV_FILE_NAME,
loadSimulatorEnvAsync,
resetSimulatorEnvAsync,
} from '../../simulator/env';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
@@ -69,8 +68,6 @@ 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',
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
runtimeVersion: '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',
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
runtimeVersion: '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.runtime.version,
runtimeVersion: updateStub.runtimeVersion,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -225,7 +225,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: updateStub.branch.id,
runtimeVersion: updateStub.runtime.version,
runtimeVersion: updateStub.runtimeVersion,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -270,7 +270,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: updateStub.branch.id,
runtimeVersion: updateStub.runtime.version,
runtimeVersion: updateStub.runtimeVersion,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -313,7 +313,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: updateStub.branch.id,
runtimeVersion: updateStub.runtime.version,
runtimeVersion: updateStub.runtimeVersion,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -357,7 +357,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: 'other-id',
runtimeVersion: updateStub.runtime.version,
runtimeVersion: updateStub.runtimeVersion,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -401,7 +401,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: 'blah-id',
runtimeVersion: updateStub.runtime.version,
runtimeVersion: updateStub.runtimeVersion,
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',
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
runtimeVersion: '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',
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
runtimeVersion: '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',
runtime: { __typename: 'Runtime' as const, id: 'runtime-1', version: '1.0.0' },
runtimeVersion: '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 => String(c[0]).includes('ago'))).toBe(true);
expect(mockLogLog.mock.calls.some(c => /ago/.test(String(c[0])))).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.runtime.version === runtime.runtimeVersion
update => update.runtimeVersion === 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].runtime.version;
const runtimeVersion = rolloutUpdateGroup[0].runtimeVersion;
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.runtime.version,
runtimeVersion: arbitraryUpdate.runtimeVersion,
};
}
@@ -60,7 +60,14 @@ export const testAppFragment: AppFragment = {
ownerAccount: {
id: 'test-account-id',
name: 'testuser',
viewerUserPermission: { role: Role.Owner },
users: [
{
role: Role.Owner,
actor: {
id: 'test-user-id',
},
},
],
},
};
@@ -11,18 +11,18 @@ export const jester: Actor = {
primaryAccount: {
id: 'jester-account-id',
name: 'jester',
viewerUserPermission: { role: Role.Admin },
users: [{ role: Role.Admin, actor: { id: 'jester-id' } }],
},
accounts: [
{
id: 'jester-account-id',
name: 'jester',
viewerUserPermission: { role: Role.Admin },
users: [{ role: Role.Admin, actor: { id: 'jester-id' } }],
},
{
id: 'other-account-id',
name: 'other',
viewerUserPermission: { role: Role.ViewOnly },
users: [{ role: Role.ViewOnly, actor: { id: 'jester-id' } }],
},
],
isExpoAdmin: false,
@@ -37,7 +37,7 @@ export const jester2 = {
{
id: 'jester2-account-id',
name: 'jester2',
viewerUserPermission: { role: Role.Admin },
users: [{ role: Role.Admin, actor: { id: 'jester2-id' } }],
},
],
isExpoAdmin: false,
@@ -52,7 +52,7 @@ export const robot: Actor = {
{
id: 'robot-account-id',
name: 'robot-account',
viewerUserPermission: { role: Role.Owner },
users: [],
},
],
isExpoAdmin: false,
@@ -60,7 +60,14 @@ export const testAppFragment: AppFragment = {
ownerAccount: {
id: 'test-account-id',
name: 'testuser',
viewerUserPermission: { role: Role.Owner },
users: [
{
role: Role.Owner,
actor: {
id: 'test-user-id',
},
},
],
},
};
@@ -1,7 +1,7 @@
import { CredentialsSource } from '@expo/eas-json';
import { vol } from 'memfs';
import { IosAppBuildCredentialsFragment, Role } from '../../../graphql/generated';
import { IosAppBuildCredentialsFragment } 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,
viewerUserPermission: { role: Role.Owner },
users: [],
},
projectName: appLookupParams.projectName,
},
@@ -102,7 +102,7 @@ describe(IosCredentialsProvider, () => {
account: {
id: `id-${appLookupParams.account.name}`,
name: appLookupParams.account.name,
viewerUserPermission: { role: Role.Owner },
users: [],
},
projectName: appLookupParams.projectName,
},
@@ -24,7 +24,7 @@ import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from '../BuildC
import { chooseDevicesAsync } from '../DeviceUtils';
import { SetUpAdhocProvisioningProfile, doUDIDsMatch } from '../SetUpAdhocProvisioningProfile';
import { getAscApiKeyForAppSubmissionsAsync } from '../../api/GraphqlClient';
import { AppleTeamType, AuthenticationMode } from '../../appstore/authenticateTypes';
import { AuthenticationMode, AppleTeamType } 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