mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d31ecc2bc | ||
|
|
890286a08d | ||
|
|
33ff59a75e | ||
|
|
c2bbba6e78 | ||
|
|
86b85e7d8d | ||
|
|
03a7d3a6ee | ||
|
|
a21db49184 | ||
|
|
6dd6da083d | ||
|
|
656921d776 | ||
|
|
fabde0c2bb | ||
|
|
7680f33879 | ||
|
|
199a996b38 | ||
|
|
4728c64d3f | ||
|
|
0fdf96e795 | ||
|
|
04f3bee0c4 | ||
|
|
140cfe445a | ||
|
|
da55f26cac | ||
|
|
e24130c6e0 | ||
|
|
2a0fe12836 | ||
|
|
4c153da14d | ||
|
|
81166df987 | ||
|
|
ef189b00be | ||
|
|
27e56afd12 | ||
|
|
1c342621c0 | ||
|
|
9b7098cb58 | ||
|
|
9b247a6197 | ||
|
|
862d0a8f09 | ||
|
|
417996ade0 | ||
|
|
1c6161432a | ||
|
|
3f667ae4a8 | ||
|
|
4fb6de564c | ||
|
|
975693e96e | ||
|
|
b84233bf07 | ||
|
|
6dec2f9a13 | ||
|
|
c15e184bb8 | ||
|
|
89740dbbfd | ||
|
|
390a165c57 | ||
|
|
f7fb43b974 | ||
|
|
a32080fadf | ||
|
|
55017fbf70 | ||
|
|
7fd8a3a75b | ||
|
|
0f38c2ad82 |
No files matched your search
@@ -26,6 +26,9 @@ jobs:
|
||||
uses: ./.github/actions/setup-mise
|
||||
env:
|
||||
MISE_NODE_VERSION: ${{ matrix.node }}
|
||||
- name: Install ffmpeg
|
||||
# build-tools recording-recovery tests cut and decode real MP4 files with ffmpeg and ffprobe.
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
env:
|
||||
|
||||
+41
-1
@@ -8,10 +8,46 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--network-capture` to `eas simulator` to record HTTP(S) traffic from apps on the device, and `--network-capture-field` to also keep headers, query values, or request and response bodies. ([#4308](https://github.com/expo/eas-cli/pull/4308) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.9.0](https://github.com/expo/eas-cli/releases/tag/v24.9.0) - 2026-10-02
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--force-end-active-rollout` to `eas update`, `eas update:republish`, `eas update:roll-back-to-embedded` and `eas update:rollback`. ([#4233](https://github.com/expo/eas-cli/pull/4233) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Allow `eas simulator:start --platform android --egress local`. On Android, `--egress-allow localhost:<port>` also covers `10.0.2.2:<port>`, and refused destinations are shown as warnings. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Support `eas/start_local_egress` on Linux for Android emulators: an always-listening relay for the emulator's proxy, a systemd scope and nftables fence that refuse traffic that cannot use the tunnel, and fence refusals in the session log. ([#4519](https://github.com/expo/eas-cli/pull/4519) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Add `--upload-source-maps` to `eas update`, which uploads the generated source maps to EAS so that stack traces from an update can be symbolicated. Source maps are stripped of `sourcesContent` before upload. ([#4436](https://github.com/expo/eas-cli/pull/4436) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Upload the embedded bundle after build by default for projects on SDK 58 and later. Set `EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE` to `0` to disable it. On SDK 57 and below the feature remains experimental and opt-in via `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE`. ([#4433](https://github.com/expo/eas-cli/pull/4433) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Save manual iOS and Android live preview screenshots as session artifacts when supported by the preview server. ([#4481](https://github.com/expo/eas-cli/pull/4481) by [@vonovak](https://github.com/vonovak))
|
||||
- [build-tools] Add the `eas/install_mitmproxy` step. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Start serve-sim with `--network-capture`, and with `--network-capture-field` for each requested field, when a simulator session requests network capture. ([#4307](https://github.com/expo/eas-cli/pull/4307) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Allow workflow submissions to skip loading app config when project and application identifiers are supplied. ([#4471](https://github.com/expo/eas-cli/pull/4471) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Record Android device sessions and upload the screen recording, including the partial recording a killed Device Hub leaves behind. ([#4418](https://github.com/expo/eas-cli/pull/4418) by [@vonovak](https://github.com/vonovak))
|
||||
- [build-tools] Add start and collect steps that attach bounded simulator logs from serve-sim to device run sessions. ([#4463](https://github.com/expo/eas-cli/pull/4463) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [build-tools] Add `eas/start_agent_device_session`, one step for agent-device sessions that boots the device, downloads, installs and launches the app, and starts the session, with the independent parts in parallel. ([#4491](https://github.com/expo/eas-cli/pull/4491) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-build-job] Add `sentry` workflow interpolation context schema for Sentry-triggered workflow runs. ([#4453](https://github.com/expo/eas-cli/pull/4453) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop `eas update:rollback` from always forcing non-interactive mode. ([#4504](https://github.com/expo/eas-cli/pull/4504) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Pass the step cancellation signal to `eas/download_build`. ([#4511](https://github.com/expo/eas-cli/pull/4511) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Accept `null` for the TestFlight group `hasAccessToAllBuilds` attribute from App Store Connect. ([#4528](https://github.com/expo/eas-cli/pull/4528) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Support `queued` and `starting` status filters in `eas simulator:list` and keep simulator commands compatible with the current GraphQL schema. ([#4527](https://github.com/expo/eas-cli/pull/4527) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Explain how to resolve missing or expired Apple agreements when App Store Connect rejects a request. ([#4513](https://github.com/expo/eas-cli/pull/4513) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Correct the `eas simulator:start --max-duration-minutes` help text: every plan can set a custom duration up to the plan's maximum, which is also the default. ([#4524](https://github.com/expo/eas-cli/pull/4524) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Fix `eas workflow:validate` crashing with certain workflow schemas, and exit with a non-zero code when validation fails. ([#4507](https://github.com/expo/eas-cli/pull/4507) by [@yuvalgnessin-qz](https://github.com/yuvalgnessin-qz))
|
||||
- [build-tools] Reuse App Store Connect tokens and refresh them before expiry so long uploads and processing waits remain authenticated. ([#4497](https://github.com/expo/eas-cli/pull/4497) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Clear old Maestro CLI HTML reports before each test attempt so retries cannot upload an earlier report. ([#4409](https://github.com/expo/eas-cli/pull/4409) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Poll the web preview readiness every 250 ms instead of every second, so a session goes live sooner after its host is ready. ([#4496](https://github.com/expo/eas-cli/pull/4496) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Keep the `eas simulator` session spinner on one line, so resizing the terminal no longer leaves copies of it behind. The `eas simulator:stop` command is now printed once before the spinner starts. ([#4488](https://github.com/expo/eas-cli/pull/4488) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Disable agent-device's internal idle timeouts in remote sessions, so only the session's own idle and duration limits end it. ([#4485](https://github.com/expo/eas-cli/pull/4485) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] Keep `eas simulator:events --follow` running while a session is queued or starting. ([#4506](https://github.com/expo/eas-cli/pull/4506) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
|
||||
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
|
||||
|
||||
### 🎉 New features
|
||||
@@ -19,6 +55,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
- [eas-cli] Add `--build-fingerprint` to `eas simulator` to install the newest build with a given fingerprint. ([#4472](https://github.com/expo/eas-cli/pull/4472) by [@tchayen](https://github.com/tchayen))
|
||||
- [eas-cli] Accept build IDs in `workflow:ssh <id>`. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Add `eas/update_testflight_metadata` to set changelogs and TestFlight groups for an uploaded build with an App Store Connect API key. The step fails if a requested group does not exist. ([#4475](https://github.com/expo/eas-cli/pull/4475) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
|
||||
- [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk))
|
||||
@@ -27,15 +64,18 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Report early Argent server exits during simulator startup instead of waiting for the full readiness timeout. ([#4454](https://github.com/expo/eas-cli/pull/4454) by [@sjkim-expo](https://github.com/sjkim-expo))
|
||||
- [eas-cli] Stop spinners from printing a new line on every frame after the terminal gets narrower. ([#4470](https://github.com/expo/eas-cli/pull/4470) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [eas-cli] Exclude time waiting for simulator concurrency from the session startup timeout and show queue progress. ([#4411](https://github.com/expo/eas-cli/pull/4411) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] List `eas simulator` only once in `eas --help`. The `sim` shorthand is now a hidden alias, so it still runs but no longer duplicates the entry. ([#4457](https://github.com/expo/eas-cli/pull/4457) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Start the Argent tool-server with `ARGENT_EMULATOR_NO_WINDOW=1`, so Argent can boot the Android emulator again after it stops. Without it, the windowed emulator fails on the Linux image because `libpulse.so.0` is missing. ([#4465](https://github.com/expo/eas-cli/pull/4465) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Fix `eas account:view` printing `Role: undefined` for the new Release Manager role. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
- [eas-cli] Include legacy serve-sim sessions in `eas simulator:list --type web-preview-only`, and list `web-preview-only` only once in the `--type` options. ([#4466](https://github.com/expo/eas-cli/pull/4466) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
|
||||
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#XXXX](https://github.com/expo/eas-cli/pull/XXXX) by [@wschurman](https://github.com/wschurman))
|
||||
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#4400](https://github.com/expo/eas-cli/pull/4400) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that you d
|
||||
to access the functionality of and directly benefit from the
|
||||
functionality of the Licensed Work.
|
||||
|
||||
Change Date: 2029-07-15
|
||||
Change Date: 2029-10-01
|
||||
|
||||
Change License: MIT
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.8.0",
|
||||
"PRODUCTION": "24.8.0"
|
||||
"STAGING": "24.9.0",
|
||||
"PRODUCTION": "24.9.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.8.0",
|
||||
"version": "24.10.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "24.8.0",
|
||||
"version": "24.9.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -42,14 +42,14 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "24.8.0",
|
||||
"@expo/eas-build-job": "24.8.0",
|
||||
"@expo/eas-build-job": "24.9.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.8.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": "24.8.0",
|
||||
"@expo/steps": "24.9.0",
|
||||
"@expo/template-file": "24.8.0",
|
||||
"@expo/turtle-spawn": "24.8.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
@@ -60,6 +60,7 @@ jest.mock('../../utils/expoUpdates', () => ({
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
|
||||
...jest.requireActual('../../utils/expoUpdatesEmbedded'),
|
||||
uploadEmbeddedBundleAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/hooks', () => ({
|
||||
@@ -317,36 +318,82 @@ describe(androidBuilder, () => {
|
||||
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
|
||||
});
|
||||
|
||||
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
describe('embedded bundle upload phase', () => {
|
||||
function createContext({
|
||||
env = {},
|
||||
sdkVersion,
|
||||
}: {
|
||||
env?: Record<string, string>;
|
||||
sdkVersion?: string;
|
||||
}): BuildContext<Android.Job> {
|
||||
return new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
...env,
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
metadata: sdkVersion ? { sdkVersion } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
it('runs by default on SDK 58 and later', async () => {
|
||||
const ctx = createContext({ sdkVersion: '58.0.0' });
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
it('skips by default on SDK 57 and earlier', async () => {
|
||||
const ctx = createContext({ sdkVersion: '57.0.0' });
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
await androidBuilder(ctx);
|
||||
|
||||
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
it('skips by default when the SDK version is unknown', async () => {
|
||||
const ctx = createContext({});
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs on SDK 57 and earlier when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
|
||||
const ctx = createContext({
|
||||
sdkVersion: '57.0.0',
|
||||
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '1' },
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
it('skips on SDK 58 and later when EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE is 0', async () => {
|
||||
const ctx = createContext({
|
||||
sdkVersion: '58.0.0',
|
||||
env: { EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE: '0' },
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs on SDK 57 and earlier when the experimental EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is 1', async () => {
|
||||
const ctx = createContext({
|
||||
sdkVersion: '57.0.0',
|
||||
env: { EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1' },
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import {
|
||||
shouldUploadEmbeddedBundle,
|
||||
uploadEmbeddedBundleAsync,
|
||||
} from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
|
||||
@@ -223,7 +226,7 @@ async function buildInnerAsync(
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
});
|
||||
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import {
|
||||
shouldUploadEmbeddedBundle,
|
||||
uploadEmbeddedBundleAsync,
|
||||
} from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
@@ -240,7 +243,7 @@ async function buildInnerAsync(
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
if (shouldUploadEmbeddedBundle(ctx)) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import { generateGymfileFromTemplateFunction } from './functions/generateGymfile
|
||||
import { createGetCredentialsForBuildTriggeredByGithubIntegration } from './functions/getCredentialsForBuildTriggeredByGitHubIntegration';
|
||||
import { injectAndroidCredentialsFunction } from './functions/injectAndroidCredentials';
|
||||
import { createInstallMaestroBuildFunction } from './functions/installMaestro';
|
||||
import { createInstallMitmproxyBuildFunction } from './functions/installMitmproxy';
|
||||
import { createInstallBuildFunction } from './functions/installBuild';
|
||||
import { createInstallNodeModulesBuildFunction } from './functions/installNodeModules';
|
||||
import { createInstallPodsBuildFunction } from './functions/installPods';
|
||||
@@ -45,6 +46,7 @@ import { createRolloutPosthogFlagFunction } from './functions/rolloutPosthogFlag
|
||||
import { createSaveCacheFunction } from './functions/saveCache';
|
||||
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from './functions/startAgentDeviceSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from './functions/startAppiumRemoteSession';
|
||||
@@ -54,11 +56,14 @@ import { createStartIosSimulatorRecordingsBuildFunction } from './functions/star
|
||||
import { createStartLocalEgressBuildFunction } from './functions/startLocalEgress';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from './functions/startWebPreviewRemoteSession';
|
||||
import { createStartServeSimMetricsBuildFunction } from './functions/startServeSimMetrics';
|
||||
import { createStartServeSimLogsBuildFunction } from './functions/startServeSimLogs';
|
||||
import { createCollectServeSimLogsBuildFunction } from './functions/collectServeSimLogs';
|
||||
import { createStartSandboxBuildFunction } from './functions/startSandbox';
|
||||
import { createCollectServeSimMetricsBuildFunction } from './functions/collectServeSimMetrics';
|
||||
import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
|
||||
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
|
||||
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
|
||||
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
|
||||
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
|
||||
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
|
||||
@@ -100,6 +105,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartAgentDeviceSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAppiumRemoteSessionBuildFunction(ctx),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
@@ -111,9 +117,12 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadDeviceRunSessionScreenRecordingsBuildFunction(ctx),
|
||||
createStartWebPreviewRemoteSessionBuildFunction(ctx),
|
||||
createStartServeSimMetricsBuildFunction(),
|
||||
createStartServeSimLogsBuildFunction(),
|
||||
createCollectServeSimLogsBuildFunction(ctx),
|
||||
createStartSandboxBuildFunction(ctx),
|
||||
createCollectServeSimMetricsBuildFunction(ctx),
|
||||
createInstallMaestroBuildFunction(),
|
||||
createInstallMitmproxyBuildFunction(),
|
||||
|
||||
createInstallPodsBuildFunction(),
|
||||
createSendSlackMessageFunction(),
|
||||
@@ -128,6 +137,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
|
||||
createSubmissionEntityFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
createUpdateTestFlightMetadataBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(ctx),
|
||||
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAppiumRemoteSessionBuildFunction } from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceEvents');
|
||||
jest.mock('../../utils/appiumEvents');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
waitForFileAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const finishHost = jest.fn();
|
||||
const closePreview = jest.fn();
|
||||
const stopTool = jest.fn();
|
||||
const stopTunnel = jest.fn();
|
||||
const stopEvents = jest.fn();
|
||||
const openPreview = jest.fn();
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// The daemon package is external. No installation or daemon process runs in these tests.
|
||||
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
|
||||
jest
|
||||
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
|
||||
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' } as never]);
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 4567, token: 'daemon-token' });
|
||||
for (const stop of [finishHost, closePreview, stopTool, stopTunnel, stopEvents]) {
|
||||
stop.mockResolvedValue(undefined);
|
||||
}
|
||||
openPreview.mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://preview.example.test',
|
||||
closeAsync: closePreview,
|
||||
});
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopTool,
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://tool.example.test',
|
||||
subdomainId: 'tool-id',
|
||||
stopAsync: stopTunnel,
|
||||
});
|
||||
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['Appium', createStartAppiumRemoteSessionBuildFunction],
|
||||
['Agent Device', createStartAgentDeviceRemoteSessionBuildFunction],
|
||||
] as const)('%s host ownership', (name, createFunction) => {
|
||||
async function runAsync() {
|
||||
const fn = createFunction({} as CustomBuildContext);
|
||||
await fn.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
it('holds the host until session completion', async () => {
|
||||
await runAsync();
|
||||
expect(openPreview).toHaveBeenCalledWith({ baseDomain: 'example.test' });
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://preview.example.test',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(closePreview).not.toHaveBeenCalled();
|
||||
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopEvents).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops automation while recording finalization or upload is pending', async () => {
|
||||
let release!: () => void;
|
||||
let stopped!: () => void;
|
||||
const pendingFinish = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopped = resolve;
|
||||
});
|
||||
finishHost.mockReturnValueOnce(pendingFinish);
|
||||
stopTool.mockImplementationOnce(async () => {
|
||||
stopped();
|
||||
});
|
||||
let completed = false;
|
||||
const running = runAsync().then(() => {
|
||||
completed = true;
|
||||
});
|
||||
try {
|
||||
await toolStopped;
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
expect(completed).toBe(false);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it('finalizes recording and stops automation while the tunnel close is pending', async () => {
|
||||
let release!: () => void;
|
||||
let finished!: () => void;
|
||||
const pendingClose = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const hostFinished = new Promise<void>(resolve => {
|
||||
finished = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopTool.mockImplementationOnce(async () => resolve());
|
||||
});
|
||||
stopTunnel.mockReturnValueOnce(pendingClose);
|
||||
finishHost.mockImplementationOnce(async () => {
|
||||
finished();
|
||||
});
|
||||
const running = runAsync();
|
||||
try {
|
||||
// Both settle while the tunnel close is still held open.
|
||||
await Promise.all([hostFinished, toolStopped]);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
|
||||
it('still stops the automation process when event collection stop rejects', async () => {
|
||||
stopEvents.mockRejectedValueOnce(new Error('collector failed'));
|
||||
// Agent Device wraps its collector stop in a catch-all helper; Appium does not.
|
||||
const outcome = await runAsync().then(
|
||||
() => 'resolved',
|
||||
(err: Error) => err.message
|
||||
);
|
||||
expect(outcome).toBe(name === 'Appium' ? 'collector failed' : 'resolved');
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('still stops automation when recording finalization rejects', async () => {
|
||||
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
|
||||
await expect(runAsync()).rejects.toThrow('recording cleanup failed');
|
||||
expect(stopTool).toHaveBeenCalledTimes(1);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])('finishes the host after %s fails', async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
// A teardown failure must not replace the error that ended the session.
|
||||
finishHost.mockRejectedValueOnce(new Error('recording cleanup failed'));
|
||||
if (phase === 'preview') {
|
||||
openPreview.mockRejectedValueOnce(error);
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(runAsync()).rejects.toBe(error);
|
||||
expect(finishHost).toHaveBeenCalledTimes(1);
|
||||
// An agent-device preview failure aborts the daemon task, which then may not start its
|
||||
// daemon or tunnel. Everything that started must be stopped.
|
||||
const abortsDaemonTask = name === 'Agent Device' && phase === 'preview';
|
||||
expect(stopTool).toHaveBeenCalledTimes(
|
||||
abortsDaemonTask ? jest.mocked(spawnDetached).mock.calls.length : 1
|
||||
);
|
||||
expect(stopTunnel).toHaveBeenCalledTimes(
|
||||
abortsDaemonTask ? jest.mocked(startNgrokTunnelAsync).mock.calls.length : 1
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.objectContaining({ message: 'recording cleanup failed' }) },
|
||||
'Could not stop the session host during remote session teardown.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not stop the session host after the remote session failed',
|
||||
expect.objectContaining({ message: 'recording cleanup failed' }),
|
||||
{ level: 'warning' }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import * as tar from 'tar';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import * as files from '../../../utils/files';
|
||||
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
|
||||
|
||||
// contains a 'TestApp.app/TestApp' file with 'i am executable' content
|
||||
@@ -255,6 +256,144 @@ describe('downloadBuild', () => {
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('passes the abort signal to the build lookup', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
|
||||
);
|
||||
const buildId = randomUUID();
|
||||
|
||||
await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(graphqlClient.query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ buildId },
|
||||
{ fetch: expect.any(Function) }
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ signal: expect.any(Object) })
|
||||
);
|
||||
});
|
||||
|
||||
it('stops a pending build lookup when aborted', async () => {
|
||||
// The lookup request runs through the abort-aware fetch from graphqlAbortContext and
|
||||
// hangs until its signal fires, like a stalled API call.
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = jest.fn(
|
||||
(_input: unknown, init?: RequestInit) =>
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason));
|
||||
})
|
||||
) as typeof globalThis.fetch;
|
||||
const graphqlClient = {
|
||||
query: jest.fn((_query, _variables, context: { fetch: typeof globalThis.fetch }) => ({
|
||||
toPromise: () =>
|
||||
context.fetch('https://api.expo.dev/graphql', {}).then(
|
||||
() => ({ data: undefined }),
|
||||
(networkError: Error) => ({ error: new CombinedError({ networkError }) })
|
||||
),
|
||||
})),
|
||||
} as unknown as Client;
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('daemon failed');
|
||||
|
||||
try {
|
||||
const download = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
controller.abort(failure);
|
||||
|
||||
await expect(download).rejects.toBe(failure);
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it('lets a started extraction finish before it reports an abort', async () => {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: APP_TAR_GZ_BUFFER, url: APPLICATION_ARCHIVE_URL })
|
||||
);
|
||||
const { decompressTarAsync } = jest.requireActual<typeof files>('../../../utils/files');
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('daemon failed');
|
||||
let extractionDirectory: string | undefined;
|
||||
let extractionFinished = false;
|
||||
const decompressSpy = jest.spyOn(files, 'decompressTarAsync').mockImplementation(async args => {
|
||||
extractionDirectory = args.destinationDirectory;
|
||||
// The startup fails while the archive is being extracted.
|
||||
controller.abort(failure);
|
||||
await decompressTarAsync(args);
|
||||
extractionFinished = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient: createMockGraphqlClient({}),
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toBe(failure);
|
||||
|
||||
// The download settled only after the extraction wrote its last file.
|
||||
expect(extractionFinished).toBe(true);
|
||||
expect(
|
||||
await fs.promises.readFile(
|
||||
path.join(extractionDirectory!, 'TestApp.app', 'TestApp'),
|
||||
'utf8'
|
||||
)
|
||||
).toBe('i am executable\n');
|
||||
} finally {
|
||||
decompressSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not look up the build when already aborted', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
const failure = new Error('boot failed');
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
signal: AbortSignal.abort(failure),
|
||||
})
|
||||
).rejects.toBe(failure);
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects missing or ambiguous build sources', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
@@ -424,6 +563,34 @@ describe('downloadBuild', () => {
|
||||
});
|
||||
|
||||
describe('createDownloadBuildFunction', () => {
|
||||
it('aborts a pending archive request when the step times out', async () => {
|
||||
let requestAborted = false;
|
||||
jest.mocked(fetch).mockImplementation(
|
||||
async (_url, options) =>
|
||||
await new Promise<Response>((_resolve, reject) => {
|
||||
options?.signal?.addEventListener('abort', () => {
|
||||
requestAborted = true;
|
||||
reject(new Error('Archive request aborted'));
|
||||
});
|
||||
})
|
||||
);
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
const step = createDownloadBuildFunction({
|
||||
graphqlClient,
|
||||
} as any).createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ staticContextContent: { job: {} } }),
|
||||
{
|
||||
callInputs: { application_archive_url: APPLICATION_ARCHIVE_URL, extensions: ['ipa'] },
|
||||
timeoutMs: 100,
|
||||
}
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow('timed out');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(requestAborted).toBe(true);
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should download a build', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BuildRuntimePlatform, BuildStep } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createInstallMitmproxyBuildFunction } from '../installMitmproxy';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
describe('createInstallMitmproxyBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function createStep(env: Record<string, string>): BuildStep {
|
||||
const globalCtx = createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.DARWIN });
|
||||
globalCtx.updateEnv(env);
|
||||
return createInstallMitmproxyBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {},
|
||||
});
|
||||
}
|
||||
|
||||
it('does not install when mitmdump is already on PATH', async () => {
|
||||
mockedSpawn.mockResolvedValueOnce({} as never);
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith('mitmdump', ['--version'], expect.anything());
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('warns instead of installing outside EAS Build VMs', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('not found'));
|
||||
|
||||
await createStep({}).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('installs the cask with Homebrew and leaves auto-update off', async () => {
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockResolvedValueOnce({} as never);
|
||||
|
||||
await createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'brew',
|
||||
['install', '--cask', 'mitmproxy'],
|
||||
expect.objectContaining({ env: expect.objectContaining({ HOMEBREW_NO_AUTO_UPDATE: '1' }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fail the job when mitmdump is still not runnable', async () => {
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockRejectedValueOnce(new Error('not found'));
|
||||
|
||||
await expect(
|
||||
createStep({ EAS_BUILD_RUNNER: 'eas-build' }).executeAsync()
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -271,20 +271,124 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
|
||||
});
|
||||
|
||||
it('rejects a non-junit output_format with maestro-runner', async () => {
|
||||
it.each(['html', 'allure'])(
|
||||
'accepts %s output_format with maestro-runner and keeps JUnit',
|
||||
async format => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const copyFileSpy = jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: format,
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--output=/home/expo/.maestro/tests/android-maestro-runner-attempt-0',
|
||||
])
|
||||
);
|
||||
expect(copyFileSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/android-maestro-runner-attempt-0/junit-report.xml',
|
||||
'/home/expo/.maestro/tests/junit-reports/android-maestro-junit-attempt-0.xml'
|
||||
);
|
||||
expect(parser.mergeJUnitReports).not.toHaveBeenCalled();
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
format === 'html'
|
||||
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/report.html'
|
||||
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results'
|
||||
);
|
||||
expect(mockedRunnerHarvest).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name:
|
||||
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
|
||||
paths: [
|
||||
format === 'html'
|
||||
? '/home/expo/.maestro/tests/android-maestro-runner-attempt-0'
|
||||
: '/home/expo/.maestro/tests/android-maestro-runner-attempt-0/allure-results',
|
||||
],
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('does not expose a selected runner report before an attempt starts', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
flow_path: [],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'html',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBeUndefined();
|
||||
expect(step.getOutputValueByName('junit_report_directory')).toBe(
|
||||
'/home/expo/.maestro/tests/junit-reports'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown output_format with maestro-runner', async () => {
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'csv',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner only supports the "junit" output_format'
|
||||
'maestro-runner supports "junit", "html", and "allure" output_format values'
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads the last runner HTML report even when the test fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'html',
|
||||
retries: 1,
|
||||
retry_failed_only: false,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
name: 'Maestro Runner HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-runner-attempt-1'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
'/home/expo/.maestro/tests/android-maestro-runner-attempt-1/report.html'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not fail a passing test when the runner report upload fails', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
mockUploadArtifact.mockRejectedValue(new Error('upload failed'));
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
backend: 'maestro-runner',
|
||||
output_format: 'allure',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('logs that maestro-runner does not support direct DADB', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
@@ -966,7 +1070,7 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
|
||||
it('uses $HOME/.maestro/tests output path and uploads the Maestro CLI HTML report', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({
|
||||
@@ -977,11 +1081,119 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
await step.executeAsync();
|
||||
|
||||
const args = mockedSpawn.mock.calls[0][1] as string[];
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
|
||||
// whole-directory upload picks it up.
|
||||
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests.
|
||||
const outputArg = args.find(a => a.startsWith('--output='));
|
||||
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
|
||||
expect(outputArg).not.toMatch(/junit-reports/);
|
||||
expect(step.getOutputValueByName('final_report_path')).toBe(
|
||||
'/home/expo/.maestro/tests/android-maestro-html.html'
|
||||
);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: 'Maestro HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads the Maestro CLI HTML report even when tests fail', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
name: 'Maestro HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('warns that Maestro CLI does not support Allure reports', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const step = createStep(
|
||||
{
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'allure',
|
||||
platform: 'android',
|
||||
},
|
||||
{ logger }
|
||||
);
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
|
||||
);
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the Maestro CLI HTML report before every retry attempt', async () => {
|
||||
const removeSpy = jest.spyOn(fs, 'rm').mockResolvedValue();
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
retries: 1,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(
|
||||
removeSpy.mock.calls.filter(
|
||||
([target]) => target === '/home/expo/.maestro/tests/android-maestro-html.html'
|
||||
)
|
||||
).toHaveLength(2);
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/home/expo/.maestro/tests/android-maestro-html.html',
|
||||
{
|
||||
force: true,
|
||||
}
|
||||
);
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/home/expo/.maestro/tests/android-maestro-html.html',
|
||||
{
|
||||
force: true,
|
||||
}
|
||||
);
|
||||
expect(removeSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockedSpawn.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(removeSpy.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
mockedSpawn.mock.invocationCallOrder[1]
|
||||
);
|
||||
});
|
||||
|
||||
it('does not run Maestro CLI or upload old HTML if the report cannot be cleared', async () => {
|
||||
jest.spyOn(fs, 'rm').mockImplementation(async target => {
|
||||
if (target === '/home/expo/.maestro/tests/android-maestro-html.html') {
|
||||
throw new Error('permission denied');
|
||||
}
|
||||
});
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
output_format: 'html',
|
||||
platform: 'android',
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(SystemError);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
|
||||
@@ -1216,28 +1428,31 @@ 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' }],
|
||||
});
|
||||
it.each(['junit', undefined])(
|
||||
'uploads runner screenshots with output_format=%s',
|
||||
async format => {
|
||||
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();
|
||||
const step = createStep({
|
||||
flow_path: ['a.yaml'],
|
||||
platform: 'ios',
|
||||
...(format === undefined ? {} : { output_format: format }),
|
||||
backend: 'maestro-runner',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(parseFlowResultsSpy).toHaveBeenCalledWith(
|
||||
'/home/expo/.maestro/tests/ios-maestro-runner-attempt-0'
|
||||
);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
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());
|
||||
@@ -1301,14 +1516,15 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
|
||||
});
|
||||
|
||||
it('does not harvest or upload screenshots when output_format is not junit', async () => {
|
||||
it('does not harvest or upload screenshots for Maestro CLI HTML', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'html' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedHarvest).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadArtifact.mock.calls[0][0].artifact.name).toBe('Maestro HTML Report');
|
||||
});
|
||||
|
||||
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
|
||||
|
||||
+19
-8
@@ -7,6 +7,7 @@ import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
monitorLocalEgressAsync,
|
||||
readLocalEgressHandoffAsync,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
stopLocalEgressResourcesAsync,
|
||||
} from '../../utils/localEgress';
|
||||
import {
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
@@ -27,6 +27,7 @@ jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/IosSimulatorUtils');
|
||||
jest.mock('../../../utils/processes');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/agentDeviceArtifacts');
|
||||
jest.mock('../../utils/argentArtifacts');
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
@@ -58,12 +59,15 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
subdomainId: 'controller-id',
|
||||
stopAsync: jest.fn(),
|
||||
}),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = { DEVICE_RUN_SESSION_ID: 'session-id' };
|
||||
const handoff = { url: 'https://egress.test', token: 'secret', fingerprint: 'key=', port: 8899 };
|
||||
@@ -82,6 +86,7 @@ const controllers = [
|
||||
|
||||
describe.each(controllers)('%s local egress', (_name, createFunction, controllerField) => {
|
||||
const stopPreview = jest.fn();
|
||||
const openPreview = jest.fn();
|
||||
const stopEgress = jest.fn();
|
||||
let lifetime: AbortSignal;
|
||||
async function run(signal?: AbortSignal): Promise<void> {
|
||||
@@ -119,11 +124,15 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as never);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
|
||||
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
openPreview.mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://preview.test',
|
||||
previewToken: 'preview-secret',
|
||||
stopAsync: stopPreview,
|
||||
closeAsync: jest.fn(),
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
|
||||
@@ -169,12 +178,14 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(stopEgress).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['startup', 'report', 'wait', 'teardown'])(
|
||||
it.each(['startup', 'preview', 'report', 'wait', 'teardown'])(
|
||||
'releases egress after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'startup') {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockRejectedValue(error);
|
||||
jest.mocked(startDeviceSessionHostAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'preview') {
|
||||
openPreview.mockRejectedValue(error);
|
||||
} else if (phase === 'report') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
|
||||
} else if (phase === 'wait') {
|
||||
@@ -186,7 +197,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
expect(lifetime.aborted).toBe(true);
|
||||
expect(stopEgress).toHaveBeenCalledTimes(1);
|
||||
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(
|
||||
phase === 'startup' || phase === 'report' ? 0 : 1
|
||||
phase === 'startup' || phase === 'preview' || phase === 'report' ? 0 : 1
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepContext, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { getDeviceRunSessionIdOrThrow } from '../../utils/remoteDeviceRunSession';
|
||||
import { uploadServeSimLogsFileAsync } from '../../utils/serveSimLogsArtifacts';
|
||||
import { ServeSimLogsRecorder } from '../../utils/serveSimLogsRecorder';
|
||||
import { createCollectServeSimLogsBuildFunction } from '../collectServeSimLogs';
|
||||
import { createStartServeSimLogsBuildFunction } from '../startServeSimLogs';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/serveSimLogsRecorder');
|
||||
jest.mock('../../utils/serveSimLogsArtifacts');
|
||||
jest.mock('../../utils/remoteDeviceRunSession');
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const step = { logger } as BuildStepContext;
|
||||
const args = { inputs: {}, outputs: {}, env: {} as BuildStepEnv };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('session-id');
|
||||
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('starts collection and tolerates startup failures', async () => {
|
||||
jest.mocked(ServeSimLogsRecorder.startAsync).mockRejectedValueOnce(new Error('disk failure'));
|
||||
await expect(createStartServeSimLogsBuildFunction().fn?.(step, args)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not start serve-sim simulator logs',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads each device with its own bounded signal', async () => {
|
||||
jest.mocked(ServeSimLogsRecorder.finishAsync).mockResolvedValue([
|
||||
{ udid: 'A', filePath: '/tmp/A.ndjson' },
|
||||
{ udid: 'B', filePath: '/tmp/B.ndjson' },
|
||||
]);
|
||||
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
|
||||
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledTimes(2);
|
||||
expect(uploadServeSimLogsFileAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'session-id',
|
||||
udid: 'A',
|
||||
filePath: '/tmp/A.ndjson',
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(uploadServeSimLogsFileAsync).mock.calls[0][1].signal).not.toBe(
|
||||
jest.mocked(uploadServeSimLogsFileAsync).mock.calls[1][1].signal
|
||||
);
|
||||
});
|
||||
|
||||
it('skips empty collection and warns on finalization failure', async () => {
|
||||
await createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args);
|
||||
expect(uploadServeSimLogsFileAsync).not.toHaveBeenCalled();
|
||||
jest.mocked(ServeSimLogsRecorder.finishAsync).mockRejectedValueOnce(new Error('failure'));
|
||||
await expect(
|
||||
createCollectServeSimLogsBuildFunction(ctx).fn?.(step, args)
|
||||
).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not finalize serve-sim simulator logs',
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
+239
-12
@@ -8,19 +8,23 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
import { type StartupTasks, createStartupTasks } from '../../utils/startupTasks';
|
||||
import {
|
||||
createStartAgentDeviceRemoteSessionBuildFunction,
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
|
||||
// The daemon entry path and the state directory are resolved from the home directory when
|
||||
// the module loads, so point it at a temp home we can populate.
|
||||
@@ -40,6 +44,7 @@ jest.mock('../../utils/agentDeviceArtifacts', () => ({
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
@@ -47,7 +52,6 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
@@ -104,6 +108,7 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: mockDaemonStopAsync,
|
||||
});
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token: 'daemon-token' });
|
||||
@@ -112,10 +117,13 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
subdomainId: 'agent-device-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -129,12 +137,12 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
});
|
||||
|
||||
it('reports the preview URL and tears every resource down', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
@@ -155,7 +163,7 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
@@ -163,7 +171,7 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
});
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
@@ -178,8 +186,31 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
);
|
||||
});
|
||||
|
||||
it('hands network capture to serve-sim on an iOS session', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: { value: true },
|
||||
network_capture_fields: { value: ['header'] },
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ networkCapture: true, networkCaptureFields: ['header'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting the daemon when network capture is asked for on Android', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.LINUX, { network_capture: { value: true } })
|
||||
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails before starting the daemon when a launch is asked for on Android', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.LINUX, {
|
||||
@@ -189,6 +220,202 @@ describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () =>
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the session host without waiting for the agent-device daemon', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const sessionHost = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
ctx,
|
||||
{} as never
|
||||
);
|
||||
let markHostStarted!: () => void;
|
||||
const hostStarted = new Promise<void>(resolve => {
|
||||
markHostStarted = resolve;
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async () => {
|
||||
markHostStarted();
|
||||
return sessionHost;
|
||||
});
|
||||
// The daemon credentials only appear after the session host started. A sequential
|
||||
// startup would wait here forever.
|
||||
jest.mocked(waitForFileAsync).mockImplementation(async () => {
|
||||
await hostStarted;
|
||||
return { port: 5678, token: 'daemon-token' };
|
||||
});
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
agentDeviceRemoteSessionUrl: 'https://agent-device-abc.tunnel.example.com',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the daemon and its tunnel when the session host fails to start', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockRejectedValue(new Error('serve-sim did not start'));
|
||||
|
||||
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
|
||||
'serve-sim did not start'
|
||||
);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
// The failure aborts the daemon task, which then may not start its daemon or tunnel.
|
||||
// Whatever started is stopped.
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(jest.mocked(spawnDetached).mock.calls.length);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(
|
||||
jest.mocked(startNgrokTunnelAsync).mock.calls.length
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the session host and the daemon when the daemon credentials never appear', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
|
||||
|
||||
await expect(runAsync(logger, BuildRuntimePlatform.DARWIN)).rejects.toThrow(
|
||||
'no daemon credentials'
|
||||
);
|
||||
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe('runAgentDeviceRemoteSessionAsync with a device that is still starting', () => {
|
||||
function deferred(): {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (e: Error) => void;
|
||||
} {
|
||||
let resolve!: () => void;
|
||||
let reject!: (e: Error) => void;
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
promise.catch(() => {});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
function startSession(device: Device | ((tasks: StartupTasks) => Device)) {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as never;
|
||||
const tasks = createStartupTasks(logger);
|
||||
return runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
sessionEnv: {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
launch: {},
|
||||
capture: { networkCapture: false, networkCaptureFields: [] },
|
||||
tasks,
|
||||
device: typeof device === 'function' ? device(tasks) : device,
|
||||
});
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
it('starts the daemon during the boot and the session host after it', async () => {
|
||||
const booted = deferred();
|
||||
const session = startSession({ booted: booted.promise, ready: booted.promise });
|
||||
await flushAsync();
|
||||
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(startNgrokTunnelAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
|
||||
booted.resolve();
|
||||
await session;
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports the session as ready only after the app is launched', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
|
||||
ready.resolve();
|
||||
await session;
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops waiting for the boot when the daemon fails', async () => {
|
||||
jest.mocked(waitForFileAsync).mockRejectedValue(new Error('no daemon credentials'));
|
||||
const neverBooted = new Promise<void>(() => {});
|
||||
|
||||
// In the combined step, `ready` stops waiting for the boot on an abort, like this.
|
||||
await expect(
|
||||
startSession(tasks => ({ booted: neverBooted, ready: tasks.untilAborted(neverBooted) }))
|
||||
).rejects.toThrow('no daemon credentials');
|
||||
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the session host when the app fails while the daemon install hangs', async () => {
|
||||
jest.mocked(spawn).mockImplementation(((
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { signal?: AbortSignal }
|
||||
) =>
|
||||
command === 'bun' && args[0] === 'add'
|
||||
? new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
|
||||
})
|
||||
: Promise.resolve(undefined)) as never);
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
ready.reject(new Error('simctl install failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('simctl install failed');
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops everything it started when the app install fails', async () => {
|
||||
const ready = deferred();
|
||||
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
|
||||
await flushAsync();
|
||||
ready.reject(new Error('simctl install failed'));
|
||||
|
||||
await expect(session).rejects.toThrow('simctl install failed');
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
+43
-2
@@ -75,6 +75,7 @@ describe(startAgentDeviceDaemonAsync, () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 1234,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync,
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation((async (
|
||||
@@ -108,7 +109,12 @@ describe(startAgentDeviceDaemonAsync, () => {
|
||||
expect(spawnDetached).toHaveBeenCalledWith({
|
||||
command: 'node',
|
||||
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
|
||||
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
|
||||
env: expect.objectContaining({
|
||||
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
|
||||
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
|
||||
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
}),
|
||||
});
|
||||
|
||||
await handle.stopAsync();
|
||||
@@ -129,6 +135,36 @@ describe(startAgentDeviceDaemonAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('kills the install and does not fall back to git when aborted', async () => {
|
||||
jest.mocked(spawn).mockImplementation(
|
||||
((_command: string, _args: string[], options?: { signal?: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
options!.signal!.addEventListener('abort', () => reject(options!.signal!.reason));
|
||||
})) as never
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const failure = new Error('app install failed');
|
||||
|
||||
const daemon = startAgentDeviceDaemonAsync({
|
||||
packageVersion: '1.2.3',
|
||||
env: {},
|
||||
logger,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
controller.abort(failure);
|
||||
|
||||
await expect(daemon).rejects.toBe(failure);
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['add', '[email protected]'],
|
||||
expect.objectContaining({ signal: controller.signal })
|
||||
);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to git clone when the published daemon is missing', async () => {
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
@@ -165,7 +201,12 @@ describe(startAgentDeviceDaemonAsync, () => {
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: '/tmp/agent-device-src',
|
||||
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
|
||||
env: expect.objectContaining({
|
||||
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
|
||||
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
|
||||
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
}),
|
||||
});
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
import { downloadBuildAsync } from '../downloadBuild';
|
||||
import { installBuildAsync } from '../installBuild';
|
||||
import { launchApplicationAsync } from '../launchApplication';
|
||||
import {
|
||||
getAgentDeviceRemoteSessionEnvOrThrow,
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
import { createStartAgentDeviceSessionBuildFunction } from '../startAgentDeviceSession';
|
||||
import { startAndroidEmulatorAsync } from '../startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from '../startIosSimulator';
|
||||
|
||||
jest.mock('../../utils/localEgressSession', () => ({
|
||||
withLocalEgressSession: (fn: unknown) => fn,
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startIosSimulator', () => ({ bootIosSimulatorAsync: jest.fn() }));
|
||||
jest.mock('../startAndroidEmulator', () => ({ startAndroidEmulatorAsync: jest.fn() }));
|
||||
jest.mock('../downloadBuild', () => ({ downloadBuildAsync: jest.fn() }));
|
||||
jest.mock('../installBuild', () => ({ installBuildAsync: jest.fn() }));
|
||||
jest.mock('../launchApplication', () => ({
|
||||
...jest.requireActual('../launchApplication'),
|
||||
launchApplicationAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../startAgentDeviceRemoteSession', () => ({
|
||||
getAgentDeviceRemoteSessionEnvOrThrow: jest.fn(),
|
||||
runAgentDeviceRemoteSessionAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const graphqlClient = { query: jest.fn() };
|
||||
const ctx = { graphqlClient } as unknown as CustomBuildContext;
|
||||
const sessionEnv = {
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
ngrokTunnelDomain: 'tunnel.example.com',
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
};
|
||||
|
||||
type Device = { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
|
||||
function deferred<T = void>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (err: Error) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (err: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
promise.catch(() => {});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function runStep(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
inputValues: Record<string, unknown> = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const inputs = Object.fromEntries(
|
||||
[
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
].map(id => [id, { value: inputValues[id] }])
|
||||
);
|
||||
return buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: {
|
||||
runtimePlatform,
|
||||
staticContext: { job: { secrets: { robotAccessToken: 'robot-token' } } },
|
||||
},
|
||||
} as unknown as BuildStepContext,
|
||||
{ inputs, outputs: {}, env: {}, signal: undefined } as never
|
||||
) as Promise<void>;
|
||||
}
|
||||
|
||||
/** The `device` promises handed to the shared agent-device session code. */
|
||||
function sessionDevice(): Device {
|
||||
return jest.mocked(runAgentDeviceRemoteSessionAsync).mock.calls[0][1].device;
|
||||
}
|
||||
|
||||
describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(getAgentDeviceRemoteSessionEnvOrThrow).mockReturnValue(sessionEnv);
|
||||
jest.mocked(runAgentDeviceRemoteSessionAsync).mockImplementation(async (_ctx, { device }) => {
|
||||
await device.ready;
|
||||
});
|
||||
jest.mocked(bootIosSimulatorAsync).mockResolvedValue({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: 'iPhone 17',
|
||||
});
|
||||
jest.mocked(startAndroidEmulatorAsync).mockResolvedValue({
|
||||
serialId: 'emulator-5554' as never,
|
||||
emulatorPromise: Promise.resolve(),
|
||||
shouldAdjustAnimationScale: true,
|
||||
});
|
||||
jest.mocked(downloadBuildAsync).mockResolvedValue({ artifactPath: '/tmp/App.app' });
|
||||
jest
|
||||
.mocked(installBuildAsync)
|
||||
.mockResolvedValue({ applicationIdentifier: 'dev.example.app', activityName: '.Main' });
|
||||
jest.mocked(launchApplicationAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('downloads the build and starts the session while the iOS Simulator boots', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
launch_args: ['-flag'],
|
||||
open_url: 'exp://example.test',
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(bootIosSimulatorAsync).mock.invocationCallOrder[0]);
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildId: 'build-id',
|
||||
extensions: ['app'],
|
||||
graphqlClient,
|
||||
robotAccessToken: 'robot-token',
|
||||
})
|
||||
);
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledTimes(1);
|
||||
// The install needs the booted Simulator.
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
});
|
||||
await step;
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifactPath: '/tmp/App.app' })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
launchArgs: ['-flag'],
|
||||
openUrl: 'exp://example.test',
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(installBuildAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(launchApplicationAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for the download when the Simulator boots first', async () => {
|
||||
const download = deferred<{ artifactPath: string }>();
|
||||
jest.mocked(downloadBuildAsync).mockReturnValue(download.promise);
|
||||
|
||||
const step = runStep(BuildRuntimePlatform.DARWIN, {
|
||||
application_archive_url: 'https://example.test/app.tar.gz',
|
||||
});
|
||||
await flushAsync();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
|
||||
download.resolve({ artifactPath: '/tmp/App.app' });
|
||||
await step;
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('makes the session ready when the device boots if there is no app', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN);
|
||||
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
await expect(sessionDevice().ready).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('boots the Android Emulator with the device inputs', async () => {
|
||||
await runStep(BuildRuntimePlatform.LINUX, {
|
||||
device_identifier: 'pixel_7',
|
||||
system_image_package: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
|
||||
lcd_width: 720,
|
||||
lcd_height: 1600,
|
||||
lcd_density: 300,
|
||||
build_id: 'build-id',
|
||||
});
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(startAndroidEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deviceIdentifier: 'pixel_7',
|
||||
systemImagePackage: 'system-images;android-35-ext15;google_apis_playstore;x86_64',
|
||||
lcdWidth: 720,
|
||||
lcdHeight: 1600,
|
||||
lcdDensity: 300,
|
||||
})
|
||||
);
|
||||
expect(downloadBuildAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ extensions: ['apk'] })
|
||||
);
|
||||
expect(launchApplicationAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ applicationIdentifier: 'dev.example.app', activityName: '.Main' })
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the session when the download fails, without an unhandled rejection', async () => {
|
||||
const boot = deferred<Awaited<ReturnType<typeof bootIosSimulatorAsync>>>();
|
||||
jest.mocked(bootIosSimulatorAsync).mockReturnValue(boot.promise);
|
||||
jest.mocked(downloadBuildAsync).mockRejectedValue(new Error('download failed'));
|
||||
|
||||
const stepFailure = expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })
|
||||
).rejects.toThrow('download failed');
|
||||
// The download fails while the boot still runs. Nothing inside the step may leave
|
||||
// that rejection unhandled; Jest fails the test if it does.
|
||||
await flushAsync();
|
||||
boot.resolve({
|
||||
deviceIdentifier: 'iPhone 17' as never,
|
||||
udid: 'udid' as never,
|
||||
displayName: '',
|
||||
});
|
||||
|
||||
await stepFailure;
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** A download that runs until its abort signal fires, like a stalled one. */
|
||||
function mockStalledDownload(): { aborted: () => boolean; settled: () => boolean } {
|
||||
let aborted = false;
|
||||
let settled = false;
|
||||
jest.mocked(downloadBuildAsync).mockImplementation(
|
||||
({ signal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => {
|
||||
aborted = true;
|
||||
// Settles a bit later, like a request that winds down.
|
||||
setImmediate(() => {
|
||||
settled = true;
|
||||
reject(new Error('The user aborted a request.'));
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
return { aborted: () => aborted, settled: () => settled };
|
||||
}
|
||||
|
||||
/** Runs the session like the real one: fails with the daemon, then waits for every part. */
|
||||
function mockSessionWithFailingDaemon(beforeFailure?: () => Promise<void>): void {
|
||||
jest
|
||||
.mocked(runAgentDeviceRemoteSessionAsync)
|
||||
.mockImplementation(async (_ctx, { tasks, device }) => {
|
||||
await beforeFailure?.();
|
||||
const daemon = tasks.run('agent-device daemon', async () => {
|
||||
throw new Error('daemon failed');
|
||||
});
|
||||
try {
|
||||
await Promise.all([daemon, device.ready]);
|
||||
} finally {
|
||||
await Promise.allSettled([daemon, device.ready]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it('stops the download and waits for it when the boot fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
jest.mocked(bootIosSimulatorAsync).mockRejectedValue(new Error('boot failed'));
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'boot failed'
|
||||
);
|
||||
|
||||
expect(download.aborted()).toBe(true);
|
||||
// The step returned only after the download stopped.
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops a stalled download and does not install when the daemon fails', async () => {
|
||||
const download = mockStalledDownload();
|
||||
mockSessionWithFailingDaemon();
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
|
||||
expect(download.aborted()).toBe(true);
|
||||
expect(download.settled()).toBe(true);
|
||||
expect(installBuildAsync).not.toHaveBeenCalled();
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not launch the app when startup fails during the install', async () => {
|
||||
const install = deferred<Awaited<ReturnType<typeof installBuildAsync>>>();
|
||||
const installStarted = deferred();
|
||||
jest.mocked(installBuildAsync).mockImplementation(() => {
|
||||
installStarted.resolve();
|
||||
return install.promise;
|
||||
});
|
||||
mockSessionWithFailingDaemon(async () => {
|
||||
await installStarted.promise;
|
||||
// The install finishes only after the daemon failed.
|
||||
setImmediate(() => install.resolve({ applicationIdentifier: 'dev.example.app' }));
|
||||
});
|
||||
|
||||
await expect(runStep(BuildRuntimePlatform.DARWIN, { build_id: 'build-id' })).rejects.toThrow(
|
||||
'daemon failed'
|
||||
);
|
||||
|
||||
expect(installBuildAsync).toHaveBeenCalledTimes(1);
|
||||
expect(launchApplicationAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects conflicting application inputs before it boots anything', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, {
|
||||
build_id: 'build-id',
|
||||
application_archive_url: 'https://example.test/app.tar.gz',
|
||||
})
|
||||
).rejects.toThrow('Pass only one of build_id or application_archive_url.');
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.DARWIN, { open_url: 'exp://example.test' })
|
||||
).rejects.toThrow('launch_args and open_url need an application');
|
||||
|
||||
expect(bootIosSimulatorAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('declares the step inputs', () => {
|
||||
const buildFunction = createStartAgentDeviceSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'device_identifier',
|
||||
'system_image_package',
|
||||
'lcd_width',
|
||||
'lcd_height',
|
||||
'lcd_density',
|
||||
'build_id',
|
||||
'application_archive_url',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'package_version',
|
||||
'max_idle_time_minutes',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes network capture to the session', async () => {
|
||||
await runStep(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: true,
|
||||
network_capture_fields: ['header', 'response-body'],
|
||||
});
|
||||
|
||||
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
capture: { networkCapture: true, networkCaptureFields: ['header', 'response-body'] },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects network capture on Android before it boots anything', async () => {
|
||||
await expect(
|
||||
runStep(BuildRuntimePlatform.LINUX, { network_capture: true, system_image_package: 'x' })
|
||||
).rejects.toThrow('records traffic through serve-sim on an iOS simulator');
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import { createStartAndroidEmulatorBuildFunction } from '../startAndroidEmulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -19,6 +20,10 @@ jest.mock('../../../utils/retry', () => ({
|
||||
retryAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/androidLocalEgress', () => ({
|
||||
resolveAndroidEmulatorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: {
|
||||
defaultSystemImagePackage: 'system-images;android-30;default;x86_64',
|
||||
@@ -35,6 +40,7 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedRetryAsync = jest.mocked(retryAsync);
|
||||
const mockedAndroidUtils = jest.mocked(AndroidEmulatorUtils);
|
||||
const mockedResolveLocalEgress = jest.mocked(resolveAndroidEmulatorLocalEgressAsync);
|
||||
const mockedMkdtemp = jest.spyOn(fs.promises, 'mkdtemp');
|
||||
function createStep(callInputs?: Record<string, unknown>, envOverrides?: NodeJS.ProcessEnv) {
|
||||
const logger = createMockLogger();
|
||||
@@ -65,6 +71,7 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedAndroidUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.disableWindowAndTransitionAnimationsAsync.mockResolvedValue(undefined);
|
||||
mockedAndroidUtils.deleteAsync.mockResolvedValue(undefined);
|
||||
mockedResolveLocalEgress.mockResolvedValue(null);
|
||||
|
||||
mockedRetryAsync.mockImplementation(async (fn, { retryOptions }) => {
|
||||
let lastErr: unknown;
|
||||
@@ -83,6 +90,82 @@ describe(createStartAndroidEmulatorBuildFunction, () => {
|
||||
mockedMkdtemp.mockRestore();
|
||||
});
|
||||
|
||||
describe('local egress', () => {
|
||||
function createLocalEgress() {
|
||||
return {
|
||||
launchGate: {
|
||||
emulatorArgs: ['-http-proxy', '127.0.0.1:8898', '-no-metrics'],
|
||||
wrapperCommand: 'bash',
|
||||
wrapperArgs: ['-c', 'gate', 'eas-egress-gate'],
|
||||
admitAsync: jest.fn(),
|
||||
},
|
||||
networkReadyTarget: { host: '192.0.2.1', port: 443 },
|
||||
configureBootedEmulatorAsync: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
it('starts the emulator exactly as before without a local egress session', async () => {
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith({
|
||||
deviceName: 'EasAndroidDevice01',
|
||||
env: expect.any(Object),
|
||||
logcatDirectory: '/tmp/logcat-directory',
|
||||
});
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith({
|
||||
env: expect.any(Object),
|
||||
serialId: 'emulator-default',
|
||||
timeoutMs: 60_000,
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(
|
||||
mockedSpawn.mock.calls.filter(
|
||||
([command, args]) =>
|
||||
['sudo', 'nft', 'systemd-run'].includes(command) ||
|
||||
(command === 'adb' && (args.includes('reverse') || args.includes('http_proxy')))
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('fences, gates and configures the emulator', async () => {
|
||||
const localEgress = createLocalEgress();
|
||||
mockedResolveLocalEgress.mockResolvedValue(localEgress);
|
||||
mockedAndroidUtils.startAsync.mockResolvedValueOnce(createStartResult('emulator-base'));
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.startAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ launchGate: localEgress.launchGate })
|
||||
);
|
||||
expect(mockedAndroidUtils.waitForReadyAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ networkReadyTarget: { host: '192.0.2.1', port: 443 } })
|
||||
);
|
||||
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ serialId: 'emulator-base' })
|
||||
);
|
||||
});
|
||||
|
||||
it('retries a boot whose local egress configuration fails', async () => {
|
||||
const localEgress = createLocalEgress();
|
||||
localEgress.configureBootedEmulatorAsync
|
||||
.mockRejectedValueOnce(new Error('netsimd outside the fence'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
mockedResolveLocalEgress.mockResolvedValue(localEgress);
|
||||
mockedAndroidUtils.startAsync
|
||||
.mockResolvedValueOnce(createStartResult('emulator-1111'))
|
||||
.mockResolvedValueOnce(createStartResult('emulator-2222'));
|
||||
|
||||
await createStep().executeAsync();
|
||||
|
||||
expect(mockedAndroidUtils.deleteAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ serialId: 'emulator-1111' })
|
||||
);
|
||||
expect(localEgress.configureBootedEmulatorAsync).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ serialId: 'emulator-2222' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('passes profile and LCD inputs to emulator creation', async () => {
|
||||
const systemImagePackage = 'system-images;android-35;default;x86_64';
|
||||
await createStep({
|
||||
|
||||
+128
-8
@@ -6,6 +6,7 @@ import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -42,15 +42,17 @@ jest.mock('../../utils/argentEvents', () => ({
|
||||
...jest.requireActual('../../utils/argentEvents'),
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
finishRemoteSessionAsync: jest.requireActual('../../utils/remoteDeviceRunSession')
|
||||
.finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
@@ -86,6 +88,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
@@ -93,10 +96,13 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
subdomainId: 'argent-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -113,6 +119,75 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports an early exit with output before opening tunnels or publishing readiness', async () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => 'could not bind server port',
|
||||
getExitError: () => new Error('process exited with code 1'),
|
||||
stopAsync: jest.fn(),
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toThrow(
|
||||
'Argent exited before becoming ready: process exited with code 1\nArgent tool-server output:\ncould not bind server port'
|
||||
);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])(
|
||||
'finishes the host and tools after %s fails',
|
||||
async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toBe(error);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(jest.mocked(spawnDetached).mock.results[0].value.stopAsync).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('enables the event log flag, shares one path, and starts/stops the collector', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
@@ -177,7 +252,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
@@ -216,7 +291,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
@@ -266,4 +341,49 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
|
||||
it('stops automation without waiting for recording upload', async () => {
|
||||
let release!: () => void;
|
||||
let stopped!: () => void;
|
||||
const pendingFinish = new Promise<void>(resolve => {
|
||||
release = resolve;
|
||||
});
|
||||
const toolStopped = new Promise<void>(resolve => {
|
||||
stopped = resolve;
|
||||
});
|
||||
mockPreviewStopAsync.mockReturnValueOnce(pendingFinish);
|
||||
const stopServer = jest.fn(async () => {
|
||||
stopped();
|
||||
});
|
||||
jest.mocked(spawnDetached).mockReturnValueOnce({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
try {
|
||||
await toolStopped;
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
release();
|
||||
await running;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,59 @@ describe(waitForArgentToolServerStateAsync, () => {
|
||||
return JSON.stringify({ port, pid, token });
|
||||
}
|
||||
|
||||
it('reports process exit instead of waiting for the startup timeout', async () => {
|
||||
const getExitError = jest.fn(() => new Error('server exited with code 1'));
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.pid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
getExitError,
|
||||
})
|
||||
).rejects.toThrow('server exited with code 1');
|
||||
expect(getExitError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('detects an exit after polling has begun', async () => {
|
||||
const getExitError = jest
|
||||
.fn<Error | undefined, []>()
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValue(new Error('server stopped'));
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.pid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
getExitError,
|
||||
})
|
||||
).rejects.toThrow('server stopped');
|
||||
expect(getExitError).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects an exit observed while reading a matching state file', async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(stateDir, 'tool-server.json'),
|
||||
stateJson({ port: 4321, pid: process.pid })
|
||||
);
|
||||
const getExitError = jest
|
||||
.fn<Error | undefined, []>()
|
||||
.mockReturnValueOnce(undefined)
|
||||
.mockReturnValue(new Error('server exited during startup'));
|
||||
await expect(
|
||||
waitForArgentToolServerStateAsync({
|
||||
stateDir,
|
||||
ancestorPid: process.ppid,
|
||||
timeoutMs: 100,
|
||||
pollIntervalMs: 1,
|
||||
getExitError,
|
||||
})
|
||||
).rejects.toThrow('server exited during startup');
|
||||
expect(getExitError).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(['tool-server.json', 'tool-server-012345abcdef.json'])(
|
||||
'reads a matching process from %s',
|
||||
async fileName => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type BuildStepContext } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { startAndroidLocalEgressAsync } from '../../utils/androidLocalEgress';
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
configureSystemProxyAsync,
|
||||
@@ -20,6 +21,9 @@ jest.mock('../../utils/localEgress', () => ({
|
||||
startChiselServerAsync: jest.fn(),
|
||||
writeLocalEgressHandoffAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/androidLocalEgress', () => ({
|
||||
startAndroidLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
stopLocalEgressGuardRelaysAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -31,7 +35,12 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
|
||||
const server = {
|
||||
pid: 12345,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: jest.fn(),
|
||||
};
|
||||
const tunnel = {
|
||||
url: 'https://egress.example.com',
|
||||
subdomainId: 'egress-id',
|
||||
@@ -47,6 +56,82 @@ async function start(signal?: AbortSignal): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function startOn(runtimePlatform: BuildRuntimePlatform): Promise<void> {
|
||||
await createStartLocalEgressBuildFunction().fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
env: {},
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
} as any
|
||||
);
|
||||
}
|
||||
|
||||
describe('local egress platforms', () => {
|
||||
const androidStop = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(fs.promises, 'mkdtemp').mockResolvedValue('/tmp/egress-acquisition-test');
|
||||
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined);
|
||||
jest.spyOn(fs.promises, 'rm').mockResolvedValue(undefined);
|
||||
server.stopAsync.mockResolvedValue(undefined);
|
||||
tunnel.stopAsync.mockResolvedValue(undefined);
|
||||
androidStop.mockResolvedValue(undefined);
|
||||
jest.mocked(findAvailablePortAsync).mockResolvedValue(52001);
|
||||
jest.mocked(downloadChiselAsync).mockResolvedValue('/tmp/egress-acquisition-test/chisel');
|
||||
jest.mocked(startChiselServerAsync).mockResolvedValue({ process: server, fingerprint: 'key=' });
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue(tunnel);
|
||||
jest.mocked(configureSystemProxyAsync).mockResolvedValue({ service: 'Ethernet' });
|
||||
jest.mocked(writeLocalEgressHandoffAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startAndroidLocalEgressAsync).mockResolvedValue({ stopAsync: androidStop });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('sets the macOS system proxy and writes the same handoff as before', async () => {
|
||||
await startOn(BuildRuntimePlatform.DARWIN);
|
||||
expect(configureSystemProxyAsync).toHaveBeenCalled();
|
||||
expect(startAndroidLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(writeLocalEgressHandoffAsync).toHaveBeenCalledWith({
|
||||
url: tunnel.url,
|
||||
token: expect.any(String),
|
||||
fingerprint: 'key=',
|
||||
port: 8899,
|
||||
});
|
||||
});
|
||||
|
||||
it('starts the Android relay and fence on Linux instead of the system proxy', async () => {
|
||||
await startOn(BuildRuntimePlatform.LINUX);
|
||||
expect(configureSystemProxyAsync).not.toHaveBeenCalled();
|
||||
expect(startAndroidLocalEgressAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chiselPid: server.pid,
|
||||
proxyPort: 8899,
|
||||
controlPort: 52001,
|
||||
workDir: '/tmp/egress-acquisition-test',
|
||||
})
|
||||
);
|
||||
expect(writeLocalEgressHandoffAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ platform: 'android' })
|
||||
);
|
||||
await stopLocalEgressResourcesAsync(logger);
|
||||
expect(androidStop).toHaveBeenCalledTimes(1);
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('releases the tunnel when the Android fence cannot start', async () => {
|
||||
jest.mocked(startAndroidLocalEgressAsync).mockRejectedValue(new Error('nft failed'));
|
||||
await expect(startOn(BuildRuntimePlatform.LINUX)).rejects.toThrow('nft failed');
|
||||
expect(server.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(tunnel.stopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(writeLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('local egress acquisition', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
+65
-16
@@ -3,22 +3,22 @@ import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
@@ -56,10 +56,13 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
stopAsync,
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
@@ -67,11 +70,14 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
stopAsync,
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: jest.fn().mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
await runAsync(BuildRuntimePlatform.DARWIN);
|
||||
@@ -87,6 +93,22 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['preview', 'config', 'wait'])('finishes the session after %s fails', async phase => {
|
||||
const error = new Error(`${phase} failed`);
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
|
||||
} else {
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
|
||||
}
|
||||
await expect(runAsync(BuildRuntimePlatform.LINUX)).rejects.toBe(error);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[BuildRuntimePlatform.DARWIN, true],
|
||||
[BuildRuntimePlatform.LINUX, false],
|
||||
@@ -94,9 +116,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
await runAsync(runtimePlatform);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(selectsXcode ? 1 : 0);
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(ctx, {
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: 'tunnel.example.com',
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
@@ -104,6 +125,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
launchAppIdentifier: undefined,
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
networkCapture: false,
|
||||
networkCaptureFields: [],
|
||||
});
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
@@ -134,19 +157,21 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'network_capture',
|
||||
'network_capture_fields',
|
||||
'package_version',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to the web preview and announces them', async () => {
|
||||
it('hands the launch inputs to the session host and announces them', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
});
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
@@ -160,12 +185,36 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('hands network capture to the session host', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
network_capture: { value: true },
|
||||
network_capture_fields: { value: ['header', 'response-body'] },
|
||||
});
|
||||
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header', 'response-body'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting anything when network capture is asked for on Android', async () => {
|
||||
await expect(
|
||||
runAsync(BuildRuntimePlatform.LINUX, {
|
||||
network_capture: { value: true },
|
||||
})
|
||||
).rejects.toThrow('this session runs on linux');
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
await expect(
|
||||
runAsync(BuildRuntimePlatform.LINUX, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
})
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,533 @@
|
||||
import nock from 'nock';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { AscApiClient } from '../../utils/ios/AscApiClient';
|
||||
import {
|
||||
createUpdateTestFlightMetadataBuildFunction,
|
||||
updateTestFlightMetadataAsync,
|
||||
} from '../updateTestFlightMetadata';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const key = { keyId: 'TESTKEY', privateKey };
|
||||
const changelog = 'Test "quotes"\n$(not-a-command)';
|
||||
const options = {
|
||||
client: new AscApiClient({ key }),
|
||||
buildUploadId: 'upload',
|
||||
changelog,
|
||||
groups: [] as string[],
|
||||
logger: createMockLogger(),
|
||||
};
|
||||
const api = () => nock('https://api.appstoreconnect.apple.com');
|
||||
|
||||
function mockBuild(
|
||||
state = 'COMPLETE',
|
||||
buildId: string | null = 'build',
|
||||
primaryLocale: string | null = 'en-US'
|
||||
): void {
|
||||
api()
|
||||
.get('/v1/buildUploads/upload')
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: {
|
||||
type: 'buildUploads',
|
||||
id: 'upload',
|
||||
attributes: { state: { state } },
|
||||
relationships: { build: { data: buildId ? { type: 'builds', id: buildId } : null } },
|
||||
},
|
||||
});
|
||||
if (state === 'COMPLETE' && buildId) {
|
||||
api()
|
||||
.get(`/v1/builds/${buildId}/app`)
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: { id: 'app', attributes: primaryLocale ? { primaryLocale } : {} },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mockAssignedGroups(ids: string[] = []): void {
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[builds]': 'build', limit: '200' })
|
||||
.reply(200, { data: ids.map(id => ({ id })) });
|
||||
}
|
||||
|
||||
beforeAll(() => nock.disableNetConnect());
|
||||
beforeEach(() => {
|
||||
options.logger = createMockLogger();
|
||||
options.client = new AscApiClient({ key });
|
||||
});
|
||||
afterAll(() => nock.enableNetConnect());
|
||||
afterEach(() => {
|
||||
try {
|
||||
expect(nock.pendingMocks()).toEqual([]);
|
||||
} finally {
|
||||
nock.cleanAll();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts group arrays and preserves changelog input without shell parsing', async () => {
|
||||
const fn = createUpdateTestFlightMetadataBuildFunction();
|
||||
const spy = jest.spyOn(fn, 'fn').mockImplementation(async (_ctx, { inputs }) => {
|
||||
expect(inputs.groups.value).toEqual(['A, "B"']);
|
||||
expect(inputs.changelog.value).toBe(changelog);
|
||||
});
|
||||
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: {
|
||||
asc_api_key_path: '/tmp/key.json',
|
||||
build_upload_id: 'upload',
|
||||
groups: ['A, "B"'],
|
||||
changelog,
|
||||
},
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses empty metadata defaults', async () => {
|
||||
const fn = createUpdateTestFlightMetadataBuildFunction();
|
||||
const spy = jest.spyOn(fn, 'fn').mockImplementation(async (_ctx, { inputs }) => {
|
||||
expect(inputs.groups.value).toEqual([]);
|
||||
expect(inputs.changelog.value).toBe('');
|
||||
});
|
||||
const step = fn.createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: { asc_api_key_path: '/tmp/key.json', build_upload_id: 'upload' },
|
||||
});
|
||||
await step.executeAsync();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips automatic internal groups and groups that already contain the build', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [
|
||||
{
|
||||
id: 'automatic',
|
||||
attributes: { name: 'Internal', isInternalGroup: true, hasAccessToAllBuilds: true },
|
||||
},
|
||||
{ id: 'assigned', attributes: { name: 'QA' } },
|
||||
{
|
||||
id: 'manual',
|
||||
attributes: { name: 'External', isInternalGroup: false, hasAccessToAllBuilds: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[builds]': 'build', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'assigned', attributes: { name: 'QA' } }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'manual' }],
|
||||
})
|
||||
.reply(204);
|
||||
await updateTestFlightMetadataAsync({
|
||||
...options,
|
||||
changelog: '',
|
||||
groups: ['Internal', 'QA', 'External'],
|
||||
});
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"Internal" (automatic): automatic access')
|
||||
);
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"QA" (assigned): build already assigned')
|
||||
);
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('"External" (manual): assignment completed')
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['en-US', null])(
|
||||
'creates a localization when none exist (primary locale: %s)',
|
||||
async primaryLocale => {
|
||||
mockBuild('COMPLETE', 'build', primaryLocale);
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew: changelog },
|
||||
relationships: { build: { data: { type: 'builds', id: 'build' } } },
|
||||
},
|
||||
})
|
||||
.reply(201, { data: { id: 'locale' } });
|
||||
await updateTestFlightMetadataAsync(options);
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
`Updating changelog: ${JSON.stringify(changelog)}`
|
||||
);
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Locale "en-US": localization created')
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('reads all localization pages and updates existing localizations without replacing them', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/builds/build/betaBuildLocalizations')
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: [{ id: 'en-US', attributes: { locale: 'en-US' } }],
|
||||
links: {
|
||||
next: 'https://api.appstoreconnect.apple.com/v1/builds/build/betaBuildLocalizations?limit=200&cursor=next',
|
||||
},
|
||||
});
|
||||
api()
|
||||
.get('/v1/builds/build/betaBuildLocalizations')
|
||||
.query({ limit: '200', cursor: 'next' })
|
||||
.reply(200, { data: [{ id: 'pl', attributes: { locale: 'pl' } }] });
|
||||
for (const id of ['en-US', 'pl']) {
|
||||
api()
|
||||
.patch(`/v1/betaBuildLocalizations/${id}`, {
|
||||
data: { type: 'betaBuildLocalizations', id, attributes: { whatsNew: changelog } },
|
||||
})
|
||||
.reply(200, { data: { id } });
|
||||
}
|
||||
await updateTestFlightMetadataAsync(options);
|
||||
for (const id of ['en-US', 'pl']) {
|
||||
expect(options.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Locale "${id}" (${id})`)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('updates by ID when Apple omits a localization locale and creates the primary locale', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/builds/build/betaBuildLocalizations')
|
||||
.query(true)
|
||||
.reply(200, { data: [{ id: 'localization' }] });
|
||||
|
||||
api()
|
||||
.patch('/v1/betaBuildLocalizations/localization', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
id: 'localization',
|
||||
attributes: { whatsNew: changelog },
|
||||
},
|
||||
})
|
||||
.reply(200, { data: { id: 'localization' } });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew: changelog },
|
||||
relationships: { build: { data: { type: 'builds', id: 'build' } } },
|
||||
},
|
||||
})
|
||||
.reply(201, { data: { id: 'primary' } });
|
||||
await updateTestFlightMetadataAsync(options);
|
||||
});
|
||||
|
||||
it('assigns requested groups when hasAccessToAllBuilds is null', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [
|
||||
{
|
||||
id: 'internal',
|
||||
attributes: { name: 'Internal', isInternalGroup: true, hasAccessToAllBuilds: false },
|
||||
},
|
||||
{
|
||||
id: 'external',
|
||||
attributes: {
|
||||
name: 'test external',
|
||||
isInternalGroup: false,
|
||||
hasAccessToAllBuilds: null,
|
||||
publicLinkEnabled: false,
|
||||
publicLink: 'https://testflight.apple.com/join/abc',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'requested-external',
|
||||
attributes: { name: 'Beta', isInternalGroup: false, hasAccessToAllBuilds: null },
|
||||
},
|
||||
],
|
||||
});
|
||||
for (const id of ['internal', 'requested-external']) {
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id }],
|
||||
})
|
||||
.reply(204);
|
||||
}
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['Internal', 'Beta'] })
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds groups without changing changelog or submitting beta review', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A, "B"' } }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'group' }],
|
||||
})
|
||||
.reply(204);
|
||||
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A, "B"', 'A, "B"'] });
|
||||
});
|
||||
|
||||
it('reports missing groups independently of the changelog task', async () => {
|
||||
mockBuild();
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.reply(201, { data: { id: 'locale' } });
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [] });
|
||||
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['missing'] })).rejects.toThrow(
|
||||
'The following TestFlight group was not found in App Store Connect: "missing".'
|
||||
);
|
||||
});
|
||||
|
||||
it('reads all pages and adds every group with a requested name', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [{ id: 'first', attributes: { name: 'A' } }],
|
||||
links: {
|
||||
next: 'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bapp%5D=app&limit=200&cursor=page-2',
|
||||
},
|
||||
});
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200', cursor: 'page-2' })
|
||||
.reply(200, {
|
||||
data: [
|
||||
{ id: 'second', attributes: { name: 'A' } },
|
||||
{ id: 'other', attributes: { name: 'B' } },
|
||||
],
|
||||
});
|
||||
for (const id of ['first', 'second']) {
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id }],
|
||||
})
|
||||
.reply(204);
|
||||
}
|
||||
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] });
|
||||
});
|
||||
|
||||
it('assigns found groups before reporting missing requested names', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'group' }],
|
||||
})
|
||||
.reply(204);
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
|
||||
).rejects.toThrow(
|
||||
'The following TestFlight groups were not found in App Store Connect: "B", "C".'
|
||||
);
|
||||
});
|
||||
|
||||
it('attempts all localization changes and reports every failed update', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/builds/build/betaBuildLocalizations')
|
||||
.query(true)
|
||||
.reply(200, {
|
||||
data: ['pl', 'fr', 'de'].map(locale => ({ id: locale, attributes: { locale } })),
|
||||
});
|
||||
for (const id of ['pl', 'fr', 'de']) {
|
||||
api()
|
||||
.patch(`/v1/betaBuildLocalizations/${id}`)
|
||||
.reply(
|
||||
id === 'de' ? 200 : 403,
|
||||
id === 'de' ? { data: { id } } : { errors: [{ code: `${id}_FAILED` }] }
|
||||
);
|
||||
}
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: 'en-US', whatsNew: changelog },
|
||||
relationships: { build: { data: { type: 'builds', id: 'build' } } },
|
||||
},
|
||||
})
|
||||
.reply(201, { data: { id: 'locale' } });
|
||||
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow(/pl_FAILED.*fr_FAILED/);
|
||||
expect(options.logger.error).toHaveBeenCalledWith(expect.stringContaining('"pl" (pl)'));
|
||||
expect(options.logger.error).toHaveBeenCalledWith(expect.stringContaining('"fr" (fr)'));
|
||||
});
|
||||
|
||||
it.each([1, 2])('explains automatic internal group rejection (%s errors)', async errorCount => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups')
|
||||
.reply(422, {
|
||||
errors: Array.from({ length: errorCount }, () => ({
|
||||
code: 'ENTITY_UNPROCESSABLE',
|
||||
title: 'Builds cannot be assigned to this internal group.',
|
||||
detail: 'Cannot add internal group to a build.',
|
||||
})),
|
||||
});
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] })
|
||||
).rejects.toThrow(
|
||||
"App Store Connect can't add this build to a requested internal TestFlight group."
|
||||
);
|
||||
});
|
||||
|
||||
it('stops after 20 group pages', async () => {
|
||||
mockBuild();
|
||||
const next =
|
||||
'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bapp%5D=app&limit=200&cursor=next';
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [], links: { next } });
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200', cursor: 'next' })
|
||||
.times(19)
|
||||
.reply(200, { data: [], links: { next } });
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] })
|
||||
).rejects.toThrow('We only support TestFlight group lists with up to 20 pages');
|
||||
});
|
||||
|
||||
it('tries group assignment if the changelog update fails', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'group' }],
|
||||
})
|
||||
.reply(204);
|
||||
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['A'] })).rejects.toThrow('403');
|
||||
});
|
||||
|
||||
it('reports both write failures', async () => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.reply(403, { errors: [{ code: 'CHANGELOG_FAILED' }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups')
|
||||
.reply(403, { errors: [{ code: 'GROUPS_FAILED' }] });
|
||||
await expect(updateTestFlightMetadataAsync({ ...options, groups: ['A'] })).rejects.toThrow(
|
||||
/CHANGELOG_FAILED.*GROUPS_FAILED/
|
||||
);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'attempts all groups and reports assignment errors (multiple: %s)',
|
||||
async multiple => {
|
||||
mockBuild();
|
||||
mockAssignedGroups();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: ['A', 'B', 'C'].map(id => ({ id, attributes: { name: id } })) });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'A' }],
|
||||
})
|
||||
.reply(
|
||||
multiple ? 403 : 204,
|
||||
multiple ? { errors: [{ code: 'FIRST_GROUP_FAILED' }] } : undefined
|
||||
);
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'B' }],
|
||||
})
|
||||
.reply(422, { errors: [{ code: 'ENTITY_UNPROCESSABLE', title: 'Partial assignment' }] });
|
||||
api()
|
||||
.post('/v1/builds/build/relationships/betaGroups', {
|
||||
data: [{ type: 'betaGroups', id: 'C' }],
|
||||
})
|
||||
.reply(204);
|
||||
await expect(
|
||||
updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A', 'B', 'C'] })
|
||||
).rejects.toThrow(multiple ? /FIRST_GROUP_FAILED.*Partial assignment/ : 'Partial assignment');
|
||||
expect(options.logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Group "B" (B): assignment failed.')
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('reads all membership pages before assigning groups', async () => {
|
||||
mockBuild();
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, { data: [{ id: 'group', attributes: { name: 'A' } }] });
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[builds]': 'build', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [],
|
||||
links: {
|
||||
next: 'https://api.appstoreconnect.apple.com/v1/betaGroups?filter%5Bbuilds%5D=build&limit=200&cursor=next',
|
||||
},
|
||||
});
|
||||
api()
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[builds]': 'build', limit: '200', cursor: 'next' })
|
||||
.reply(200, { data: [{ id: 'group' }] });
|
||||
await updateTestFlightMetadataAsync({ ...options, changelog: '', groups: ['A'] });
|
||||
});
|
||||
|
||||
it.each(['PROCESSING', 'FAILED'])('rejects a %s upload', async state => {
|
||||
mockBuild(state);
|
||||
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('not ready');
|
||||
});
|
||||
|
||||
it('rejects a completed upload without its build relationship', async () => {
|
||||
mockBuild('COMPLETE', null);
|
||||
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('not ready');
|
||||
});
|
||||
|
||||
it('propagates Apple write failures', async () => {
|
||||
mockBuild();
|
||||
api().get('/v1/builds/build/betaBuildLocalizations').query(true).reply(200, { data: [] });
|
||||
api()
|
||||
.post('/v1/betaBuildLocalizations')
|
||||
.reply(403, { errors: [{ code: 'FORBIDDEN', detail: 'Not allowed' }] });
|
||||
await expect(updateTestFlightMetadataAsync(options)).rejects.toThrow('403');
|
||||
});
|
||||
+22
-9
@@ -29,7 +29,20 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads simulator device metadata', async () => {
|
||||
it.each([
|
||||
{
|
||||
platform: BuildRuntimePlatform.DARWIN,
|
||||
udid: SIMULATOR_UDID,
|
||||
name: 'iPhone 16',
|
||||
runtime: 'iOS 18.6',
|
||||
},
|
||||
{
|
||||
platform: BuildRuntimePlatform.LINUX,
|
||||
udid: 'emulator-5554',
|
||||
name: 'Pixel',
|
||||
runtime: 'Android 16',
|
||||
},
|
||||
])('uploads device metadata on $platform', async ({ platform, udid, name, runtime }) => {
|
||||
const recordingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
|
||||
const recordingPath = path.join(recordingDirectory, 'recording.mp4');
|
||||
await fs.writeFile(recordingPath, 'recording');
|
||||
@@ -45,7 +58,7 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
|
||||
try {
|
||||
const globalContext = createGlobalContextMock({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
runtimePlatform: platform,
|
||||
});
|
||||
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
{} as CustomBuildContext
|
||||
@@ -54,9 +67,9 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
callInputs: {
|
||||
recordings_json: [
|
||||
{
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
udid,
|
||||
deviceName: name,
|
||||
runtimeDisplayName: runtime,
|
||||
directory: recordingDirectory,
|
||||
},
|
||||
],
|
||||
@@ -68,13 +81,13 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
name: 'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
name: `${name} screen recording (${udid.slice(0, 8)}-…, started at Jul 10, 2026, 10:00:00.000 UTC)`,
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording',
|
||||
__eas_screen_recording: '1',
|
||||
udid: SIMULATOR_UDID,
|
||||
deviceName: 'iPhone 16',
|
||||
runtimeDisplayName: 'iOS 18.6',
|
||||
udid,
|
||||
deviceName: name,
|
||||
runtimeDisplayName: runtime,
|
||||
firstFrameAt: '2026-07-10T10:00:00.000Z',
|
||||
width: 1179,
|
||||
height: 2556,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
|
||||
import { uploadServeSimLogsFileAsync } from '../utils/serveSimLogsArtifacts';
|
||||
import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 30_000;
|
||||
|
||||
export function createCollectServeSimLogsBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'collect_serve_sim_logs',
|
||||
name: 'Collect serve-sim simulator logs',
|
||||
__metricsId: 'eas/collect_serve_sim_logs',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env }) => {
|
||||
try {
|
||||
const collected = await ServeSimLogsRecorder.finishAsync({ logger });
|
||||
if (collected.length === 0) {
|
||||
logger.info('No simulator logs collected; skipping upload.');
|
||||
return;
|
||||
}
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
for (const { udid, filePath } of collected) {
|
||||
// Each device gets its own budget so one stuck upload does not cancel the rest.
|
||||
await uploadServeSimLogsFileAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
udid,
|
||||
filePath,
|
||||
logger,
|
||||
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not finalize serve-sim simulator logs', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not finalize simulator logs; the session result is unchanged.'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import plist from 'plist';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import { pluralize } from '../../utils/strings';
|
||||
|
||||
@@ -78,7 +79,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs }) => {
|
||||
fn: async (stepsCtx, { inputs, outputs, signal }) => {
|
||||
const { logger } = stepsCtx;
|
||||
|
||||
const extensions = z.array(z.string()).parse(inputs.extensions.value);
|
||||
@@ -119,6 +120,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions,
|
||||
signal,
|
||||
});
|
||||
|
||||
outputs.artifact_path.set(artifactPath);
|
||||
@@ -129,11 +131,17 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
async function fetchApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
graphqlClient,
|
||||
signal,
|
||||
}: {
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<string> {
|
||||
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
|
||||
signal?.throwIfAborted();
|
||||
const result = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
|
||||
if (result.error) {
|
||||
const { error } = result;
|
||||
@@ -161,9 +169,11 @@ export async function downloadBuildAsync(
|
||||
graphqlClient: Client;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
/** Stops the build lookup, the request and the file write when aborted, and skips the rest. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<{ artifactPath: string }> {
|
||||
const { logger, graphqlClient, robotAccessToken, extensions } = params;
|
||||
const { logger, graphqlClient, robotAccessToken, extensions, signal } = params;
|
||||
|
||||
let downloadUrl: string;
|
||||
let headers: { Authorization: string } | undefined;
|
||||
@@ -178,7 +188,7 @@ export async function downloadBuildAsync(
|
||||
headers = undefined;
|
||||
} else if (params.buildId) {
|
||||
const buildId = z.string().uuid().parse(params.buildId);
|
||||
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
|
||||
downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient, signal });
|
||||
headers = robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined;
|
||||
} else {
|
||||
throw new UserError(
|
||||
@@ -191,7 +201,8 @@ export async function downloadBuildAsync(
|
||||
path.join(os.tmpdir(), 'download_build-downloaded-')
|
||||
);
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers });
|
||||
signal?.throwIfAborted();
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
@@ -201,7 +212,9 @@ export async function downloadBuildAsync(
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
|
||||
|
||||
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
|
||||
await streamPipeline(response.body, fs.createWriteStream(archivePath));
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const { size } = await fs.promises.stat(archivePath);
|
||||
|
||||
@@ -217,10 +230,15 @@ export async function downloadBuildAsync(
|
||||
const extractionDirectory = await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'download_build-extracted-')
|
||||
);
|
||||
// Not interrupted on abort: tar cannot stop its in-flight file writes, so an abort during
|
||||
// the extraction lets it finish (local, bounded work) and is reported right after it.
|
||||
// This way no file is written after this function settles.
|
||||
signal?.throwIfAborted();
|
||||
await decompressTarAsync({
|
||||
archivePath,
|
||||
destinationDirectory: extractionDirectory,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const matchingFiles = await glob(`**/*.(${extensions.join('|')})`, {
|
||||
absolute: true,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildFunction, BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
|
||||
export function createInstallMitmproxyBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'install_mitmproxy',
|
||||
name: 'Install mitmproxy',
|
||||
__metricsId: 'eas/install_mitmproxy',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env }) => {
|
||||
if (await isMitmproxyAvailableAsync(env)) {
|
||||
logger.info('mitmproxy is already installed.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (env.EAS_BUILD_RUNNER !== 'eas-build') {
|
||||
logger.warn(
|
||||
'mitmproxy is not installed and network capture needs it. Install it with `brew install --cask mitmproxy` and rerun the job.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Installing mitmproxy with Homebrew.');
|
||||
await installMitmproxyWithHomebrewAsync({ env, logger });
|
||||
|
||||
if (!(await isMitmproxyAvailableAsync(env))) {
|
||||
throw new Error('`brew install --cask mitmproxy` succeeded but mitmdump is not on PATH.');
|
||||
}
|
||||
logger.info('Installed mitmproxy.');
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not install mitmproxy for the device session', error, {
|
||||
level: 'warning',
|
||||
});
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not install mitmproxy, so network capture will record nothing in this session. Re-run the session to retry the install, or run it without network capture.'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function isMitmproxyAvailableAsync(env: BuildStepEnv): Promise<boolean> {
|
||||
return (await asyncResult(spawn('mitmdump', ['--version'], { env }))).ok;
|
||||
}
|
||||
|
||||
async function installMitmproxyWithHomebrewAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
await spawn('brew', ['install', '--cask', 'mitmproxy'], {
|
||||
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
|
||||
logger,
|
||||
});
|
||||
}
|
||||
@@ -107,6 +107,8 @@ function buildMaestroArgs({
|
||||
return { executable: 'maestro', args };
|
||||
}
|
||||
case 'maestro-runner': {
|
||||
// The runner writes every report format to one directory. output_format only
|
||||
// selects which extra report artifact EAS uploads after the test.
|
||||
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
|
||||
if (shards !== undefined && shards > 1) {
|
||||
args.push(`--parallel=${shards}`);
|
||||
@@ -213,23 +215,20 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}
|
||||
const testsDirectory = path.join(home, '.maestro', 'tests');
|
||||
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
|
||||
const finalReportPath =
|
||||
outputFormat === 'junit'
|
||||
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
|
||||
: undefined;
|
||||
const junitFinalReportPath = path.join(testsDirectory, `${platform}-maestro-junit.xml`);
|
||||
|
||||
// Public docs (EAS workflows pre-packaged-jobs) document
|
||||
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
|
||||
// the uploaded dir.
|
||||
const spawnEnv: NodeJS.ProcessEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
|
||||
|
||||
// Outputs are published BEFORE any throw below so downstream
|
||||
// `if: always()` upload steps still see populated values when this
|
||||
// step fails early.
|
||||
// Publish known paths before backend validation so `if: always()` upload
|
||||
// steps can use them after an early failure. final_report_path names the
|
||||
// report selected by output_format, not every report the backend produces.
|
||||
outputs.tests_directory.set(testsDirectory);
|
||||
outputs.junit_report_directory.set(junitReportDirectory);
|
||||
if (finalReportPath !== undefined) {
|
||||
outputs.final_report_path.set(finalReportPath);
|
||||
if (outputFormat === 'junit') {
|
||||
outputs.final_report_path.set(junitFinalReportPath);
|
||||
}
|
||||
|
||||
// Resolved after the output assignments above: an invalid backend input or
|
||||
@@ -239,6 +238,16 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
input: inputs.backend.value,
|
||||
env,
|
||||
});
|
||||
// The runner can write JUnit regardless of output_format; Maestro CLI only
|
||||
// writes it when JUnit is selected. Either command may fail before it writes a file.
|
||||
const mayHaveJUnitReports = backend === 'maestro-runner' || outputFormat === 'junit';
|
||||
const maestroCliOutputPath =
|
||||
backend === 'maestro' && outputFormat && outputFormat !== 'junit'
|
||||
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
||||
: null;
|
||||
if (backend === 'maestro' && outputFormat === 'html' && maestroCliOutputPath) {
|
||||
outputs.final_report_path.set(maestroCliOutputPath);
|
||||
}
|
||||
|
||||
const flowPaths = parseInput(
|
||||
FlowPathSchema,
|
||||
@@ -262,10 +271,14 @@ 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' && outputFormat !== undefined && outputFormat !== 'junit') {
|
||||
if (
|
||||
backend === 'maestro-runner' &&
|
||||
outputFormat !== undefined &&
|
||||
!['junit', 'html', 'allure'].includes(outputFormat)
|
||||
) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
`maestro-runner only supports the "junit" output_format, but received "${outputFormat}".`
|
||||
`maestro-runner supports "junit", "html", and "allure" output_format values, but received "${outputFormat}".`
|
||||
);
|
||||
}
|
||||
const retryFailedOnly = inputs.retry_failed_only.value as boolean;
|
||||
@@ -337,17 +350,14 @@ 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.
|
||||
// maestro-runner writes all report formats and screenshot metadata here.
|
||||
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
|
||||
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
|
||||
: null;
|
||||
const outputPath = mayHaveJUnitReports
|
||||
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
|
||||
: maestroCliOutputPath;
|
||||
const { executable, args: maestroArgs } = buildMaestroArgs({
|
||||
backend,
|
||||
platform,
|
||||
@@ -371,6 +381,17 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
logger.warn({ err }, `Failed to clear ${runnerOutputDirectory} before the attempt.`);
|
||||
}
|
||||
}
|
||||
// Maestro CLI uses the same HTML path for every retry. Remove the previous report
|
||||
// before spawning so an attempt that fails before report generation cannot upload it.
|
||||
if (backend === 'maestro' && outputFormat === 'html' && maestroCliOutputPath) {
|
||||
try {
|
||||
await fs.rm(maestroCliOutputPath, { force: true });
|
||||
} catch (err) {
|
||||
throw new SystemError('Failed to clear the previous Maestro HTML report', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const attemptStartedAtMs = Date.now();
|
||||
|
||||
@@ -412,9 +433,8 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
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.
|
||||
// The pre-packaged job reports test cases only for JUnit (the default format).
|
||||
// Individual screenshots need those test cases to be visible on the website.
|
||||
if (outputFormat === 'junit') {
|
||||
let screenshots: HarvestedScreenshot[];
|
||||
switch (backend) {
|
||||
@@ -504,14 +524,15 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
await sleepAsync(2000);
|
||||
}
|
||||
|
||||
// Smart merge first; on data errors (bad XML, missing input) fall back
|
||||
// to copy-latest so the caller still gets a single JUnit file.
|
||||
// Only JUnit is merged into final_report_path. Runner HTML/Allure keeps its
|
||||
// per-attempt JUnit files, but final_report_path names the selected report.
|
||||
// On data errors (bad XML, missing input), fall back to copy-latest.
|
||||
// Filesystem errors short-circuit straight to SystemError.
|
||||
if (finalReportPath !== undefined) {
|
||||
if (outputFormat === 'junit') {
|
||||
try {
|
||||
await mergeJUnitReports({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
outputPath: junitFinalReportPath,
|
||||
});
|
||||
} catch (mergeErr: any) {
|
||||
if (isFilesystemError(mergeErr)) {
|
||||
@@ -521,7 +542,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
try {
|
||||
await copyLatestAttemptXml({
|
||||
sourceDir: junitReportDirectory,
|
||||
outputPath: finalReportPath,
|
||||
outputPath: junitFinalReportPath,
|
||||
});
|
||||
} catch (copyErr: any) {
|
||||
// Swallow: a copy failure here usually means maestro itself failed
|
||||
@@ -529,14 +550,13 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// the real reason and cancel billing for a user-side failure — let
|
||||
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
|
||||
logger.warn(
|
||||
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
|
||||
`Failed to produce final_report_path at ${junitFinalReportPath}: ${copyErr?.message ?? copyErr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Upload before the failure verdict so fully-failed runs still get screenshots.
|
||||
if (outputFormat === 'junit') {
|
||||
await uploadFailureScreenshotsAsync({
|
||||
harvested,
|
||||
@@ -547,6 +567,81 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
});
|
||||
}
|
||||
|
||||
// Upload the selected non-JUnit report before the failure verdict. The pre-packaged
|
||||
// job also uploads testsDirectory, but a named artifact makes the report easy to find.
|
||||
let selectedReport:
|
||||
| { name: string; artifactPath: string; finalReportPath: string }
|
||||
| undefined;
|
||||
switch (outputFormat) {
|
||||
case 'html': {
|
||||
switch (backend) {
|
||||
case 'maestro': {
|
||||
if (!maestroCliOutputPath) {
|
||||
break;
|
||||
}
|
||||
selectedReport = {
|
||||
name: 'Maestro HTML Report',
|
||||
artifactPath: maestroCliOutputPath,
|
||||
finalReportPath: maestroCliOutputPath,
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'maestro-runner': {
|
||||
const reportDirectory = reportDirectories.at(-1);
|
||||
if (!reportDirectory) {
|
||||
break;
|
||||
}
|
||||
// HTML references nearby screenshots, so keep its whole attempt directory.
|
||||
selectedReport = {
|
||||
name: 'Maestro Runner HTML Report',
|
||||
artifactPath: reportDirectory,
|
||||
finalReportPath: path.join(reportDirectory, 'report.html'),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'allure': {
|
||||
switch (backend) {
|
||||
case 'maestro':
|
||||
logger.warn(
|
||||
'Maestro CLI does not support Allure reports; no Allure artifact was uploaded.'
|
||||
);
|
||||
break;
|
||||
case 'maestro-runner': {
|
||||
const reportDirectory = reportDirectories.at(-1);
|
||||
if (!reportDirectory) {
|
||||
break;
|
||||
}
|
||||
const allureResultsDirectory = path.join(reportDirectory, 'allure-results');
|
||||
selectedReport = {
|
||||
name: 'Maestro Runner Allure Results',
|
||||
artifactPath: allureResultsDirectory,
|
||||
finalReportPath: allureResultsDirectory,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selectedReport) {
|
||||
outputs.final_report_path.set(selectedReport.finalReportPath);
|
||||
try {
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
artifact: {
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: selectedReport.name,
|
||||
paths: [selectedReport.artifactPath],
|
||||
},
|
||||
logger,
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.warn({ err }, `Failed to upload ${selectedReport.name}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// The retry loop exits via success (0), numeric status (retryable),
|
||||
// or throw (infra). A non-null non-zero status means the user's tests
|
||||
// failed every attempt.
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
@@ -25,18 +26,23 @@ import {
|
||||
resolvePackageInstall,
|
||||
} from '../../utils/packageManager';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
import { type StartupTasks, createStartupTasks } from '../utils/startupTasks';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
@@ -51,6 +57,13 @@ const STARTUP_TIMEOUT_MS = 60_000;
|
||||
const AGENT_DEVICE_DAEMON_ENV = {
|
||||
AGENT_DEVICE_DAEMON_SERVER_MODE: 'http',
|
||||
AGENT_DEVICE_RETAIN_ARTIFACTS: '1',
|
||||
// The session lifetime is owned by max_idle_time_minutes / max_duration_seconds
|
||||
// and the device run session stop, so disable agent-device's own idle timers.
|
||||
// Without this the daemon exits after 5 minutes with no open session, and the
|
||||
// tunnel is left without a daemon behind it.
|
||||
AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0',
|
||||
AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS: '0',
|
||||
AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS: '0',
|
||||
};
|
||||
|
||||
export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
@@ -63,6 +76,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_agent_device_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -80,13 +94,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async ({ logger, global }, { inputs, env, signal }) => {
|
||||
// Fail fast before any expensive setup if the injected env
|
||||
// vars are missing: DEVICE_RUN_SESSION_ID (to report the remote config
|
||||
// back to the API server), EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain
|
||||
// for our ngrok tunnels), and NGROK_AUTHTOKEN (to authenticate them).
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
// Fail fast before any expensive setup if the injected env vars are missing.
|
||||
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
@@ -101,124 +110,269 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
logger.info('Launching agent-device daemon.');
|
||||
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
|
||||
|
||||
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
|
||||
const { port: daemonPort, token: daemonToken } = await waitForDaemonInfoAsync({
|
||||
daemonProcess,
|
||||
});
|
||||
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
|
||||
|
||||
const agentDeviceTunnel = await startNgrokTunnelAsync({
|
||||
port: daemonPort,
|
||||
subdomainPrefix: 'agent-device',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
// Earlier steps booted the device and installed and launched the app.
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion,
|
||||
maxIdleTimeMinutes,
|
||||
maxDurationSeconds,
|
||||
launch,
|
||||
capture,
|
||||
tasks: createStartupTasks(logger),
|
||||
device: { booted: Promise.resolve(), ready: Promise.resolve() },
|
||||
});
|
||||
const agentDeviceRemoteSessionUrl = agentDeviceTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
|
||||
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let eventCollection:
|
||||
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
|
||||
| undefined;
|
||||
try {
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
});
|
||||
logger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
);
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl: `http://127.0.0.1:${daemonPort}`,
|
||||
daemonToken,
|
||||
logger,
|
||||
});
|
||||
|
||||
eventCollection = await startAgentDeviceEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
stateDir: AGENT_DEVICE_STATE_DIR,
|
||||
logger,
|
||||
});
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
await agentDeviceTunnel.stopAsync();
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
await daemonProcess.stopAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export type AgentDeviceRemoteSessionEnv = {
|
||||
deviceRunSessionId: string;
|
||||
ngrokTunnelDomain: string;
|
||||
ngrokAuthtoken: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the env vars that the API server injects into a device run session job:
|
||||
* DEVICE_RUN_SESSION_ID (to report the remote config back to the API server),
|
||||
* EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN (base domain for our ngrok tunnels), and
|
||||
* NGROK_AUTHTOKEN (to authenticate them).
|
||||
*/
|
||||
export function getAgentDeviceRemoteSessionEnvOrThrow(
|
||||
env: BuildStepEnv
|
||||
): AgentDeviceRemoteSessionEnv {
|
||||
return {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
ngrokTunnelDomain: getNgrokTunnelDomainOrThrow(env),
|
||||
ngrokAuthtoken: getNgrokAuthtokenOrThrow(env),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the agent-device daemon, its tunnel, the session host and the web preview,
|
||||
* reports the session as ready, and keeps it alive until it stops.
|
||||
*
|
||||
* The daemon does not need the device, so it starts at once. The session host starts
|
||||
* when `device.booted` resolves. The session is reported as ready only when both are
|
||||
* up and `device.ready` (the app is installed and launched) resolved too.
|
||||
*
|
||||
* The first failure aborts `tasks.signal`: each part stops before its next stage, and
|
||||
* the teardown then stops whatever was started. `device.ready` must settle soon after
|
||||
* an abort, because the teardown waits for it.
|
||||
*/
|
||||
export async function runAgentDeviceRemoteSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv: { deviceRunSessionId, ngrokTunnelDomain, ngrokAuthtoken },
|
||||
packageVersion,
|
||||
maxIdleTimeMinutes,
|
||||
maxDurationSeconds,
|
||||
launch,
|
||||
capture,
|
||||
tasks,
|
||||
device,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
sessionEnv: AgentDeviceRemoteSessionEnv;
|
||||
packageVersion: string | undefined;
|
||||
maxIdleTimeMinutes: number | undefined;
|
||||
maxDurationSeconds: number | undefined;
|
||||
launch: ReturnType<typeof parseServeSimLaunchInputs>;
|
||||
capture: ReturnType<typeof parseNetworkCaptureInputs>;
|
||||
tasks: StartupTasks;
|
||||
device: { booted: Promise<unknown>; ready: Promise<unknown> };
|
||||
}
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
let daemonProcess: DetachedProcessHandle | undefined;
|
||||
let agentDeviceTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>> | undefined;
|
||||
let sessionFailed = false;
|
||||
|
||||
// Each task stores what it started, so the teardown below can stop it even when
|
||||
// another task failed first.
|
||||
const agentDeviceStartup = tasks.run('agent-device daemon', async taskLogger => {
|
||||
taskLogger.info('Launching agent-device daemon.');
|
||||
daemonProcess = await startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
signal: tasks.signal,
|
||||
});
|
||||
|
||||
taskLogger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
|
||||
// The daemon is stored above, so the teardown stops it. The wait itself is bounded
|
||||
// (STARTUP_TIMEOUT_MS) and only reads a file, so it may finish in the background.
|
||||
const daemonInfo = await tasks.untilAborted(waitForDaemonInfoAsync({ daemonProcess }));
|
||||
taskLogger.info(`Daemon is listening on port ${daemonInfo.port}; loaded auth token.`);
|
||||
|
||||
tasks.signal.throwIfAborted();
|
||||
agentDeviceTunnel = await startNgrokTunnelAsync({
|
||||
port: daemonInfo.port,
|
||||
subdomainPrefix: 'agent-device',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
logger: taskLogger,
|
||||
});
|
||||
taskLogger.info(`Tunnel is ready at ${agentDeviceTunnel.url}.`);
|
||||
return { ...daemonInfo, remoteSessionUrl: agentDeviceTunnel.url };
|
||||
});
|
||||
const sessionHostStartup = tasks.run('session host', async taskLogger => {
|
||||
// A boot cannot be cancelled, so stop waiting for it when startup is aborted.
|
||||
await tasks.untilAborted(device.booted);
|
||||
tasks.signal.throwIfAborted();
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
taskLogger.info(launchDescription);
|
||||
}
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture: capture.networkCapture,
|
||||
networkCaptureFields: capture.networkCaptureFields,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
taskLogger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
);
|
||||
return webPreview;
|
||||
});
|
||||
|
||||
try {
|
||||
const [
|
||||
{ port: daemonPort, token: daemonToken, remoteSessionUrl: agentDeviceRemoteSessionUrl },
|
||||
webPreview,
|
||||
] = await Promise.all([agentDeviceStartup, sessionHostStartup, device.ready]).catch(
|
||||
(err: unknown) => {
|
||||
// Also aborts for a `device.ready` that does not come from `tasks.run`.
|
||||
tasks.abort(err);
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
logger.info(tasks.summary());
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
daemonUrl: `http://127.0.0.1:${daemonPort}`,
|
||||
daemonToken,
|
||||
logger,
|
||||
});
|
||||
|
||||
eventCollection = await startAgentDeviceEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
stateDir: AGENT_DEVICE_STATE_DIR,
|
||||
logger,
|
||||
});
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
maxDurationSeconds,
|
||||
signal,
|
||||
idleTimeout:
|
||||
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
// Promise.all rejects on the first failure while other tasks can still be starting.
|
||||
// They stop at their next abort check. Wait for all of them, so the teardown sees
|
||||
// everything that was started.
|
||||
await Promise.allSettled([agentDeviceStartup, sessionHostStartup, device.ready]);
|
||||
const startedDaemon = daemonProcess;
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
|
||||
[
|
||||
'agent-device daemon',
|
||||
startedDaemon &&
|
||||
(async () => {
|
||||
try {
|
||||
if (eventCollection) {
|
||||
await stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await startedDaemon.stopAsync();
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
/** Kills the install and stops before the daemon starts, when aborted. No git fallback then. */
|
||||
signal?: AbortSignal;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
@@ -231,13 +385,14 @@ export async function startAgentDeviceDaemonAsync({
|
||||
try {
|
||||
const add = resolvePackageAdd(packageManager, packageSpec);
|
||||
logger.info(`Installing ${packageSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, { cwd: installDir, env, logger });
|
||||
await spawn(add.command, add.args, { cwd: installDir, env, logger, signal });
|
||||
|
||||
const daemonPath = getInstalledAgentDeviceDaemonPath(installDir);
|
||||
if (!fs.existsSync(daemonPath)) {
|
||||
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
logger.info(`Launching daemon from ${daemonPath} after ${add.command} install.`);
|
||||
const daemonProcess = spawnDetached({
|
||||
command: 'node',
|
||||
@@ -253,6 +408,10 @@ export async function startAgentDeviceDaemonAsync({
|
||||
};
|
||||
} catch (err) {
|
||||
await fs.promises.rm(installDir, { recursive: true, force: true });
|
||||
// An abort is not an install problem: stop instead of falling back to git.
|
||||
if (signal?.aborted) {
|
||||
throw err;
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
|
||||
Sentry.capture(
|
||||
@@ -275,7 +434,7 @@ export async function startAgentDeviceDaemonAsync({
|
||||
logger.warn(
|
||||
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
|
||||
);
|
||||
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
|
||||
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger, signal });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,17 +464,19 @@ async function startAgentDeviceDaemonFromGitAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
logger.info(
|
||||
packageVersion
|
||||
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
||||
: `Cloning agent-device (latest) into ${SRC_DIR}.`
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger, signal });
|
||||
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const install = resolvePackageInstall(packageManager, { production: true });
|
||||
@@ -324,8 +485,10 @@ async function startAgentDeviceDaemonFromGitAsync({
|
||||
cwd: SRC_DIR,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
|
||||
signal?.throwIfAborted();
|
||||
logger.info('Launching daemon from cloned agent-device source.');
|
||||
// Git fallback is TypeScript source. The published path runs node on dist JS.
|
||||
return spawnDetached({
|
||||
@@ -340,15 +503,18 @@ async function cloneAgentDeviceAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
|
||||
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
AndroidDeviceName,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { withLocalEgressSession } from '../utils/localEgressSession';
|
||||
import {
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { createStartupTasks } from '../utils/startupTasks';
|
||||
|
||||
import { downloadBuildAsync } from './downloadBuild';
|
||||
import { installBuildAsync } from './installBuild';
|
||||
import {
|
||||
launchApplicationAsync,
|
||||
parseLaunchArgsInput,
|
||||
parseOpenUrlInput,
|
||||
} from './launchApplication';
|
||||
import {
|
||||
getAgentDeviceRemoteSessionEnvOrThrow,
|
||||
runAgentDeviceRemoteSessionAsync,
|
||||
} from './startAgentDeviceRemoteSession';
|
||||
import { startAndroidEmulatorAsync } from './startAndroidEmulator';
|
||||
import { bootIosSimulatorAsync } from './startIosSimulator';
|
||||
|
||||
const ANDROID_DEVICE_NAME = 'EasAndroidDevice01' as AndroidVirtualDeviceName;
|
||||
|
||||
/**
|
||||
* One step for a whole agent-device session: boot the device, download, install and
|
||||
* launch the app, and start the agent-device daemon and the web preview.
|
||||
*
|
||||
* It replaces eas/start_ios_simulator or eas/start_android_emulator, eas/download_build,
|
||||
* eas/install_build, eas/launch_application and eas/start_agent_device_remote_session,
|
||||
* so the parts that do not depend on each other can run at the same time:
|
||||
*
|
||||
* boot ─────────────┬─► install ─► launch ─┐
|
||||
* download ─────────┘ │
|
||||
* boot ─► session host ─► web preview ─────┼─► ready
|
||||
* agent-device daemon ─► tunnel ───────────┘
|
||||
*
|
||||
* The first failure aborts the rest: the download stops, and nothing installs, launches
|
||||
* or starts after it. A boot cannot be cancelled, so it can still run when a failed
|
||||
* step returns.
|
||||
*/
|
||||
export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_agent_device_session',
|
||||
name: 'Start agent-device session',
|
||||
__metricsId: 'eas/start_agent_device_session',
|
||||
inputProviders: [
|
||||
// Device. On iOS a Simulator name or UDID, on Android an AVD hardware profile.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'device_identifier',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'system_image_package',
|
||||
required: false,
|
||||
defaultValue: AndroidEmulatorUtils.defaultSystemImagePackage,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_width',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_height',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'lcd_density',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
// Application. Pass at most one of build_id and application_archive_url.
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_id',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'application_archive_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'launch_args',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'open_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
// Session.
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_idle_time_minutes',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: withLocalEgressSession(async (stepsCtx, { inputs, env, signal }) => {
|
||||
const { logger, global } = stepsCtx;
|
||||
const { runtimePlatform } = global;
|
||||
const isIos = runtimePlatform === BuildRuntimePlatform.DARWIN;
|
||||
|
||||
// Validate everything before any expensive work starts.
|
||||
const sessionEnv = getAgentDeviceRemoteSessionEnvOrThrow(env);
|
||||
const buildId = inputs.build_id.value as string | undefined;
|
||||
const applicationArchiveUrl = inputs.application_archive_url.value as string | undefined;
|
||||
if (buildId && applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_INVALID_SOURCE',
|
||||
'Pass only one of build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
const hasApplication = Boolean(buildId || applicationArchiveUrl);
|
||||
const launchArgs = parseLaunchArgsInput(inputs.launch_args.value);
|
||||
const openUrl =
|
||||
inputs.open_url.value === undefined ? undefined : parseOpenUrlInput(inputs.open_url.value);
|
||||
if (!hasApplication && (launchArgs.length > 0 || openUrl)) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'launch_args and open_url need an application: pass build_id or application_archive_url.'
|
||||
);
|
||||
}
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture.value,
|
||||
networkCaptureFields: inputs.network_capture_fields.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
if (isIos) {
|
||||
// Before the boot, so every Xcode tool below uses the same developer directory.
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
const tasks = createStartupTasks(logger);
|
||||
|
||||
const booted = tasks.run(
|
||||
isIos ? 'iOS Simulator boot' : 'Android Emulator boot',
|
||||
async taskLogger => {
|
||||
if (isIos) {
|
||||
await bootIosSimulatorAsync({
|
||||
deviceIdentifier: deviceIdentifier as IosSimulatorUuid | IosSimulatorName | undefined,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName: ANDROID_DEVICE_NAME,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
deviceIdentifier: deviceIdentifier as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory: await fs.promises.mkdtemp(
|
||||
path.join(os.tmpdir(), 'eas-android-emulator-logcat-')
|
||||
),
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
const downloaded = hasApplication
|
||||
? tasks.run('build download', async taskLogger => {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: taskLogger,
|
||||
...(buildId ? { buildId } : { applicationArchiveUrl: applicationArchiveUrl! }),
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
robotAccessToken: global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions: isIos ? ['app'] : ['apk'],
|
||||
signal: tasks.signal,
|
||||
});
|
||||
return artifactPath;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// `ready` settles only after everything it started has settled, so the session
|
||||
// teardown, which waits for it, never leaves a download running. It must also settle
|
||||
// soon after an abort: it stops waiting for the boot then, and the download stops.
|
||||
const ready = downloaded
|
||||
? tasks.run('app install and launch', async taskLogger => {
|
||||
const [download, boot] = await Promise.allSettled([
|
||||
downloaded,
|
||||
tasks.untilAborted(booted),
|
||||
]);
|
||||
// After a failure elsewhere, report that failure, not the abort it caused here.
|
||||
tasks.signal.throwIfAborted();
|
||||
if (download.status === 'rejected') {
|
||||
throw download.reason;
|
||||
}
|
||||
if (boot.status === 'rejected') {
|
||||
throw boot.reason;
|
||||
}
|
||||
const { applicationIdentifier, activityName } = await installBuildAsync({
|
||||
artifactPath: download.value,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
tasks.signal.throwIfAborted();
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier,
|
||||
activityName,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger: taskLogger,
|
||||
});
|
||||
})
|
||||
: tasks.untilAborted(booted);
|
||||
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
runtimePlatform,
|
||||
sessionEnv,
|
||||
packageVersion: inputs.package_version.value as string | undefined,
|
||||
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
||||
maxIdleTimeMinutes: inputs.max_idle_time_minutes.value as number | undefined,
|
||||
maxDurationSeconds: inputs.max_duration_seconds.value as number | undefined,
|
||||
// The app is launched with simctl / adb above, not by serve-sim.
|
||||
launch: parseServeSimLaunchInputs({}, { runtimePlatform }),
|
||||
capture,
|
||||
tasks,
|
||||
device: { booted, ready },
|
||||
});
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
type BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
@@ -14,10 +16,12 @@ import path from 'node:path';
|
||||
|
||||
import {
|
||||
AndroidDeviceName,
|
||||
AndroidDeviceSerialId,
|
||||
AndroidEmulatorUtils,
|
||||
AndroidVirtualDeviceName,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { retryAsync } from '../../utils/retry';
|
||||
import { resolveAndroidEmulatorLocalEgressAsync } from '../utils/androidLocalEgress';
|
||||
|
||||
const ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS = [60_000, 120_000, 180_000];
|
||||
const ANDROID_STARTUP_RETRIES_COUNT = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length - 1;
|
||||
@@ -80,140 +84,20 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
);
|
||||
outputs.logcat_directory.set(logcatDirectory);
|
||||
|
||||
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
|
||||
await assertAndroidEmulatorHostSupportAsync({ env });
|
||||
} else {
|
||||
logger.info(
|
||||
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
|
||||
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
|
||||
} catch (error) {
|
||||
logger.info('Failed to list available Android devices.', error);
|
||||
} finally {
|
||||
logger.info('');
|
||||
}
|
||||
|
||||
const deviceName = `${inputs.device_name.value}` as AndroidVirtualDeviceName;
|
||||
const systemImagePackage = `${inputs.system_image_package.value}`;
|
||||
// We can cast because allowedValueTypeName validated this is a string.
|
||||
const deviceIdentifier = inputs.device_identifier.value as AndroidDeviceName | undefined;
|
||||
const lcdWidth = inputs.lcd_width.value as number | undefined;
|
||||
const lcdHeight = inputs.lcd_height.value as number | undefined;
|
||||
const lcdDensity = inputs.lcd_density.value as number | undefined;
|
||||
const shouldAdjustAnimationScale =
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
|
||||
|
||||
if (!shouldAdjustAnimationScale) {
|
||||
logger.info(
|
||||
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Making sure system image is installed');
|
||||
await retryAsync(
|
||||
async () => {
|
||||
await spawn('sdkmanager', [systemImagePackage], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
},
|
||||
{
|
||||
const { serialId, emulatorPromise, shouldAdjustAnimationScale } =
|
||||
await startAndroidEmulatorAsync({
|
||||
deviceName,
|
||||
systemImagePackage: `${inputs.system_image_package.value}`,
|
||||
// We can cast because allowedValueTypeName validated this is a string.
|
||||
deviceIdentifier: inputs.device_identifier.value as AndroidDeviceName | undefined,
|
||||
lcdWidth: inputs.lcd_width.value as number | undefined,
|
||||
lcdHeight: inputs.lcd_height.value as number | undefined,
|
||||
lcdDensity: inputs.lcd_density.value as number | undefined,
|
||||
logcatDirectory,
|
||||
env,
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: 3, // Retry 3 times
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let emulatorPromise = null;
|
||||
let serialId = null;
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
const attempt = attemptCount + 1;
|
||||
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
|
||||
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
|
||||
let attemptSerialId = null;
|
||||
|
||||
try {
|
||||
logger.info(`Creating emulator device${attemptSuffix}.`);
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
lcdWidth: lcdWidth ?? null,
|
||||
lcdHeight: lcdHeight ?? null,
|
||||
lcdDensity: lcdDensity ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`Starting emulator device${attemptSuffix}.`);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
});
|
||||
attemptSerialId = startResult.serialId;
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
env,
|
||||
serialId: attemptSerialId,
|
||||
timeoutMs,
|
||||
logger,
|
||||
});
|
||||
if (shouldAdjustAnimationScale) {
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
env,
|
||||
logger,
|
||||
serialId: attemptSerialId,
|
||||
});
|
||||
}
|
||||
logger.info(`${deviceName} is ready.`);
|
||||
|
||||
serialId = attemptSerialId;
|
||||
emulatorPromise = startResult.emulatorPromise;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`
|
||||
);
|
||||
try {
|
||||
if (attemptSerialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: attemptSerialId,
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: ANDROID_STARTUP_RETRIES_COUNT,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!serialId || !emulatorPromise) {
|
||||
throw new Error(`Failed to start emulator ${deviceName}.`);
|
||||
}
|
||||
});
|
||||
|
||||
const count = Number(inputs.count.value ?? 1);
|
||||
if (count > 1) {
|
||||
@@ -306,6 +190,167 @@ export function createStartAndroidEmulatorBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the host, installs the system image, then creates and starts one emulator and
|
||||
* waits until it is ready, with retries.
|
||||
*/
|
||||
export async function startAndroidEmulatorAsync({
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier,
|
||||
lcdWidth,
|
||||
lcdHeight,
|
||||
lcdDensity,
|
||||
logcatDirectory,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
systemImagePackage: string;
|
||||
deviceIdentifier?: AndroidDeviceName;
|
||||
lcdWidth?: number;
|
||||
lcdHeight?: number;
|
||||
lcdDensity?: number;
|
||||
logcatDirectory: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{
|
||||
serialId: AndroidDeviceSerialId;
|
||||
emulatorPromise: Promise<unknown>;
|
||||
shouldAdjustAnimationScale: boolean;
|
||||
}> {
|
||||
if (env.EAS_NO_EMULATOR_HOST_SUPPORT_CHECK !== '1') {
|
||||
await assertAndroidEmulatorHostSupportAsync({ env });
|
||||
} else {
|
||||
logger.info(
|
||||
'Skipping Android emulator host support check because $EAS_NO_EMULATOR_HOST_SUPPORT_CHECK is enabled.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const availableDevices = await AndroidEmulatorUtils.getAvailableDevicesAsync({ env });
|
||||
logger.info(`Available Android devices:\n- ${availableDevices.join(`\n- `)}`);
|
||||
} catch (error) {
|
||||
logger.info('Failed to list available Android devices.', error);
|
||||
} finally {
|
||||
logger.info('');
|
||||
}
|
||||
|
||||
const shouldAdjustAnimationScale =
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== 'false' &&
|
||||
env.ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE !== '0';
|
||||
|
||||
if (!shouldAdjustAnimationScale) {
|
||||
logger.info(
|
||||
'Skipping Android emulator animation scale adjustments because $ANDROID_EMULATOR_ADJUST_ANIMATION_SCALE is disabled.'
|
||||
);
|
||||
}
|
||||
|
||||
const localEgress = await resolveAndroidEmulatorLocalEgressAsync();
|
||||
|
||||
logger.info('Making sure system image is installed');
|
||||
await retryAsync(
|
||||
async () => {
|
||||
await spawn('sdkmanager', [systemImagePackage], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: 3, // Retry 3 times
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
let emulatorPromise = null;
|
||||
let serialId = null;
|
||||
await retryAsync(
|
||||
async attemptCount => {
|
||||
const timeoutMs = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS[attemptCount];
|
||||
const attempt = attemptCount + 1;
|
||||
const maxAttempts = ANDROID_STARTUP_ATTEMPT_TIMEOUT_MS.length;
|
||||
const attemptSuffix = attempt > 1 ? ` (attempt ${attempt}/${maxAttempts})` : '';
|
||||
let attemptSerialId = null;
|
||||
|
||||
try {
|
||||
logger.info(`Creating emulator device${attemptSuffix}.`);
|
||||
await AndroidEmulatorUtils.createAsync({
|
||||
deviceName,
|
||||
systemImagePackage,
|
||||
deviceIdentifier: deviceIdentifier ?? null,
|
||||
lcdWidth: lcdWidth ?? null,
|
||||
lcdHeight: lcdHeight ?? null,
|
||||
lcdDensity: lcdDensity ?? null,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`Starting emulator device${attemptSuffix}.`);
|
||||
const startResult = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
...(localEgress ? { launchGate: localEgress.launchGate } : {}),
|
||||
});
|
||||
attemptSerialId = startResult.serialId;
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
env,
|
||||
serialId: attemptSerialId,
|
||||
timeoutMs,
|
||||
logger,
|
||||
...(localEgress ? { networkReadyTarget: localEgress.networkReadyTarget } : {}),
|
||||
});
|
||||
if (shouldAdjustAnimationScale) {
|
||||
await AndroidEmulatorUtils.disableWindowAndTransitionAnimationsAsync({
|
||||
env,
|
||||
logger,
|
||||
serialId: attemptSerialId,
|
||||
});
|
||||
}
|
||||
await localEgress?.configureBootedEmulatorAsync({ serialId: attemptSerialId, logger });
|
||||
logger.info(`${deviceName} is ready.`);
|
||||
|
||||
serialId = attemptSerialId;
|
||||
emulatorPromise = startResult.emulatorPromise;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `${deviceName} failed to start on attempt ${attempt}/${maxAttempts}.`);
|
||||
try {
|
||||
if (attemptSerialId) {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
serialId: attemptSerialId,
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
} else {
|
||||
await AndroidEmulatorUtils.deleteAsync({
|
||||
deviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
logger.warn({ err: cleanupErr }, `Failed to clean up ${deviceName}.`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
{
|
||||
logger,
|
||||
retryOptions: {
|
||||
retries: ANDROID_STARTUP_RETRIES_COUNT,
|
||||
retryIntervalMs: 1_000,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!serialId || !emulatorPromise) {
|
||||
throw new Error(`Failed to start emulator ${deviceName}.`);
|
||||
}
|
||||
return { serialId, emulatorPromise, shouldAdjustAnimationScale };
|
||||
}
|
||||
|
||||
async function assertAndroidEmulatorHostSupportAsync({
|
||||
env,
|
||||
}: {
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
@@ -32,13 +37,13 @@ import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -63,6 +68,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -89,6 +95,13 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
|
||||
logger.info(
|
||||
@@ -138,7 +151,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger,
|
||||
});
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
appiumTunnel = await startNgrokTunnelAsync({
|
||||
port: APPIUM_PORT,
|
||||
@@ -154,16 +168,18 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
@@ -197,16 +213,29 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
if (appiumTunnel) {
|
||||
await appiumTunnel.stopAsync();
|
||||
}
|
||||
await eventCollection.stopAsync();
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Appium tunnel', appiumTunnel?.stopAsync()],
|
||||
[
|
||||
'Appium server',
|
||||
(async () => {
|
||||
try {
|
||||
await eventCollection.stopAsync();
|
||||
} finally {
|
||||
await appiumProcess.stopAsync();
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
@@ -32,13 +37,13 @@ import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
finishRemoteSessionAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
@@ -75,6 +80,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -115,6 +121,13 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
logger.info(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
@@ -184,6 +197,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
stateDir: ARGENT_STATE_DIR,
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
@@ -216,7 +230,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let webPreview: Awaited<ReturnType<typeof startDeviceWebPreviewWithTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
try {
|
||||
toolsTunnel = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
@@ -233,16 +248,18 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
logger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
);
|
||||
@@ -276,23 +293,46 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (webPreview) {
|
||||
await webPreview.stopAsync();
|
||||
}
|
||||
if (toolsTunnel) {
|
||||
await toolsTunnel.stopAsync();
|
||||
}
|
||||
await stopArgentEventCollectionSafelyAsync({ eventCollection, deviceRunSessionId, logger });
|
||||
artifactPollAbortController.abort();
|
||||
try {
|
||||
await artifactPollingPromise;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not finish Argent remote session artifact polling', error);
|
||||
logger.warn({ err: error }, 'Could not finish Argent remote session artifact polling.');
|
||||
}
|
||||
await argentServer.stopAsync();
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
teardown: [
|
||||
['Argent tunnel', toolsTunnel?.stopAsync()],
|
||||
[
|
||||
'Argent tool-server',
|
||||
(async () => {
|
||||
try {
|
||||
await stopArgentEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
});
|
||||
artifactPollAbortController.abort();
|
||||
try {
|
||||
await artifactPollingPromise;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture(
|
||||
'Could not finish Argent remote session artifact polling',
|
||||
error
|
||||
);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not finish Argent remote session artifact polling.'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await argentServer.stopAsync();
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -371,16 +411,26 @@ export async function waitForArgentToolServerStateAsync({
|
||||
ancestorPid,
|
||||
timeoutMs,
|
||||
pollIntervalMs = 1_000,
|
||||
getExitError,
|
||||
}: {
|
||||
stateDir: string;
|
||||
ancestorPid: number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs?: number;
|
||||
getExitError?: () => Error | undefined;
|
||||
}): Promise<ArgentToolServerState> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
const throwIfExited = (): void => {
|
||||
const error = getExitError?.();
|
||||
if (error) {
|
||||
throw new SystemError(`Argent exited before becoming ready: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
throwIfExited();
|
||||
let matchingState: ArgentToolServerState | undefined;
|
||||
try {
|
||||
const stateFileNames = (await fs.promises.readdir(stateDir)).filter(
|
||||
name => name.startsWith('tool-server') && name.endsWith('.json')
|
||||
@@ -391,7 +441,8 @@ export async function waitForArgentToolServerStateAsync({
|
||||
await fs.promises.readFile(path.join(stateDir, stateFileName), 'utf8')
|
||||
);
|
||||
if (await isProcessDescendantOfAsync(state.pid, ancestorPid)) {
|
||||
return state;
|
||||
matchingState = state;
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT' && !(err instanceof SystemError)) {
|
||||
@@ -402,6 +453,11 @@ export async function waitForArgentToolServerStateAsync({
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
// Keep process failures outside the retryable state-file read errors.
|
||||
throwIfExited();
|
||||
if (matchingState) {
|
||||
return matchingState;
|
||||
}
|
||||
await sleepAsync(pollIntervalMs);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,59 +47,18 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env }) => {
|
||||
try {
|
||||
const availableDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'available',
|
||||
});
|
||||
logger.info(
|
||||
`Available Simulator devices:\n- ${availableDevices
|
||||
.map(device => device.displayName)
|
||||
.join(`\n- `)}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.info('Failed to list available Simulator devices.', error);
|
||||
} finally {
|
||||
logger.info('');
|
||||
}
|
||||
|
||||
const deviceIdentifierInput = inputs.device_identifier.value?.toString() as
|
||||
| IosSimulatorUuid
|
||||
| IosSimulatorName
|
||||
| undefined;
|
||||
const originalDeviceIdentifier =
|
||||
deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
|
||||
const enableAccessibilitySettings = Boolean(inputs.enable_accessibility_settings.value);
|
||||
|
||||
if (!originalDeviceIdentifier) {
|
||||
throw new Error('Could not find an iPhone among available simulator devices.');
|
||||
}
|
||||
|
||||
if (enableAccessibilitySettings) {
|
||||
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
const { deviceIdentifier: originalDeviceIdentifier, displayName: formattedDevice } =
|
||||
await bootIosSimulatorAsync({
|
||||
deviceIdentifier: deviceIdentifierInput,
|
||||
enableAccessibilitySettings,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
const udid = await bootWithLocalEgressAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({ udid, env });
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
|
||||
|
||||
logger.info('');
|
||||
|
||||
const device = await IosSimulatorUtils.getDeviceAsync({ udid, env });
|
||||
const formattedDevice = device?.displayName ?? originalDeviceIdentifier;
|
||||
logger.info(`${formattedDevice} is ready.`);
|
||||
|
||||
const count = Number(inputs.count.value ?? 1);
|
||||
if (count > 1) {
|
||||
@@ -150,6 +109,67 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Boots one iOS Simulator and waits until it is ready: the requested device, or the
|
||||
* most generic iPhone when none is given.
|
||||
*/
|
||||
export async function bootIosSimulatorAsync({
|
||||
deviceIdentifier: deviceIdentifierInput,
|
||||
enableAccessibilitySettings = false,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
deviceIdentifier?: IosSimulatorUuid | IosSimulatorName;
|
||||
enableAccessibilitySettings?: boolean;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<{
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
udid: IosSimulatorUuid;
|
||||
displayName: string;
|
||||
}> {
|
||||
try {
|
||||
const availableDevices = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'available',
|
||||
});
|
||||
logger.info(
|
||||
`Available Simulator devices:\n- ${availableDevices
|
||||
.map(device => device.displayName)
|
||||
.join(`\n- `)}`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.info('Failed to list available Simulator devices.', error);
|
||||
} finally {
|
||||
logger.info('');
|
||||
}
|
||||
|
||||
const deviceIdentifier = deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
|
||||
if (!deviceIdentifier) {
|
||||
throw new Error('Could not find an iPhone among available simulator devices.');
|
||||
}
|
||||
|
||||
if (enableAccessibilitySettings) {
|
||||
await IosSimulatorUtils.enableAccessibilitySettingsAsync({ deviceIdentifier, env });
|
||||
}
|
||||
const udid = await bootWithLocalEgressAsync({ deviceIdentifier, env, logger });
|
||||
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({ udid, env });
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
|
||||
|
||||
logger.info('');
|
||||
|
||||
const device = await IosSimulatorUtils.getDeviceAsync({ udid, env });
|
||||
const displayName = device?.displayName ?? deviceIdentifier;
|
||||
logger.info(`${displayName} is ready.`);
|
||||
return { deviceIdentifier, udid, displayName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a device with the local egress environment in place before anything
|
||||
* inside it starts. `simctl boot` returns once the simulator's launchd is up
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { startAndroidLocalEgressAsync } from '../utils/androidLocalEgress';
|
||||
import {
|
||||
CHISEL_VERSION,
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
@@ -79,8 +80,9 @@ function awaitLocalEgressAcquisitionAsync<T>(
|
||||
* at boot. Once a simulator boots, `eas/start_ios_simulator` also sets proxy
|
||||
* environment variables inside it for clients that read them (gRPC, libcurl)
|
||||
* and installs the local egress guard, which refuses connections that ignore
|
||||
* both. The shared session cleanup releases the resources started here when
|
||||
* the session ends, with a job finalizer as a fallback.
|
||||
* both. On Linux it starts the Android emulator relay and fence instead; see
|
||||
* androidLocalEgress.ts. The shared session cleanup releases the resources
|
||||
* started here when the session ends, with a job finalizer as a fallback.
|
||||
*/
|
||||
export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
@@ -88,13 +90,15 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
id: 'start_local_egress',
|
||||
name: 'Start local egress',
|
||||
__metricsId: 'eas/start_local_egress',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }, { env, signal }) => {
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX],
|
||||
fn: async ({ logger, global }, { env, signal }) => {
|
||||
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(env);
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
const isAndroid = global?.runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
let workDir: string | undefined;
|
||||
let server: DetachedProcessHandle | undefined;
|
||||
let tunnel: NgrokTunnelHandle | undefined;
|
||||
let android: { stopAsync: () => Promise<void> } | undefined;
|
||||
let finishSetup!: () => void;
|
||||
const setupFinished = new Promise<void>(resolve => {
|
||||
finishSetup = resolve;
|
||||
@@ -105,6 +109,7 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
await setupFinished;
|
||||
const results = await Promise.allSettled([
|
||||
Promise.resolve().then(() => stopLocalEgressGuardRelaysAsync(logger)),
|
||||
Promise.resolve().then(() => android?.stopAsync()),
|
||||
Promise.resolve().then(() => tunnel?.stopAsync()),
|
||||
Promise.resolve().then(() => server?.stopAsync()),
|
||||
]);
|
||||
@@ -169,6 +174,35 @@ export function createStartLocalEgressBuildFunction(): BuildFunction {
|
||||
);
|
||||
startupSignal.throwIfAborted();
|
||||
|
||||
if (isAndroid) {
|
||||
if (server.pid === undefined) {
|
||||
throw new Error('The reverse tunnel server has no process id.');
|
||||
}
|
||||
android = await startAndroidLocalEgressAsync({
|
||||
env,
|
||||
logger,
|
||||
workDir,
|
||||
chiselPid: server.pid,
|
||||
proxyPort: LOCAL_EGRESS_PROXY_PORT,
|
||||
controlPort,
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
await writeLocalEgressHandoffAsync({
|
||||
url: tunnel.url,
|
||||
token: credentials.password,
|
||||
fingerprint: started.fingerprint,
|
||||
port: LOCAL_EGRESS_PROXY_PORT,
|
||||
platform: 'android',
|
||||
});
|
||||
startupSignal.throwIfAborted();
|
||||
logger.info(
|
||||
"Local egress is configured for the Android emulator. Its TCP traffic goes through the emulator's " +
|
||||
'proxy and fails until the EAS CLI egress client connects, then exits from that machine. ' +
|
||||
'Traffic that cannot use the tunnel, such as UDP, is refused and reported here.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { service } = await configureSystemProxyAsync({
|
||||
env,
|
||||
logger,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import { withLogPhaseAsync } from '../../utils/logPhase';
|
||||
import { startSandboxDaemonAsync } from '../utils/sandboxDaemon';
|
||||
|
||||
@@ -76,19 +77,7 @@ export async function markSandboxReadyAsync(
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(
|
||||
MARK_SANDBOX_READY_MUTATION,
|
||||
{ sandboxId },
|
||||
signal
|
||||
? {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
signal: init?.signal ? AbortSignal.any([signal, init.signal]) : signal,
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
.mutation(MARK_SANDBOX_READY_MUTATION, { sandboxId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
if (result.error) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { ServeSimLogsRecorder } from '../utils/serveSimLogsRecorder';
|
||||
|
||||
export function createStartServeSimLogsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_serve_sim_logs',
|
||||
name: 'Start serve-sim simulator logs',
|
||||
__metricsId: 'eas/start_serve_sim_logs',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
fn: async ({ logger }) => {
|
||||
try {
|
||||
await ServeSimLogsRecorder.startAsync({ logger });
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not start serve-sim simulator logs', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not start simulator log collection; the session will continue.'
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
} from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import {
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync,
|
||||
withLocalEgressSession,
|
||||
@@ -17,7 +22,6 @@ import {
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
@@ -33,6 +37,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -58,6 +63,13 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
networkCaptureFields: inputs.network_capture_fields?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
@@ -69,9 +81,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
const webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
const sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
@@ -79,10 +90,13 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
|
||||
|
||||
try {
|
||||
const webPreview = await sessionHost.openPreviewAsync({ baseDomain: ngrokTunnelDomain });
|
||||
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
signal,
|
||||
@@ -104,7 +118,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
signal,
|
||||
});
|
||||
} finally {
|
||||
await webPreview.stopAsync();
|
||||
await sessionHost.finishAsync();
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import path from 'node:path';
|
||||
import limitFactory from 'promise-limit';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AscApiClient, AscApiRequestError } from '../utils/ios/AscApiClient';
|
||||
import { AscApiUtils } from '../utils/ios/AscApiUtils';
|
||||
|
||||
export function createUpdateTestFlightMetadataBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'update_testflight_metadata',
|
||||
name: 'Update TestFlight metadata',
|
||||
__metricsId: 'eas/update_testflight_metadata',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'asc_api_key_path',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_upload_id',
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'changelog',
|
||||
required: false,
|
||||
defaultValue: '',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'groups',
|
||||
required: false,
|
||||
defaultValue: [],
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
],
|
||||
fn: async (ctx, { inputs }) => {
|
||||
const parsedInputs = z
|
||||
.object({
|
||||
asc_api_key_path: z.string(),
|
||||
build_upload_id: z.string(),
|
||||
changelog: z.string(),
|
||||
groups: z.array(z.string()),
|
||||
})
|
||||
.parse({
|
||||
asc_api_key_path: inputs.asc_api_key_path.value,
|
||||
build_upload_id: inputs.build_upload_id.value,
|
||||
changelog: inputs.changelog.value,
|
||||
groups: inputs.groups.value,
|
||||
});
|
||||
const keyPath = path.resolve(ctx.workingDirectory, parsedInputs.asc_api_key_path);
|
||||
const key = await AscApiUtils.loadApiKeyAsync({ keyPath });
|
||||
const client = new AscApiClient({ key, logger: ctx.logger });
|
||||
await updateTestFlightMetadataAsync({
|
||||
client,
|
||||
buildUploadId: parsedInputs.build_upload_id,
|
||||
changelog: parsedInputs.changelog,
|
||||
groups: parsedInputs.groups,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
ctx.logger.info('TestFlight metadata updated.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTestFlightMetadataAsync({
|
||||
client,
|
||||
buildUploadId,
|
||||
changelog,
|
||||
groups,
|
||||
logger,
|
||||
}: {
|
||||
client: AscApiClient;
|
||||
buildUploadId: string;
|
||||
changelog: string;
|
||||
groups: string[];
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const { data: upload } = await client.getAsync(
|
||||
'/v1/buildUploads/:id',
|
||||
{
|
||||
'fields[buildUploads]': ['state', 'build'],
|
||||
include: ['build'],
|
||||
},
|
||||
{ id: buildUploadId }
|
||||
);
|
||||
const buildId = upload.relationships?.build?.data?.id;
|
||||
if (upload.attributes?.state?.state !== 'COMPLETE' || !buildId) {
|
||||
throw new UserError(
|
||||
'EAS_TESTFLIGHT_BUILD_NOT_READY',
|
||||
'The uploaded build is not ready for TestFlight metadata. Run eas/upload_to_asc with wait_for_processing: true first.'
|
||||
);
|
||||
}
|
||||
logger.info(`Updating TestFlight metadata for Apple build ${buildId}...`);
|
||||
const { data: app } = await client.getAsync('/v1/builds/:id/app', {}, { id: buildId });
|
||||
const limit = limitFactory<void>(1);
|
||||
const results = await Promise.allSettled([
|
||||
limit(async () => {
|
||||
if (!changelog) {
|
||||
return;
|
||||
}
|
||||
logger.info(`Updating changelog: ${JSON.stringify(changelog)}`);
|
||||
const primaryLocale = app.attributes?.primaryLocale || 'en-US';
|
||||
if (!app.attributes?.primaryLocale) {
|
||||
logger.warn('App Store Connect did not return the primary locale; using "en-US".');
|
||||
}
|
||||
logger.info(`Primary locale: ${JSON.stringify(primaryLocale)}.`);
|
||||
const localizations = await AscApiUtils.getAllBetaBuildLocalizationsAsync({
|
||||
client,
|
||||
buildId,
|
||||
});
|
||||
const localizationLimit = limitFactory<void>(1);
|
||||
const updates = localizations.map(localization =>
|
||||
localizationLimit(async () => {
|
||||
const label = `${JSON.stringify(localization.attributes?.locale ?? 'unknown')} (${localization.id})`;
|
||||
try {
|
||||
await client.patchAsync(
|
||||
'/v1/betaBuildLocalizations/:id',
|
||||
{
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
id: localization.id,
|
||||
attributes: { whatsNew: changelog },
|
||||
},
|
||||
},
|
||||
{ id: localization.id }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`❌ Locale ${label}: changelog update failed. ${String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
logger.info(`✅ Locale ${label}: changelog updated.`);
|
||||
})
|
||||
);
|
||||
if (!localizations.some(localization => localization.attributes?.locale === primaryLocale)) {
|
||||
updates.push(
|
||||
localizationLimit(async () => {
|
||||
try {
|
||||
await client.postAsync('/v1/betaBuildLocalizations', {
|
||||
data: {
|
||||
type: 'betaBuildLocalizations',
|
||||
attributes: { locale: primaryLocale, whatsNew: changelog },
|
||||
relationships: { build: { data: { type: 'builds', id: buildId } } },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Locale ${JSON.stringify(primaryLocale)}: localization creation failed. ${String(error)}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
logger.info(`✅ Locale ${JSON.stringify(primaryLocale)}: localization created.`);
|
||||
})
|
||||
);
|
||||
}
|
||||
const localizationResults = await Promise.allSettled(updates);
|
||||
const localizationFailures = localizationResults.filter(
|
||||
result => result.status === 'rejected'
|
||||
);
|
||||
if (localizationFailures.length === 1) {
|
||||
throw localizationFailures[0].reason;
|
||||
}
|
||||
if (localizationFailures.length > 1) {
|
||||
throw new AggregateError(
|
||||
localizationFailures.map(failure => failure.reason),
|
||||
`Failed to update TestFlight localizations: ${localizationFailures.map(failure => String(failure.reason)).join('; ')}`
|
||||
);
|
||||
}
|
||||
}),
|
||||
limit(async () => {
|
||||
if (!groups.length) {
|
||||
return;
|
||||
}
|
||||
logger.info(`Apple app: ${app.id}. Requested groups: ${JSON.stringify(groups)}.`);
|
||||
const requestedNames = new Set(groups);
|
||||
const allGroups = await AscApiUtils.getAllBetaGroupsAsync({ client, appId: app.id });
|
||||
const requestedGroups = allGroups.filter(group =>
|
||||
requestedNames.has(group.attributes?.name ?? '')
|
||||
);
|
||||
const foundNames = new Set(requestedGroups.map(group => group.attributes?.name));
|
||||
const missingNames = [...requestedNames].filter(name => !foundNames.has(name));
|
||||
logger.info(`Found ${requestedGroups.length} TestFlight group(s).`);
|
||||
const assignedGroups = requestedGroups.length
|
||||
? await AscApiUtils.getAllBetaGroupsAsync({
|
||||
client,
|
||||
buildId,
|
||||
})
|
||||
: [];
|
||||
const assignedIds = new Set(assignedGroups.map(group => group.id));
|
||||
const groupLimit = limitFactory<void>(1);
|
||||
const groupResults = await Promise.allSettled(
|
||||
requestedGroups.map(group =>
|
||||
groupLimit(async () => {
|
||||
const label = `${JSON.stringify(group.attributes?.name)} (${group.id})`;
|
||||
if (assignedIds.has(group.id)) {
|
||||
logger.info(`✅ Group ${label}: build already assigned; no assignment needed.`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
group.attributes?.isInternalGroup === true &&
|
||||
group.attributes.hasAccessToAllBuilds === true
|
||||
) {
|
||||
logger.info(
|
||||
`✅ Group ${label}: automatic access to all builds; no assignment needed.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await client.postAsync(
|
||||
'/v1/builds/:id/relationships/betaGroups',
|
||||
{
|
||||
data: [{ type: 'betaGroups', id: group.id }],
|
||||
},
|
||||
{ id: buildId }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`❌ Group ${label}: assignment failed. ${String(error)}`);
|
||||
if (isInternalGroupAssignmentError(error)) {
|
||||
throw new UserError(
|
||||
'EAS_TESTFLIGHT_INTERNAL_GROUP_ASSIGNMENT_FAILED',
|
||||
"App Store Connect can't add this build to a requested internal TestFlight group. " +
|
||||
"Internal groups that automatically receive new builds can't be assigned to manually. " +
|
||||
'Remove the group from the list, or turn off automatic distribution in App Store Connect. ' +
|
||||
`Manage groups at https://appstoreconnect.apple.com/apps/${app.id}/testflight`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
logger.info(`✅ Group ${label}: assignment completed.`);
|
||||
})
|
||||
)
|
||||
);
|
||||
const groupFailures = groupResults.filter(result => result.status === 'rejected');
|
||||
if (missingNames.length) {
|
||||
const error = new UserError(
|
||||
'EAS_TESTFLIGHT_GROUPS_NOT_FOUND',
|
||||
`The following TestFlight group${missingNames.length > 1 ? 's were' : ' was'} not found in App Store Connect: ${missingNames.map(name => `"${name}"`).join(', ')}. Check the group names and try again.`
|
||||
);
|
||||
logger.error(`❌ ${error.message}`);
|
||||
groupFailures.push({ status: 'rejected', reason: error });
|
||||
}
|
||||
if (groupFailures.length === 1) {
|
||||
throw groupFailures[0].reason;
|
||||
}
|
||||
if (groupFailures.length > 1) {
|
||||
throw new AggregateError(
|
||||
groupFailures.map(failure => failure.reason),
|
||||
`Failed to assign TestFlight groups: ${groupFailures.map(failure => String(failure.reason)).join('; ')}`
|
||||
);
|
||||
}
|
||||
}),
|
||||
]);
|
||||
const failures = results.filter(result => result.status === 'rejected');
|
||||
if (failures.length === 1) {
|
||||
throw failures[0].reason;
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(
|
||||
failures.map(failure => failure.reason),
|
||||
`Failed to update the TestFlight changelog and groups: ${failures
|
||||
.map(failure => String(failure.reason))
|
||||
.join('; ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Apple returns a generic 422 code, so match the title or detail too.
|
||||
function isInternalGroupAssignmentError(error: unknown): boolean {
|
||||
if (error instanceof AggregateError) {
|
||||
return error.errors.some(isInternalGroupAssignmentError);
|
||||
}
|
||||
return (
|
||||
error instanceof AscApiRequestError &&
|
||||
error.status === 422 &&
|
||||
error.code === 'ENTITY_UNPROCESSABLE' &&
|
||||
(error.responseJson.title === 'Builds cannot be assigned to this internal group.' ||
|
||||
error.responseJson.detail === 'Cannot add internal group to a build.')
|
||||
);
|
||||
}
|
||||
+11
-100
@@ -1,53 +1,17 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import limitFactory from 'promise-limit';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../utils/deviceRunSessionArtifacts';
|
||||
import {
|
||||
parseDeviceScreenRecordings,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../utils/deviceRunSessionScreenRecordings';
|
||||
import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const RecordingsSchema = z.array(
|
||||
z.object({
|
||||
udid: z.string(),
|
||||
deviceName: z.string(),
|
||||
runtimeDisplayName: z.string(),
|
||||
directory: z.string(),
|
||||
})
|
||||
);
|
||||
|
||||
const RecordingManifestSchema = z.object({
|
||||
firstFrameWallClock: z.object({
|
||||
iso8601: z.string(),
|
||||
}),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
recording: z.string(),
|
||||
});
|
||||
|
||||
const recordingStartTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
hourCycle: 'h23',
|
||||
timeZone: 'UTC',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
@@ -65,69 +29,16 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { env, inputs }) => {
|
||||
const result = RecordingsSchema.safeParse(inputs.recordings_json.value ?? []);
|
||||
if (!result.success) {
|
||||
throw new SystemError('Invalid iOS Simulator recordings input.', {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
const recordings = result.data;
|
||||
const recordings = parseDeviceScreenRecordings(inputs.recordings_json.value ?? []);
|
||||
if (recordings.length === 0) {
|
||||
logger.info('No iOS Simulator recordings found; skipping uploads.');
|
||||
logger.info('No device screen recordings found; skipping uploads.');
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
|
||||
const limit = limitFactory(5);
|
||||
await Promise.all(
|
||||
recordings.map(recording =>
|
||||
limit(async () => {
|
||||
try {
|
||||
const metadata = RecordingManifestSchema.parse(
|
||||
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
|
||||
);
|
||||
const startedAt = recordingStartTimeFormatter.format(
|
||||
new Date(metadata.firstFrameWallClock.iso8601)
|
||||
);
|
||||
const shortUdid = `${recording.udid.slice(0, 8)}-…`;
|
||||
const displayName = `${recording.deviceName} screen recording (${shortUdid}, started at ${startedAt})`;
|
||||
const recordingPath = path.join(recording.directory, metadata.recording);
|
||||
const { size } = await stat(recordingPath);
|
||||
const recordingId = path.basename(recording.directory);
|
||||
logger.info(
|
||||
`Uploading screen recording for ${recording.deviceName} (${formatBytes(size)}).`
|
||||
);
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: recordingId,
|
||||
name: displayName,
|
||||
filename: `${recordingId}.mp4`,
|
||||
kind: 'screen-recording',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording',
|
||||
__eas_screen_recording: '1',
|
||||
udid: recording.udid,
|
||||
deviceName: recording.deviceName,
|
||||
runtimeDisplayName: recording.runtimeDisplayName,
|
||||
firstFrameAt: metadata.firstFrameWallClock.iso8601,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
},
|
||||
size,
|
||||
stream: createReadStream(recordingPath),
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload iOS Simulator screen recording', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not upload screen recording for ${recording.deviceName}.`
|
||||
);
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
await uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
recordings,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import fs from 'fs-extra';
|
||||
import * as jose from 'jose';
|
||||
import fetch from 'node-fetch';
|
||||
import path from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
@@ -87,29 +86,8 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
throw new Error(`ASC API Key file not found: ${ascApiKeyPath}`);
|
||||
}
|
||||
|
||||
const ascApiKeyJson = await fs.readJson(ascApiKeyPath);
|
||||
const ascApiKey = z
|
||||
.object({
|
||||
issuer_id: z.string().nullish(),
|
||||
key_id: z.string(),
|
||||
key: z.string(),
|
||||
})
|
||||
.parse(ascApiKeyJson);
|
||||
|
||||
const privateKey = await jose.importPKCS8(ascApiKey.key, 'ES256');
|
||||
const jwt = new jose.SignJWT({})
|
||||
.setProtectedHeader({ alg: 'ES256', kid: ascApiKey.key_id })
|
||||
.setAudience('appstoreconnect-v1')
|
||||
.setExpirationTime('20m');
|
||||
if (ascApiKey.issuer_id) {
|
||||
jwt.setIssuer(ascApiKey.issuer_id);
|
||||
} else {
|
||||
// Nullish issuer_id means an individual API key
|
||||
jwt.setSubject('user');
|
||||
}
|
||||
const token = await jwt.sign(privateKey);
|
||||
|
||||
const client = new AscApiClient({ token, logger: stepsCtx.logger });
|
||||
const key = await AscApiUtils.loadApiKeyAsync({ keyPath: ascApiKeyPath });
|
||||
const client = new AscApiClient({ key, logger: stepsCtx.logger });
|
||||
|
||||
stepsCtx.logger.info(
|
||||
`Reading App information for Apple app identifier: ${appleAppIdentifier}...`
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import {
|
||||
buildEgressFenceRuleset,
|
||||
findProcessesOutsideScope,
|
||||
getCgroupLevel,
|
||||
listEmulatorProcessesAsync,
|
||||
parseAdbReversePorts,
|
||||
parseConnectAuthority,
|
||||
parseFenceCounters,
|
||||
parseFenceLogLine,
|
||||
parseLoopbackListenerPorts,
|
||||
resolveAndroidEmulatorLocalEgressAsync,
|
||||
startLocalEgressRelayAsync,
|
||||
} from '../androidLocalEgress';
|
||||
import { writeLocalEgressHandoffAsync } from '../localEgress';
|
||||
|
||||
describe(parseConnectAuthority, () => {
|
||||
it('parses IPv4, hostname and bracketed IPv6 authorities', () => {
|
||||
expect(parseConnectAuthority('CONNECT 192.0.2.1:443 HTTP/1.1\r\nHost: x\r\n')).toEqual({
|
||||
host: '192.0.2.1',
|
||||
port: 443,
|
||||
});
|
||||
expect(parseConnectAuthority('CONNECT Example.com:8443 HTTP/1.0\r\n')).toEqual({
|
||||
host: 'example.com',
|
||||
port: 8443,
|
||||
});
|
||||
expect(parseConnectAuthority('CONNECT [2001:db8::1]:443 HTTP/1.1\r\n')).toEqual({
|
||||
host: '2001:db8::1',
|
||||
port: 443,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores requests that are not CONNECT', () => {
|
||||
expect(parseConnectAuthority('GET http://example.com/ HTTP/1.1\r\n')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildEgressFenceRuleset, () => {
|
||||
it('refuses traffic from the session scope that cannot use the tunnel', () => {
|
||||
const ruleset = buildEgressFenceRuleset({ cgroupLevel: 2 });
|
||||
expect(ruleset).toContain('socket cgroupv2 level 2 @fenced jump fence');
|
||||
expect(ruleset).toContain('oif "lo" meta l4proto tcp accept');
|
||||
expect(ruleset).toContain('oif "lo" ip daddr 127.0.0.53 udp dport 53 accept');
|
||||
expect(ruleset).toContain('log prefix "eas-egress " level info');
|
||||
expect(ruleset).toContain('meta l4proto tcp counter name fence_tcp reject with tcp reset');
|
||||
expect(ruleset).toContain(
|
||||
'meta l4proto udp counter name fence_udp reject with icmpx admin-prohibited'
|
||||
);
|
||||
expect(ruleset).not.toMatch(/fence_\w+ accept/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFenceLogLine, () => {
|
||||
it('parses a refused IPv4 TCP connection from the emulator', () => {
|
||||
expect(
|
||||
parseFenceLogLine(
|
||||
'eas-egress IN= OUT=ens3 SRC=10.128.0.5 DST=172.253.155.190 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=1 DF PROTO=TCP SPT=42408 DPT=443 WINDOW=64240 RES=0x00 SYN URGP=0'
|
||||
)
|
||||
).toEqual({
|
||||
process: 'Android emulator',
|
||||
pid: null,
|
||||
function: 'tcp',
|
||||
action: 'blocked',
|
||||
peer: '172.253.155.190:443',
|
||||
callers: [],
|
||||
note: expect.stringContaining('the emulator itself opened'),
|
||||
});
|
||||
});
|
||||
|
||||
it('labels QUIC and compresses IPv6 destinations', () => {
|
||||
expect(
|
||||
parseFenceLogLine(
|
||||
'eas-egress IN= OUT=ens3 SRC=10.128.0.5 DST=142.250.152.94 LEN=1278 PROTO=UDP SPT=51000 DPT=443 LEN=1258'
|
||||
)
|
||||
).toMatchObject({
|
||||
function: 'udp',
|
||||
peer: '142.250.152.94:443',
|
||||
note: expect.stringContaining('QUIC'),
|
||||
});
|
||||
expect(
|
||||
parseFenceLogLine(
|
||||
'eas-egress IN= OUT=ens3 SRC=fe80:0000:0000:0000:0000:0000:0000:0001 DST=ff02:0000:0000:0000:0000:0000:0000:00fb LEN=100 TC=0 HOPLIMIT=255 FLOWLBL=0 PROTO=UDP SPT=5353 DPT=5353 LEN=60'
|
||||
)
|
||||
).toMatchObject({ function: 'udp', peer: '[ff02::fb]:5353', note: undefined });
|
||||
});
|
||||
|
||||
it('reports ICMP without a port', () => {
|
||||
expect(
|
||||
parseFenceLogLine(
|
||||
'eas-egress IN= OUT=ens3 SRC=10.128.0.5 DST=8.8.8.8 LEN=84 PROTO=ICMP TYPE=8 CODE=0 ID=7 SEQ=1'
|
||||
)
|
||||
).toMatchObject({ function: 'icmp', action: 'blocked', peer: '8.8.8.8' });
|
||||
});
|
||||
|
||||
it('ignores unrelated kernel log lines', () => {
|
||||
expect(parseFenceLogLine('kvm: some unrelated message')).toBeNull();
|
||||
expect(parseFenceLogLine('eas-egress IN= OUT=ens3')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFenceCounters, () => {
|
||||
it('reads packet counts from nft JSON output', () => {
|
||||
expect(
|
||||
parseFenceCounters(
|
||||
JSON.stringify({
|
||||
nftables: [
|
||||
{ metainfo: { version: '1.1.6' } },
|
||||
{ counter: { family: 'inet', name: 'fence_tcp', table: 'eas_fence', packets: 4 } },
|
||||
{ counter: { family: 'inet', name: 'fence_udp', table: 'eas_fence', packets: 57 } },
|
||||
{ counter: { family: 'inet', name: 'fence_other', table: 'eas_fence', packets: 2 } },
|
||||
],
|
||||
})
|
||||
)
|
||||
).toEqual({ tcp: 4, udp: 57, other: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('process coverage', () => {
|
||||
let procRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
procRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'fake-proc-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(procRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function addProcessAsync(pid: number, comm: string, cgroup: string): Promise<void> {
|
||||
await fs.promises.mkdir(path.join(procRoot, String(pid)));
|
||||
await fs.promises.writeFile(path.join(procRoot, String(pid), 'comm'), `${comm}\n`);
|
||||
await fs.promises.writeFile(path.join(procRoot, String(pid), 'cgroup'), `0::${cgroup}\n`);
|
||||
}
|
||||
|
||||
it('lists emulator processes and finds those outside the session scope', async () => {
|
||||
const scope = 'system.slice/eas-egress-session.scope';
|
||||
await addProcessAsync(10, 'qemu-system-x86', `/${scope}`);
|
||||
await addProcessAsync(11, 'netsimd', `/${scope}`);
|
||||
await addProcessAsync(12, 'netsimd', '/system.slice/eas-build-worker.service');
|
||||
await addProcessAsync(13, 'node', '/system.slice/eas-build-worker.service');
|
||||
await fs.promises.mkdir(path.join(procRoot, 'self'));
|
||||
|
||||
const processes = await listEmulatorProcessesAsync(procRoot);
|
||||
expect(processes.map(({ pid }) => pid).sort()).toEqual([10, 11, 12]);
|
||||
expect(findProcessesOutsideScope(processes, scope)).toEqual([
|
||||
{ pid: 12, comm: 'netsimd', cgroup: '/system.slice/eas-build-worker.service' },
|
||||
]);
|
||||
expect(getCgroupLevel(scope)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adb reverse parsing', () => {
|
||||
it('finds loopback ports a process listens on', () => {
|
||||
const ss = [
|
||||
'LISTEN 0 4096 127.0.0.1:8899 0.0.0.0:* users:(("chisel",pid=4321,fd=9))',
|
||||
'LISTEN 0 4096 127.0.0.1:8082 0.0.0.0:* users:(("chisel",pid=4321,fd=10))',
|
||||
'LISTEN 0 4096 127.0.0.1:5037 0.0.0.0:* users:(("adb",pid=999,fd=3))',
|
||||
'LISTEN 0 4096 127.0.0.1:52001 0.0.0.0:* users:(("chisel",pid=43210,fd=3))',
|
||||
].join('\n');
|
||||
expect(parseLoopbackListenerPorts(ss, 4321)).toEqual([8082, 8899]);
|
||||
});
|
||||
|
||||
it('parses adb reverse --list output', () => {
|
||||
expect(
|
||||
parseAdbReversePorts('emulator-5554 tcp:8082 tcp:8082\nemulator-5554 tcp:8083 tcp:8083\n')
|
||||
).toEqual(new Set([8082, 8083]));
|
||||
});
|
||||
});
|
||||
|
||||
describe(startLocalEgressRelayAsync, () => {
|
||||
async function getFreePortAsync(): Promise<number> {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const { port } = server.address() as net.AddressInfo;
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
return port;
|
||||
}
|
||||
|
||||
async function exchangeAsync(port: number, request: string): Promise<string> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const socket = net.connect({ host: '127.0.0.1', port });
|
||||
let received = '';
|
||||
socket.on('data', chunk => {
|
||||
received += chunk.toString();
|
||||
});
|
||||
socket.on('error', reject);
|
||||
socket.on('close', () => resolve(received));
|
||||
socket.write(request);
|
||||
});
|
||||
}
|
||||
|
||||
async function startUpstreamAsync(
|
||||
port: number
|
||||
): Promise<{ requests: string[]; close: () => Promise<void> }> {
|
||||
const requests: string[] = [];
|
||||
const server = net.createServer(socket => {
|
||||
socket.once('data', chunk => {
|
||||
requests.push(chunk.toString());
|
||||
socket.end('upstream reply');
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(port, '127.0.0.1', resolve));
|
||||
return {
|
||||
requests,
|
||||
close: async () => await new Promise<void>(resolve => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
it('answers the readiness CONNECT itself, even without an egress client', async () => {
|
||||
const logger = createMockLogger();
|
||||
const relay = await startLocalEgressRelayAsync({
|
||||
port: await getFreePortAsync(),
|
||||
upstreamPort: await getFreePortAsync(),
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
exchangeAsync(relay.port, 'CONNECT 192.0.2.1:443 HTTP/1.1\r\nHost: 192.0.2.1:443\r\n\r\n')
|
||||
).resolves.toBe('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await relay.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses while the client is detached and forwards once it attaches', async () => {
|
||||
const logger = createMockLogger();
|
||||
const upstreamPort = await getFreePortAsync();
|
||||
const relay = await startLocalEgressRelayAsync({
|
||||
port: await getFreePortAsync(),
|
||||
upstreamPort,
|
||||
logger,
|
||||
});
|
||||
const request = 'CONNECT 93.184.216.34:443 HTTP/1.1\r\nHost: 93.184.216.34:443\r\n\r\n';
|
||||
try {
|
||||
await expect(exchangeAsync(relay.port, request)).resolves.toMatch(
|
||||
/^HTTP\/1\.1 503 Service Unavailable\r\n[\s\S]*not connected/
|
||||
);
|
||||
await exchangeAsync(relay.port, request);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
|
||||
const upstream = await startUpstreamAsync(upstreamPort);
|
||||
try {
|
||||
await expect(exchangeAsync(relay.port, request)).resolves.toBe('upstream reply');
|
||||
expect(upstream.requests).toEqual([request]);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('2 request(s) from the Android emulator were refused')
|
||||
);
|
||||
} finally {
|
||||
await upstream.close();
|
||||
}
|
||||
} finally {
|
||||
await relay.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('unwraps a CONNECT to itself and forwards the inner request', async () => {
|
||||
const upstreamPort = await getFreePortAsync();
|
||||
const relay = await startLocalEgressRelayAsync({
|
||||
port: await getFreePortAsync(),
|
||||
upstreamPort,
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
const upstream = await startUpstreamAsync(upstreamPort);
|
||||
const inner = 'CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n';
|
||||
try {
|
||||
await expect(
|
||||
exchangeAsync(
|
||||
relay.port,
|
||||
`CONNECT 10.0.2.2:${relay.port} HTTP/1.1\r\nHost: 10.0.2.2:${relay.port}\r\n\r\n${inner}`
|
||||
)
|
||||
).resolves.toBe('HTTP/1.1 200 Connection Established\r\n\r\nupstream reply');
|
||||
expect(upstream.requests).toEqual([inner]);
|
||||
} finally {
|
||||
await upstream.close();
|
||||
await relay.stopAsync();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveAndroidEmulatorLocalEgressAsync, () => {
|
||||
let handoffPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
handoffPath = path.join(
|
||||
await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-egress-handoff-')),
|
||||
'handoff.json'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(path.dirname(handoffPath), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null without a local egress session', async () => {
|
||||
await expect(resolveAndroidEmulatorLocalEgressAsync({ handoffPath })).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('fails when a session exists but the fence was not started', async () => {
|
||||
await writeLocalEgressHandoffAsync(
|
||||
{
|
||||
url: 'https://egress.example.com',
|
||||
token: 'token',
|
||||
fingerprint: 'key=',
|
||||
port: 8899,
|
||||
platform: 'android',
|
||||
},
|
||||
handoffPath
|
||||
);
|
||||
await expect(resolveAndroidEmulatorLocalEgressAsync({ handoffPath })).rejects.toThrow(
|
||||
'the Android emulator fence was not started'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
metadata: { firstFrameRecordAt: 'test-time' },
|
||||
size: reportedSize,
|
||||
},
|
||||
})
|
||||
}),
|
||||
{ fetch: expect.any(Function) }
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/artifact',
|
||||
@@ -73,4 +74,52 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards cancellation through upload allocation and skips PUT when allocation aborts', async () => {
|
||||
const controller = new AbortController();
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => {
|
||||
controller.abort();
|
||||
return {
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
|
||||
await expect(
|
||||
uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'session',
|
||||
artifactId: 'id',
|
||||
name: 'Logs',
|
||||
filename: 'logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
size: 0,
|
||||
stream: Readable.from([]),
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
const allocationFetch = mutation.mock.calls[0][2].fetch;
|
||||
const mockFetch = jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('aborted'));
|
||||
try {
|
||||
await expect(allocationFetch('https://api.expo.test/graphql', {})).rejects.toThrow('aborted');
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/graphql',
|
||||
expect.objectContaining({
|
||||
signal: expect.objectContaining({ aborted: true }),
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
mockFetch.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { screenshotArtifactDetails } from '../deviceRunSessionScreenshotNames';
|
||||
|
||||
const timestamp = '2026-09-24T08-45-59-123Z';
|
||||
const name = 'Screenshot 2026-09-24 08:45:59 UTC';
|
||||
const gitCommitHash = 'a1b2c3d' + '0'.repeat(33);
|
||||
const session = {
|
||||
platform: 'IOS' as const,
|
||||
app: { slug: 'workflow-project' },
|
||||
build: { id: 'installed-build', gitCommitHash, app: { slug: 'my-app' } },
|
||||
};
|
||||
|
||||
it('omits the commit for archive and empty sessions', () => {
|
||||
expect(screenshotArtifactDetails(timestamp, { ...session, build: null })).toEqual({
|
||||
name,
|
||||
filename: `workflow-project-ios-${timestamp}.png`,
|
||||
metadata: { __eas_type: 'screenshot', appSlug: 'workflow-project', platform: 'ios' },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([null, 'not-a-commit'])('omits a missing or invalid build commit: %s', gitCommitHash => {
|
||||
const result = screenshotArtifactDetails(timestamp, {
|
||||
...session,
|
||||
build: { ...session.build, gitCommitHash },
|
||||
});
|
||||
expect(result.filename).toBe(`my-app-ios-${timestamp}.png`);
|
||||
expect(result.metadata.gitCommitHash).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sanitizes and bounds the app slug for filesystem-safe names', () => {
|
||||
const result = screenshotArtifactDetails(timestamp, {
|
||||
...session,
|
||||
build: { ...session.build, app: { slug: '../../My App/' + 'x'.repeat(100) } },
|
||||
});
|
||||
expect(result.filename).toMatch(/^My-App-x+-ios-a1b2c3d-/);
|
||||
expect(result.filename).not.toMatch(/[/\\]/);
|
||||
expect(result.filename.length).toBeLessThan(150);
|
||||
});
|
||||
|
||||
it('accepts a full SHA-256 commit hash', () => {
|
||||
const gitCommitHash = 'b'.repeat(64);
|
||||
const result = screenshotArtifactDetails(timestamp, {
|
||||
...session,
|
||||
build: { ...session.build, gitCommitHash },
|
||||
});
|
||||
expect(result.filename).toBe(`my-app-ios-bbbbbbb-${timestamp}.png`);
|
||||
expect(result.metadata.gitCommitHash).toBe(gitCommitHash);
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import fs from 'node:fs';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { lstat, mkdtemp, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import {
|
||||
startDeviceRunSessionScreenshotsAsync,
|
||||
uploadDeviceRunSessionScreenshotsAsync,
|
||||
} from '../deviceRunSessionScreenshots';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const options = { deviceRunSessionId: 'session-id', logger };
|
||||
let directory: string;
|
||||
let uploaded: Buffer[];
|
||||
let failedUploads: Map<string, { attempts: number; lastError: Error }>;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'screenshots-test-'));
|
||||
uploaded = [];
|
||||
failedUploads = new Map();
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, { stream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
uploaded.push(Buffer.concat(chunks));
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
for (const [, { stream }] of jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls) {
|
||||
stream.destroy();
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
const flush = () =>
|
||||
uploadDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
...options,
|
||||
directory,
|
||||
failedUploads,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
it('uploads completed captures with their bytes and stable IDs, ignoring partial files and symlinks', async () => {
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-ffffffffffff.png';
|
||||
// Sorts before the real file, so following the link would upload it before the target is gone.
|
||||
const link = 'screenshot-2026-09-24T08-45-59-123Z-000000000000.png';
|
||||
await writeFile(path.join(directory, filename), 'png-content');
|
||||
await writeFile(path.join(directory, `${filename}.tmp`), 'partial');
|
||||
await symlink(path.join(directory, filename), path.join(directory, link));
|
||||
await flush();
|
||||
await flush();
|
||||
expect(uploaded).toEqual([Buffer.from('png-content')]);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'session-id',
|
||||
artifactId: filename.slice(0, -4),
|
||||
name: 'Screenshot 2026-09-24 08:45:59 UTC',
|
||||
filename: 'screenshot-2026-09-24T08-45-59-123Z.png',
|
||||
kind: 'screenshot',
|
||||
size: 11,
|
||||
metadata: { __eas_type: 'screenshot' },
|
||||
})
|
||||
);
|
||||
expect(await readdir(directory)).not.toContain(filename);
|
||||
expect((await lstat(path.join(directory, link))).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('uploads captures that never failed first, oldest first, before retrying failed ones', async () => {
|
||||
const failed = 'screenshot-2026-09-24T08-45-00-000Z-aaaaaaaaaaaa.png';
|
||||
const newer = 'screenshot-2026-09-24T08-45-59-123Z-bbbbbbbbbbbb.png';
|
||||
const older = 'screenshot-2026-09-24T08-45-30-000Z-cccccccccccc.png';
|
||||
for (const name of [newer, failed, older]) {
|
||||
await writeFile(path.join(directory, name), name);
|
||||
}
|
||||
failedUploads.set(failed, { attempts: 1, lastError: new Error('stalled') });
|
||||
await flush();
|
||||
expect(
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, { artifactId }]) => artifactId)
|
||||
).toEqual([older, newer, failed].map(name => name.slice(0, -4)));
|
||||
});
|
||||
|
||||
it('keeps failed uploads and retries the same artifact ID, logging the failed attempt count', async () => {
|
||||
const filename = `screenshot-2026-09-24T08-45-59-123Z-${randomBytes(6).toString('hex')}.png`;
|
||||
await writeFile(path.join(directory, filename), 'png');
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
await flush();
|
||||
expect(await readdir(directory)).toContain(filename);
|
||||
await flush();
|
||||
expect(uploaded).toEqual([Buffer.from('png')]);
|
||||
expect(await readdir(directory)).toEqual([]);
|
||||
expect(
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls.map(([, args]) => args.artifactId)
|
||||
).toEqual([filename.slice(0, -4), filename.slice(0, -4)]);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringMatching(
|
||||
/^Uploaded preview screenshot screenshot-2026-09-24T08-45-59-123Z\.png \(3 B\) in \d+ ms after 1 failed attempt\.$/
|
||||
)
|
||||
);
|
||||
expect(failedUploads.size).toBe(0);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads atomic captures immediately, including a capture arriving during another upload', async () => {
|
||||
jest.useFakeTimers();
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
let releaseFirstUpload!: () => void;
|
||||
const firstUploadGate = new Promise<void>(resolve => {
|
||||
releaseFirstUpload = resolve;
|
||||
});
|
||||
let signalFirstUpload!: () => void;
|
||||
const firstUploadStarted = new Promise<void>(resolve => {
|
||||
signalFirstUpload = resolve;
|
||||
});
|
||||
let signalSecondUpload!: () => void;
|
||||
const secondUploadStarted = new Promise<void>(resolve => {
|
||||
signalSecondUpload = resolve;
|
||||
});
|
||||
const uploadNormally = jest.mocked(uploadDeviceRunSessionArtifactAsync).getMockImplementation();
|
||||
if (!uploadNormally) {
|
||||
throw new Error('Expected the default upload implementation.');
|
||||
}
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (...args) => {
|
||||
signalFirstUpload();
|
||||
await firstUploadGate;
|
||||
await uploadNormally(...args);
|
||||
})
|
||||
.mockImplementationOnce(async (...args) => {
|
||||
signalSecondUpload();
|
||||
await uploadNormally(...args);
|
||||
});
|
||||
const firstFile = path.join(
|
||||
collector.directory,
|
||||
'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png'
|
||||
);
|
||||
const secondFile = path.join(
|
||||
collector.directory,
|
||||
'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.png'
|
||||
);
|
||||
try {
|
||||
await writeFile(`${firstFile}.tmp`, 'first-capture');
|
||||
await rename(`${firstFile}.tmp`, firstFile);
|
||||
await firstUploadStarted;
|
||||
await writeFile(`${secondFile}.tmp`, 'second-capture');
|
||||
await rename(`${secondFile}.tmp`, secondFile);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
|
||||
releaseFirstUpload();
|
||||
await secondUploadStarted;
|
||||
await collector.finishAsync(true);
|
||||
expect(uploaded).toEqual([Buffer.from('first-capture'), Buffer.from('second-capture')]);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
releaseFirstUpload();
|
||||
jest.useRealTimers();
|
||||
await collector.finishAsync(true);
|
||||
await rm(collector.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses periodic scans when watching is unavailable and does not overlap uploads', async () => {
|
||||
jest.useFakeTimers();
|
||||
const watchSpy = jest.spyOn(fs, 'watch').mockImplementation(() => {
|
||||
throw new Error('Watching unavailable');
|
||||
});
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
let releaseUpload!: () => void;
|
||||
const uploadGate = new Promise<void>(resolve => {
|
||||
releaseUpload = resolve;
|
||||
});
|
||||
let signalUploadStarted!: () => void;
|
||||
const uploadStarted = new Promise<void>(resolve => {
|
||||
signalUploadStarted = resolve;
|
||||
});
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(collector.directory, 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png'),
|
||||
'live-capture'
|
||||
);
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementationOnce(async () => {
|
||||
signalUploadStarted();
|
||||
await uploadGate;
|
||||
});
|
||||
jest.advanceTimersByTime(30_000);
|
||||
await uploadStarted;
|
||||
jest.advanceTimersByTime(60_000);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
|
||||
releaseUpload();
|
||||
await collector.finishAsync(true);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
releaseUpload();
|
||||
watchSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
await collector.finishAsync(true);
|
||||
await rm(collector.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('warns for the first three attempts and every tenth after, without reporting to Sentry', async () => {
|
||||
const filename = `screenshot-2026-09-24T08-45-59-123Z-${randomBytes(6).toString('hex')}.png`;
|
||||
await writeFile(path.join(directory, filename), 'png');
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockRejectedValue(new Error('offline'));
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await flush();
|
||||
}
|
||||
expect(jest.mocked(logger.warn).mock.calls.map(([fields]) => fields.attempt)).toEqual([
|
||||
1, 2, 3, 10,
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ file: path.join(directory, filename), attempt: 1, size: 3 }),
|
||||
'Could not upload preview screenshot (attempt 1). Keeping it for retry.'
|
||||
);
|
||||
expect(logger.warn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ attempt: 2 }),
|
||||
'Could not upload preview screenshot (attempt 2). Keeping it for retry.'
|
||||
);
|
||||
expect(failedUploads.get(filename)).toEqual({
|
||||
attempts: 12,
|
||||
lastError: expect.objectContaining({ message: 'offline' }),
|
||||
});
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes an empty directory on finish and returns the same task when finish repeats', async () => {
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
const finish = collector.finishAsync(true);
|
||||
expect(collector.finishAsync(true)).toBe(finish);
|
||||
await finish;
|
||||
await expect(readdir(collector.directory)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(logger.info).toHaveBeenCalledWith('Uploaded 0 preview screenshots during the session.');
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the directory and warns when the session host is still running', async () => {
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
try {
|
||||
await collector.finishAsync(false);
|
||||
expect(await readdir(collector.directory)).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ directory: collector.directory },
|
||||
'The session host is still running, so preview screenshots it saves from now on are not uploaded.'
|
||||
);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(collector.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('warns with the files not uploaded and their last error, and reports them to Sentry once', async () => {
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png';
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockRejectedValue(new Error('offline'));
|
||||
try {
|
||||
await writeFile(path.join(collector.directory, filename), 'png');
|
||||
await collector.finishAsync(true);
|
||||
const notUploaded = [{ name: filename, attempts: expect.any(Number), lastError: 'offline' }];
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ directory: collector.directory, files: notUploaded },
|
||||
'1 preview screenshot was not uploaded.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Preview screenshots were not uploaded',
|
||||
expect.objectContaining({
|
||||
message: '1 preview screenshot was not uploaded.',
|
||||
}),
|
||||
{ extras: { files: notUploaded } }
|
||||
);
|
||||
} finally {
|
||||
await rm(collector.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a screenshot as not uploaded when the shutdown deadline cuts off its upload', async () => {
|
||||
jest.useFakeTimers();
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png';
|
||||
const file = path.join(collector.directory, filename);
|
||||
let signalUploadStarted!: () => void;
|
||||
const uploadStarted = new Promise<void>(resolve => {
|
||||
signalUploadStarted = resolve;
|
||||
});
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_ctx, { signal }) => {
|
||||
signalUploadStarted();
|
||||
await new Promise((_resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => reject(signal.reason));
|
||||
});
|
||||
});
|
||||
try {
|
||||
await writeFile(file, 'png');
|
||||
const finish = collector.finishAsync(true);
|
||||
await uploadStarted;
|
||||
jest.advanceTimersByTime(30_000);
|
||||
await finish;
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ directory: collector.directory },
|
||||
'Preview screenshot flush timed out after 30 s.'
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ file, size: 3 },
|
||||
'Shutdown deadline reached before this preview screenshot uploaded. Keeping the file.'
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ directory: collector.directory, files: [{ name: filename }] },
|
||||
'1 preview screenshot was not uploaded.'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Uploaded 0 preview screenshots during the session.');
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(await readdir(collector.directory)).toEqual([filename]);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
await rm(collector.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a failure record once and deletes it while uploading captures from the same scan', async () => {
|
||||
const png = 'screenshot-2026-09-24T08-45-59-123Z-aaaaaaaaaaaa.png';
|
||||
const failed = 'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.png';
|
||||
const record = { file: failed, error: 'ENOSPC: no space left', at: '2026-09-24T08:45:59.124Z' };
|
||||
await writeFile(path.join(directory, png), 'png');
|
||||
await writeFile(
|
||||
path.join(directory, 'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.failed.json'),
|
||||
JSON.stringify(record)
|
||||
);
|
||||
expect(await flush()).toEqual({ uploaded: 1, saveFailures: 1 });
|
||||
expect(await flush()).toEqual({ uploaded: 0, saveFailures: 0 });
|
||||
expect(uploaded).toEqual([Buffer.from('png')]);
|
||||
expect(await readdir(directory)).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
record,
|
||||
'The session host could not save a preview screenshot.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'The session host could not save a preview screenshot',
|
||||
expect.objectContaining({ message: 'ENOSPC: no space left' }),
|
||||
{ extras: { file: failed, at: record.at } }
|
||||
);
|
||||
});
|
||||
|
||||
it('warns about a malformed failure record, deletes it, and does not report it to Sentry', async () => {
|
||||
const file = path.join(directory, 'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.failed.json');
|
||||
await writeFile(file, JSON.stringify({ file: 'screenshot.png' }));
|
||||
await flush();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.anything(), file },
|
||||
'Could not read a preview screenshot failure record.'
|
||||
);
|
||||
expect(await readdir(directory)).toEqual([]);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles a failure record as soon as it appears and counts it on finish', async () => {
|
||||
jest.useFakeTimers();
|
||||
const collector = await startDeviceRunSessionScreenshotsAsync(ctx, options);
|
||||
let signalReported!: () => void;
|
||||
const reported = new Promise<void>(resolve => {
|
||||
signalReported = resolve;
|
||||
});
|
||||
jest.mocked(Sentry.capture).mockImplementationOnce(() => signalReported());
|
||||
const record = path.join(
|
||||
collector.directory,
|
||||
'screenshot-2026-09-24T08-45-59-124Z-bbbbbbbbbbbb.failed.json'
|
||||
);
|
||||
try {
|
||||
await writeFile(`${record}.tmp`, JSON.stringify({ file: 'x.png', error: 'EIO', at: 'now' }));
|
||||
await rename(`${record}.tmp`, record);
|
||||
await reported;
|
||||
await collector.finishAsync(true);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Uploaded 0 preview screenshots during the session; the session host could not save 1.'
|
||||
);
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
await collector.finishAsync(true);
|
||||
await rm(collector.directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
loadScreenshotSessionAsync,
|
||||
screenshotArtifactDetails,
|
||||
} from '../deviceRunSessionScreenshotNames';
|
||||
import { uploadDeviceRunSessionScreenshotsAsync } from '../deviceRunSessionScreenshots';
|
||||
jest.unmock('node-fetch');
|
||||
jest.mock('../../../sentry');
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(r => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let handle: (request: IncomingMessage, response: ServerResponse) => void;
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creates: number;
|
||||
let authorization: string | undefined;
|
||||
let stallCreation: boolean;
|
||||
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
|
||||
let creationBodies: string[];
|
||||
const installedCommit = 'a1b2c3d' + '0'.repeat(33);
|
||||
let sessionLookupFails: boolean;
|
||||
let sessionLookups: number;
|
||||
let uploadQuery: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
creates = 0;
|
||||
sessionLookups = 0;
|
||||
sessionLookupFails = false;
|
||||
uploadQuery = '';
|
||||
authorization = undefined;
|
||||
stallCreation = false;
|
||||
creationBodies = [];
|
||||
creationStarted = deferred();
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url?.startsWith('/graphql')) {
|
||||
authorization = request.headers.authorization;
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
if (request.method === 'GET') {
|
||||
const requestUrl = new URL(request.url, 'http://localhost');
|
||||
body = JSON.stringify({ query: requestUrl.searchParams.get('query') });
|
||||
}
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => {
|
||||
const operation = JSON.parse(body);
|
||||
if (operation.query.includes('query ScreenshotSession')) {
|
||||
sessionLookups++;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
if (sessionLookupFails) {
|
||||
response.end(JSON.stringify({ errors: [{ message: 'Lookup unavailable' }] }));
|
||||
} else {
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSessions: {
|
||||
byId: {
|
||||
platform: 'IOS',
|
||||
app: { slug: 'session-project' },
|
||||
build: {
|
||||
id: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
app: { slug: 'my-app' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
creates++;
|
||||
creationBodies.push(body);
|
||||
creationStarted.resolve(request);
|
||||
if (stallCreation) {
|
||||
return;
|
||||
}
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
handle(request, response);
|
||||
}
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Missing test server address');
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload path', async () => {
|
||||
const payload = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
|
||||
await writeFile(path.join(directory, filename), payload);
|
||||
const bodies: Buffer[] = [];
|
||||
handle = (request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
||||
request.on('end', () => {
|
||||
bodies.push(Buffer.concat(chunks));
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
const session = loadScreenshotSessionAsync(ctx, 'drs-id', {
|
||||
warn: jest.fn(),
|
||||
} as unknown as bunyan);
|
||||
await uploadDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
session,
|
||||
directory,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
failedUploads: new Map(),
|
||||
logger: { info: jest.fn(), warn: jest.fn() } as unknown as bunyan,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(bodies).toEqual([payload]);
|
||||
expect(creates).toBe(1);
|
||||
expect(sessionLookups).toBe(1);
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(JSON.parse(creationBodies[0]).variables).toEqual(
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'drs-id',
|
||||
input: expect.objectContaining({
|
||||
name: 'Screenshot 2026-09-24 08:45:59 UTC',
|
||||
filename: 'my-app-ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
|
||||
kind: 'screenshot',
|
||||
size: payload.length,
|
||||
metadata: {
|
||||
__eas_type: 'screenshot',
|
||||
appSlug: 'my-app',
|
||||
platform: 'ios',
|
||||
buildId: 'installed-build',
|
||||
gitCommitHash: installedCommit,
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('rereads the PNG when the artifact PUT is retried', async () => {
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
|
||||
await writeFile(path.join(directory, filename), 'retried-capture');
|
||||
const bodies: string[] = [];
|
||||
handle = (request, response) => {
|
||||
let body = '';
|
||||
request.setEncoding('utf8');
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => {
|
||||
bodies.push(body);
|
||||
response.statusCode = bodies.length === 1 ? 503 : 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
const uploaded = await uploadDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
directory,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
failedUploads: new Map(),
|
||||
logger: { info: jest.fn(), warn: jest.fn() } as unknown as bunyan,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(uploaded).toEqual({ uploaded: 1, saveFailures: 0 });
|
||||
expect(creates).toBe(1);
|
||||
expect(bodies).toEqual(['retried-capture', 'retried-capture']);
|
||||
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('aborts a stalled GraphQL upload request and keeps the PNG', async () => {
|
||||
stallCreation = true;
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
|
||||
await writeFile(path.join(directory, filename), 'png');
|
||||
const controller = new AbortController();
|
||||
const upload = uploadDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
directory,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
failedUploads: new Map(),
|
||||
logger: { info: jest.fn(), warn: jest.fn() } as unknown as bunyan,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await creationStarted.promise;
|
||||
controller.abort();
|
||||
await upload;
|
||||
expect((await stat(path.join(directory, filename))).size).toBe(3);
|
||||
});
|
||||
|
||||
it('keeps the signed upload URL out of the error when the upload connection drops', async () => {
|
||||
uploadQuery = '?X-Goog-Signature=secret-signature';
|
||||
handle = request => request.socket.destroy();
|
||||
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
|
||||
await writeFile(path.join(directory, filename), 'png');
|
||||
const failedUploads = new Map<string, { attempts: number; lastError: Error }>();
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
await uploadDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
directory,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
failedUploads,
|
||||
logger,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
const error = failedUploads.get(filename)?.lastError;
|
||||
expect(error?.message).toBe(
|
||||
`Failed to upload device run session artifact ${filename.slice(0, -4)}: ECONNRESET.`
|
||||
);
|
||||
expect(error?.cause).toBeUndefined();
|
||||
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('secret-signature');
|
||||
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('/artifact');
|
||||
});
|
||||
|
||||
it('falls back without using the workflow commit when the session lookup fails', async () => {
|
||||
sessionLookupFails = true;
|
||||
ctx.metadata = { appName: 'wrong-app', gitCommitHash: 'f'.repeat(40) };
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
const session = await loadScreenshotSessionAsync(ctx, 'drs-id', logger);
|
||||
expect(session).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deviceRunSessionId: 'drs-id' }),
|
||||
'Could not load screenshot session details; using capture timestamps.'
|
||||
);
|
||||
expect(screenshotArtifactDetails('2026-09-24T08-45-59-123Z', session)).toEqual({
|
||||
name: 'Screenshot 2026-09-24 08:45:59 UTC',
|
||||
filename: 'screenshot-2026-09-24T08-45-59-123Z.png',
|
||||
metadata: { __eas_type: 'screenshot' },
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
DeviceRunSessionTimeoutError,
|
||||
withDeviceRunSessionTimeoutAsync,
|
||||
} from '../deviceRunSessionTimeout';
|
||||
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('aborts a stalled operation when its deadline expires', async () => {
|
||||
jest.useFakeTimers();
|
||||
let signal: AbortSignal | undefined;
|
||||
const operation = withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'test operation', timeoutMs: 100 },
|
||||
async value => {
|
||||
signal = value;
|
||||
return await new Promise<void>(() => {});
|
||||
}
|
||||
);
|
||||
const rejected = expect(operation).rejects.toThrow('test operation timed out after 100ms');
|
||||
const typed = expect(operation).rejects.toBeInstanceOf(DeviceRunSessionTimeoutError);
|
||||
await jest.advanceTimersByTimeAsync(100);
|
||||
await rejected;
|
||||
await typed;
|
||||
expect(signal?.aborted).toBe(true);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not start an operation with an already aborted parent', async () => {
|
||||
const operation = jest.fn();
|
||||
await expect(
|
||||
withDeviceRunSessionTimeoutAsync(
|
||||
{
|
||||
name: 'test',
|
||||
timeoutMs: 100,
|
||||
signal: AbortSignal.abort(new Error('parent ended')),
|
||||
},
|
||||
operation
|
||||
)
|
||||
).rejects.toThrow('parent ended');
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the deadline when an operation succeeds', async () => {
|
||||
jest.useFakeTimers();
|
||||
await expect(
|
||||
withDeviceRunSessionTimeoutAsync({ name: 'test', timeoutMs: 100 }, async () => 42)
|
||||
).resolves.toBe(42);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a deadline reset that arrives after the operation settled', async () => {
|
||||
jest.useFakeTimers();
|
||||
let resetDeadline!: () => void;
|
||||
await expect(
|
||||
withDeviceRunSessionTimeoutAsync({ name: 'test', timeoutMs: 100 }, async (_signal, reset) => {
|
||||
resetDeadline = reset;
|
||||
return 42;
|
||||
})
|
||||
).resolves.toBe(42);
|
||||
resetDeadline();
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps an operation alive while it reports progress', async () => {
|
||||
jest.useFakeTimers();
|
||||
const operation = withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'test operation', timeoutMs: 100 },
|
||||
async (signal, resetDeadline) => {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 80));
|
||||
resetDeadline();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 80));
|
||||
resetDeadline();
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 80));
|
||||
return signal.aborted;
|
||||
}
|
||||
);
|
||||
const settled = expect(operation).resolves.toBe(false);
|
||||
await jest.advanceTimersByTimeAsync(240);
|
||||
await settled;
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(r => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'recording-id',
|
||||
name: 'Recording',
|
||||
filename: 'recording.mp4',
|
||||
kind: 'screen-recording',
|
||||
size: 16,
|
||||
};
|
||||
let handle: (request: IncomingMessage, response: ServerResponse) => void;
|
||||
let server: ReturnType<typeof createServer>;
|
||||
let ctx: CustomBuildContext;
|
||||
let url: string;
|
||||
let directory: string;
|
||||
let creates: number;
|
||||
let authorization: string | undefined;
|
||||
let stallCreation: boolean;
|
||||
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
|
||||
let creationBodies: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
creates = 0;
|
||||
authorization = undefined;
|
||||
stallCreation = false;
|
||||
creationBodies = [];
|
||||
creationStarted = deferred();
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
|
||||
server = createServer((request, response) => {
|
||||
if (request.url === '/graphql') {
|
||||
creates++;
|
||||
authorization = request.headers.authorization;
|
||||
creationStarted.resolve(request);
|
||||
request.setEncoding('utf8');
|
||||
let body = '';
|
||||
request.on('data', chunk => (body += chunk));
|
||||
request.on('end', () => creationBodies.push(body));
|
||||
request.on('end', () => {
|
||||
if (stallCreation) {
|
||||
return;
|
||||
}
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: { url: `${url}/artifact`, headers: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
} else {
|
||||
handle(request, response);
|
||||
}
|
||||
});
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Missing test server address');
|
||||
}
|
||||
url = `http://127.0.0.1:${address.port}`;
|
||||
ctx = {
|
||||
graphqlClient: new Client({
|
||||
url: `${url}/graphql`,
|
||||
exchanges: [fetchExchange],
|
||||
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
|
||||
}),
|
||||
} as CustomBuildContext;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('replays the full file after a transient PUT failure without recreating the upload session', async () => {
|
||||
const bodies: string[] = [];
|
||||
handle = (request, response) => {
|
||||
const chunks: string[] = [];
|
||||
request.setEncoding('utf8');
|
||||
request.on('data', chunk => chunks.push(chunk));
|
||||
request.on('end', () => {
|
||||
bodies.push(chunks.join(''));
|
||||
response.statusCode = bodies.length === 1 ? 503 : 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
const file = path.join(directory, 'recording.mp4');
|
||||
const payload = '*'.repeat(128 * 1024);
|
||||
await writeFile(file, payload);
|
||||
const streams: Readable[] = [];
|
||||
const open = () => {
|
||||
const stream = createReadStream(file);
|
||||
streams.push(stream);
|
||||
return stream;
|
||||
};
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
size: payload.length,
|
||||
stream: open(),
|
||||
reopenStream: open,
|
||||
});
|
||||
expect(creates).toBe(1);
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(bodies).toEqual([payload, payload]);
|
||||
expect(streams).toHaveLength(2);
|
||||
expect(streams.every(stream => stream.destroyed)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a slow PUT alive while its body is still flowing', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
let received = 0;
|
||||
let onChunk = () => {};
|
||||
handle = (request, response) => {
|
||||
request.on('data', chunk => {
|
||||
received += chunk.length;
|
||||
onChunk();
|
||||
});
|
||||
request.on('end', () => {
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
const waitForBytes = (total: number) =>
|
||||
new Promise<void>(resolve => {
|
||||
onChunk = () => {
|
||||
if (received >= total) {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
onChunk();
|
||||
});
|
||||
const stream = new Readable({ read() {} });
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, { ...metadata, size: 3, stream });
|
||||
const settled = expect(upload).resolves.toBeUndefined();
|
||||
stream.push('a');
|
||||
await waitForBytes(1);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
stream.push('b');
|
||||
await waitForBytes(2);
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
stream.push('c');
|
||||
stream.push(null);
|
||||
await settled;
|
||||
expect(received).toBe(3);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('retries a PUT that stalled past the idle deadline with a fresh stream', async () => {
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
let puts = 0;
|
||||
const firstBodyRead = deferred<void>();
|
||||
handle = (request, response) => {
|
||||
puts++;
|
||||
request.resume();
|
||||
request.on('end', () => {
|
||||
if (puts === 1) {
|
||||
firstBodyRead.resolve();
|
||||
return;
|
||||
}
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
const streams: Readable[] = [];
|
||||
const open = () => {
|
||||
const stream = Readable.from('recording');
|
||||
streams.push(stream);
|
||||
return stream;
|
||||
};
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
size: 9,
|
||||
stream: open(),
|
||||
reopenStream: open,
|
||||
});
|
||||
const settled = expect(upload).resolves.toBeUndefined();
|
||||
await firstBodyRead.promise;
|
||||
await jest.advanceTimersByTimeAsync(90_000);
|
||||
await jest.advanceTimersByTimeAsync(500);
|
||||
await settled;
|
||||
expect(puts).toBe(2);
|
||||
expect(streams).toHaveLength(2);
|
||||
expect(streams.every(stream => stream.destroyed)).toBe(true);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('cancels an active PUT socket and destroys its source without retrying', async () => {
|
||||
const started = deferred<void>();
|
||||
const closed = deferred<void>();
|
||||
let puts = 0;
|
||||
handle = request => {
|
||||
puts++;
|
||||
request.socket.once('close', () => closed.resolve());
|
||||
request.on('data', () => started.resolve());
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.push(Buffer.alloc(1024));
|
||||
const reopenStream = jest.fn(() => Readable.from('unexpected retry'));
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream,
|
||||
reopenStream,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejected = expect(upload).rejects.toThrow('test cancellation');
|
||||
await started.promise;
|
||||
controller.abort(new Error('test cancellation'));
|
||||
await rejected;
|
||||
await closed.promise;
|
||||
expect(stream.destroyed).toBe(true);
|
||||
expect(reopenStream).not.toHaveBeenCalled();
|
||||
expect(puts).toBe(1);
|
||||
});
|
||||
|
||||
it.each([200, 400, 503])(
|
||||
'closes a stalled HTTP %s response before leaving or retrying the PUT',
|
||||
async status => {
|
||||
const sockets = new Set<Socket>();
|
||||
let puts = 0;
|
||||
let maxOpenSockets = 0;
|
||||
handle = (request, response) => {
|
||||
puts++;
|
||||
sockets.add(request.socket);
|
||||
maxOpenSockets = Math.max(maxOpenSockets, sockets.size);
|
||||
request.socket.once('close', () => sockets.delete(request.socket));
|
||||
request.resume();
|
||||
request.on('end', () => {
|
||||
response.writeHead(status, { 'Content-Length': '1000' });
|
||||
response.flushHeaders();
|
||||
response.write('partial response');
|
||||
});
|
||||
};
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream: Readable.from('recording'),
|
||||
reopenStream: () => Readable.from('recording'),
|
||||
});
|
||||
if (status === 200) {
|
||||
await expect(upload).resolves.toBeUndefined();
|
||||
} else {
|
||||
await expect(upload).rejects.toThrow(`HTTP ${status}`);
|
||||
}
|
||||
for (let i = 0; i < 100 && sockets.size > 0; i++) {
|
||||
await delay(10);
|
||||
}
|
||||
expect(creates).toBe(1);
|
||||
expect(puts).toBe(status === 503 ? 3 : 1);
|
||||
expect(maxOpenSockets).toBe(1);
|
||||
expect(sockets.size).toBe(0);
|
||||
}
|
||||
);
|
||||
|
||||
it('cancels urql session creation with auth intact and never starts a PUT', async () => {
|
||||
stallCreation = true;
|
||||
const puts = jest.fn();
|
||||
handle = puts;
|
||||
const controller = new AbortController();
|
||||
const stream = Readable.from('recording');
|
||||
const upload = uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
...metadata,
|
||||
stream,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejected = expect(upload).rejects.toThrow('test cancellation');
|
||||
const request = await creationStarted.promise;
|
||||
const closed = new Promise<void>(resolve => request.socket.once('close', resolve));
|
||||
controller.abort(new Error('test cancellation'));
|
||||
await rejected;
|
||||
await closed;
|
||||
expect(authorization).toBe('Bearer test-auth');
|
||||
expect(creates).toBe(1);
|
||||
expect(puts).not.toHaveBeenCalled();
|
||||
expect(stream.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it('says ffprobe is missing instead of calling an unlisted recording undecodable', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const session = path.join(directory, 'session');
|
||||
await mkdir(session);
|
||||
await writeFile(path.join(session, 'recording.mp4.partial'), new Uint8Array(28));
|
||||
await writeFile(
|
||||
path.join(session, 'session.json'),
|
||||
JSON.stringify({
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
status: 'recording',
|
||||
recording: 'recording.mp4.partial',
|
||||
})
|
||||
);
|
||||
|
||||
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
|
||||
root: directory,
|
||||
env: { ...process.env, PATH: path.join(directory, 'no-tools') } as BuildStepEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(recordings).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'ffprobe is not available; cannot recover the unlisted recording session/recording.mp4.partial.'
|
||||
);
|
||||
});
|
||||
|
||||
it('recovers unlisted recordings from a killed host and flags the unfinished ones as partial', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const env = process.env as BuildStepEnv;
|
||||
const cut = path.join(directory, 'cut');
|
||||
const empty = path.join(directory, 'empty');
|
||||
const failed = path.join(directory, 'failed');
|
||||
const done = path.join(directory, 'done');
|
||||
await mkdir(cut);
|
||||
await mkdir(empty);
|
||||
await mkdir(failed);
|
||||
await mkdir(done);
|
||||
const manifest = {
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
status: 'recording',
|
||||
recording: 'recording.mp4.partial',
|
||||
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
|
||||
width: 128,
|
||||
height: 96,
|
||||
};
|
||||
execFileSync('ffmpeg', [
|
||||
'-v',
|
||||
'error',
|
||||
'-f',
|
||||
'lavfi',
|
||||
'-i',
|
||||
'color=c=red:s=128x96:r=1',
|
||||
'-t',
|
||||
'3',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-g',
|
||||
'1',
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-movflags',
|
||||
'frag_keyframe+empty_moov',
|
||||
'-f',
|
||||
'mp4',
|
||||
path.join(cut, 'recording.mp4.partial'),
|
||||
]);
|
||||
await writeFile(path.join(cut, 'session.json'), JSON.stringify(manifest));
|
||||
await writeFile(path.join(empty, 'recording.mp4.partial'), new Uint8Array(28));
|
||||
await writeFile(path.join(empty, 'session.json'), JSON.stringify(manifest));
|
||||
await copyFile(
|
||||
path.join(cut, 'recording.mp4.partial'),
|
||||
path.join(failed, 'recording.mp4.partial')
|
||||
);
|
||||
await writeFile(
|
||||
path.join(failed, 'session.json'),
|
||||
JSON.stringify({
|
||||
...manifest,
|
||||
status: 'failed',
|
||||
error: 'Recording finalization exceeded 30000 ms.',
|
||||
})
|
||||
);
|
||||
|
||||
await copyFile(path.join(cut, 'recording.mp4.partial'), path.join(done, 'recording.mp4'));
|
||||
await writeFile(
|
||||
path.join(done, 'session.json'),
|
||||
JSON.stringify({ ...manifest, status: 'complete', recording: 'recording.mp4' })
|
||||
);
|
||||
|
||||
const recordings = await findUnlistedDeviceScreenRecordingsAsync({
|
||||
root: directory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
expect(recordings).toEqual([
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: cut,
|
||||
},
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: done,
|
||||
},
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: failed,
|
||||
},
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
|
||||
|
||||
let putBytes = 0;
|
||||
handle = (request, response) => {
|
||||
request.on('data', chunk => (putBytes += chunk.length));
|
||||
request.on('end', () => {
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
};
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
recordings,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
|
||||
const inputs = creationBodies
|
||||
.map(body => JSON.parse(body).variables.input)
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(inputs.map(input => input.filename)).toEqual(['cut.mp4', 'done.mp4', 'failed.mp4']);
|
||||
expect(inputs.map(input => input.name.includes(', partial)'))).toEqual([true, false, true]);
|
||||
expect(inputs[1].metadata).not.toHaveProperty('partial');
|
||||
expect(inputs[0].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'The Device Hub stopped before it could finalize the recording.',
|
||||
width: 128,
|
||||
height: 96,
|
||||
});
|
||||
expect(inputs[2].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'Recording finalization exceeded 30000 ms.',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { access, mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from '../deviceRunSessionScreenRecordings';
|
||||
import { startDeviceSessionHostAsync } from '../deviceSessionHost';
|
||||
import { spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../serveSimMetricsRecorder', () => ({
|
||||
readServeSimServersAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ udid: 'emulator-5554', token: 'preview-token' }]),
|
||||
}));
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
fetchWebPreviewTurnArgsAsync: jest.fn().mockResolvedValue([]),
|
||||
spawnDetached: jest.fn(),
|
||||
}));
|
||||
jest.mock('../deviceRunSessionScreenRecordings', () => ({
|
||||
...jest.requireActual('../deviceRunSessionScreenRecordings'),
|
||||
findUnlistedDeviceScreenRecordingsAsync: jest.fn(),
|
||||
uploadDeviceRunSessionScreenRecordingsAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(r => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const env = {
|
||||
DEVICE_RUN_SESSION_ID: 'drs-id',
|
||||
NGROK_AUTHTOKEN: 'token',
|
||||
} as BuildStepEnv;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const stopServer = jest.fn();
|
||||
const closeTunnel = jest.fn();
|
||||
const directories: string[] = [];
|
||||
const baseDomain = 'preview.example.test';
|
||||
|
||||
async function startHostAsync() {
|
||||
return await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
stopServer.mockResolvedValue(undefined);
|
||||
closeTunnel.mockResolvedValue(undefined);
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset().mockResolvedValue(false);
|
||||
jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockReset().mockResolvedValue([]);
|
||||
jest.mocked(spawnDetached).mockImplementation(options => {
|
||||
const flag = options.args.indexOf('--android-recording-directory');
|
||||
if (flag >= 0) {
|
||||
directories.push(options.args[flag + 1]);
|
||||
}
|
||||
return {
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
};
|
||||
});
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://public.example.test',
|
||||
close: closeTunnel,
|
||||
} as never);
|
||||
jest.mocked(turtleFetch).mockImplementation(
|
||||
async () =>
|
||||
({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ready', device: 'emulator-5554' }),
|
||||
}) as Awaited<ReturnType<typeof turtleFetch>>
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.useRealTimers();
|
||||
await Promise.all(
|
||||
directories.splice(0).map(directory => rm(directory, { recursive: true, force: true }))
|
||||
);
|
||||
});
|
||||
|
||||
it('records by default with no preview and finalizes before process stop, uploading exactly once', async () => {
|
||||
const host = await startHostAsync();
|
||||
expect(ngrok.forward).not.toHaveBeenCalled();
|
||||
const directory = directories[0];
|
||||
const recordings = [
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android 16',
|
||||
directory: path.join(directory, 'session'),
|
||||
},
|
||||
];
|
||||
await writeFile(path.join(directory, 'recordings.json'), JSON.stringify(recordings));
|
||||
await mkdir(recordings[0].directory);
|
||||
const started = deferred<void>();
|
||||
const response = deferred<Awaited<ReturnType<typeof turtleFetch>>>();
|
||||
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
|
||||
started.resolve();
|
||||
return await response.promise;
|
||||
});
|
||||
const finishing = host.finishAsync();
|
||||
expect(host.finishAsync()).toBe(finishing);
|
||||
await started.promise;
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
const token =
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN;
|
||||
expect(token).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(turtleFetch).toHaveBeenLastCalledWith(
|
||||
expect.stringMatching(/\/_eas\/android-recording\/stop$/),
|
||||
'POST',
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
signal: expect.any(AbortSignal),
|
||||
}
|
||||
);
|
||||
response.resolve({ ok: true } as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
await finishing;
|
||||
await host.finishAsync();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledWith(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
recordings,
|
||||
});
|
||||
expect(stopServer.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow(
|
||||
'after session host finalization'
|
||||
);
|
||||
});
|
||||
|
||||
it('coalesces preview opens and allows close/reopen without ending capture', async () => {
|
||||
const host = await startHostAsync();
|
||||
const opening = host.openPreviewAsync({ baseDomain });
|
||||
expect(host.openPreviewAsync({ baseDomain })).toBe(opening);
|
||||
const first = await opening;
|
||||
const closing = first.closeAsync();
|
||||
expect(first.closeAsync()).toBe(closing);
|
||||
await closing;
|
||||
const secondClose = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValueOnce({
|
||||
url: () => 'https://replacement.example.test',
|
||||
close: secondClose,
|
||||
} as never);
|
||||
const second = await host.openPreviewAsync({ baseDomain });
|
||||
await first.closeAsync();
|
||||
expect(await host.openPreviewAsync({ baseDomain })).toBe(second);
|
||||
expect(secondClose).not.toHaveBeenCalled();
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
|
||||
await host.finishAsync();
|
||||
expect(closeTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(secondClose).toHaveBeenCalledTimes(1);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries a failed tunnel without restarting the host', async () => {
|
||||
const host = await startHostAsync();
|
||||
jest.mocked(ngrok.forward).mockRejectedValueOnce(new Error('tunnel unavailable'));
|
||||
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow('tunnel unavailable');
|
||||
expect(stopServer).not.toHaveBeenCalled();
|
||||
await host.openPreviewAsync({ baseDomain });
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
await host.finishAsync();
|
||||
expect(closeTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drains an in-flight tunnel when finishing and rejects new opens', async () => {
|
||||
const host = await startHostAsync();
|
||||
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
|
||||
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
|
||||
const opening = host.openPreviewAsync({ baseDomain });
|
||||
const finishing = host.finishAsync();
|
||||
await expect(host.openPreviewAsync({ baseDomain })).rejects.toThrow(
|
||||
'after session host finalization'
|
||||
);
|
||||
tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never);
|
||||
await expect(opening).rejects.toThrow('finalized while the preview was opening');
|
||||
await finishing;
|
||||
expect(closeTunnel).toHaveBeenCalledTimes(1);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps cleanup and upload best-effort when tunnel close and finalization fail', async () => {
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
|
||||
return {
|
||||
pid: undefined,
|
||||
getOutput: () => '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
};
|
||||
});
|
||||
const host = await startHostAsync();
|
||||
await writeFile(path.join(directories[0], 'recordings.json'), '[]');
|
||||
await host.openPreviewAsync({ baseDomain });
|
||||
closeTunnel.mockRejectedValueOnce(new Error('tunnel close failure'));
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Error: scrcpy exited with code 255',
|
||||
} as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
await host.finishAsync();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{
|
||||
err: expect.objectContaining({
|
||||
message: expect.stringContaining('HTTP 500: Error: scrcpy exited with code 255'),
|
||||
}),
|
||||
},
|
||||
'Could not finalize Android recording before shutdown.'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not finalize Android recording before shutdown',
|
||||
expect.any(Error),
|
||||
{ level: 'warning' }
|
||||
);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ hostOutput: '[serve-emu] emulator-5554 capture error: scrcpy exited with code 255' },
|
||||
'Session host output around the recording failure.'
|
||||
);
|
||||
});
|
||||
|
||||
it('logs the reason and skips upload and output dump when the Hub never recorded', async () => {
|
||||
jest.mocked(spawnDetached).mockImplementationOnce(options => {
|
||||
directories.push(options.args[options.args.indexOf('--android-recording-directory') + 1]);
|
||||
return {
|
||||
pid: undefined,
|
||||
getOutput: () => '[serve-emu] Android recording skipped',
|
||||
getExitError: () => undefined,
|
||||
stopAsync: stopServer,
|
||||
};
|
||||
});
|
||||
const host = await startHostAsync();
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 409,
|
||||
text: async () => 'Android recording requires exactly one booted emulator; found 0.',
|
||||
} as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
await host.finishAsync();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Android recording was not captured: Android recording requires exactly one booted emulator; found 0.'
|
||||
);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Session host output around the recording failure.'
|
||||
);
|
||||
});
|
||||
|
||||
it('rolls back failed host startup without replacing the original error', async () => {
|
||||
stopServer.mockRejectedValueOnce(new Error('stop failure'));
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 0,
|
||||
})
|
||||
).rejects.toThrow('Timed out waiting');
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
const directory =
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].env.EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY;
|
||||
if (!directory) {
|
||||
throw new Error('Missing screenshot artifact directory');
|
||||
}
|
||||
// The host could not be stopped, so it may still write captures there.
|
||||
await expect(access(directory)).resolves.toBeUndefined();
|
||||
expect(ngrok.forward).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
const hostStillRunning =
|
||||
'The session host is still running, so preview screenshots it saves from now on are not uploaded.';
|
||||
expect(logger.warn).toHaveBeenCalledWith({ directory }, hostStillRunning);
|
||||
const recordingWarnings = jest
|
||||
.mocked(logger.warn)
|
||||
.mock.calls.map(([, message]) => message)
|
||||
.filter(message => message !== hostStillRunning);
|
||||
expect(recordingWarnings).not.toContainEqual(expect.stringMatching(/finalize|upload/));
|
||||
});
|
||||
|
||||
async function writeRecordingDescriptorAsync(directory: string) {
|
||||
const child = path.join(directory, 'session');
|
||||
await mkdir(child);
|
||||
await writeFile(
|
||||
path.join(directory, 'recordings.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android',
|
||||
directory: child,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
it.each([true, false])(
|
||||
'removes its recording root only when every upload succeeds (%s)',
|
||||
async uploaded => {
|
||||
const host = await startHostAsync();
|
||||
const directory = directories[0];
|
||||
await writeRecordingDescriptorAsync(directory);
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockResolvedValueOnce(uploaded);
|
||||
await host.finishAsync();
|
||||
if (uploaded) {
|
||||
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
} else {
|
||||
await expect(access(directory)).resolves.toBeUndefined();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('finishes despite a stalled tunnel close', async () => {
|
||||
const host = await startHostAsync();
|
||||
await host.openPreviewAsync({ baseDomain });
|
||||
const closing = deferred<void>();
|
||||
closeTunnel.mockReturnValueOnce(closing.promise);
|
||||
jest.useFakeTimers();
|
||||
const finishing = host.finishAsync();
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
await finishing;
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('deadline'));
|
||||
closing.resolve();
|
||||
});
|
||||
|
||||
it('aborts a stalled finalization request before stopping the host', async () => {
|
||||
const host = await startHostAsync();
|
||||
const response = deferred<Awaited<ReturnType<typeof turtleFetch>>>();
|
||||
jest.mocked(turtleFetch).mockReturnValueOnce(response.promise);
|
||||
jest.useFakeTimers();
|
||||
const finishing = host.finishAsync();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
await finishing;
|
||||
const options = jest.mocked(turtleFetch).mock.calls.at(-1)?.[2];
|
||||
expect(options?.signal?.aborted).toBe(true);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
response.resolve({ ok: true } as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
});
|
||||
|
||||
it('uploads the partial recording a killed host left behind and then removes the root', async () => {
|
||||
const host = await startHostAsync();
|
||||
const directory = directories[0];
|
||||
const child = path.join(directory, 'session');
|
||||
await mkdir(child);
|
||||
await writeFile(path.join(directory, 'recordings.json'), '[]');
|
||||
const partial = [
|
||||
{ udid: 'emulator-5554', deviceName: 'Pixel', runtimeDisplayName: 'Android', directory: child },
|
||||
];
|
||||
jest.mocked(findUnlistedDeviceScreenRecordingsAsync).mockResolvedValueOnce(partial);
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockResolvedValueOnce(true);
|
||||
await host.finishAsync();
|
||||
expect(findUnlistedDeviceScreenRecordingsAsync).toHaveBeenCalledWith({
|
||||
root: directory,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).toHaveBeenCalledWith(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
recordings: partial,
|
||||
});
|
||||
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('rejects descriptors outside its recording root', async () => {
|
||||
const host = await startHostAsync();
|
||||
const directory = directories[0];
|
||||
await writeFile(
|
||||
path.join(directory, 'recordings.json'),
|
||||
JSON.stringify([
|
||||
{
|
||||
udid: 'emulator-5554',
|
||||
deviceName: 'Pixel',
|
||||
runtimeDisplayName: 'Android',
|
||||
directory: path.dirname(directory),
|
||||
},
|
||||
])
|
||||
);
|
||||
await host.finishAsync();
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
await expect(access(directory)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('retains recordings without uploading if the host cannot be stopped', async () => {
|
||||
const host = await startHostAsync();
|
||||
const directory = directories[0];
|
||||
await writeRecordingDescriptorAsync(directory);
|
||||
stopServer.mockRejectedValueOnce(new Error('process stop failed'));
|
||||
await host.finishAsync();
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
await expect(access(directory)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('closes a listener that arrives after finish has already timed out waiting for it', async () => {
|
||||
const host = await startHostAsync();
|
||||
const tunnel = deferred<Awaited<ReturnType<typeof ngrok.forward>>>();
|
||||
jest.mocked(ngrok.forward).mockReturnValueOnce(tunnel.promise);
|
||||
const opening = host.openPreviewAsync({ baseDomain });
|
||||
const rejected = expect(opening).rejects.toThrow('finalized while the preview was opening');
|
||||
jest.useFakeTimers();
|
||||
const finishing = host.finishAsync();
|
||||
await jest.advanceTimersByTimeAsync(5_000);
|
||||
await finishing;
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
tunnel.resolve({ url: () => 'https://late.example.test', close: closeTunnel } as never);
|
||||
await rejected;
|
||||
expect(closeTunnel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves iOS recording to its existing build steps', async () => {
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const options = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(options.args).not.toContain('--android-recording-directory');
|
||||
expect(options.env.EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN).toBeUndefined();
|
||||
await host.finishAsync();
|
||||
expect(jest.mocked(turtleFetch).mock.calls.some(([, method]) => method === 'POST')).toBe(false);
|
||||
expect(uploadDeviceRunSessionScreenRecordingsAsync).not.toHaveBeenCalled();
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.LINUX, BuildRuntimePlatform.DARWIN])(
|
||||
'uploads screenshots and flushes them exactly once when the %s host finishes',
|
||||
async runtimePlatform => {
|
||||
const uploads: Buffer[] = [];
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementation(async (_ctx, { stream }) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
uploads.push(Buffer.concat(chunks));
|
||||
});
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await host.openPreviewAsync({ baseDomain });
|
||||
const directory =
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].env.EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY;
|
||||
if (!directory) {
|
||||
throw new Error('Missing screenshot artifact directory');
|
||||
}
|
||||
await writeFile(
|
||||
path.join(directory, 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png'),
|
||||
'manual-capture'
|
||||
);
|
||||
let directoryWhenStopping: string[] | undefined;
|
||||
stopServer.mockImplementationOnce(async () => {
|
||||
directoryWhenStopping = await readdir(directory);
|
||||
});
|
||||
const finishing = host.finishAsync();
|
||||
expect(host.finishAsync()).toBe(finishing);
|
||||
await finishing;
|
||||
expect(uploads).toEqual([Buffer.from('manual-capture')]);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(closeTunnel).toHaveBeenCalledTimes(1);
|
||||
// The collector finishes, and removes its directory, only after the host has stopped.
|
||||
expect(directoryWhenStopping).toBeDefined();
|
||||
await expect(access(directory)).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
}
|
||||
);
|
||||
@@ -454,6 +454,7 @@ describe(startChiselServerAsync, () => {
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 12345,
|
||||
getOutput: () => output,
|
||||
getExitError: () => undefined,
|
||||
stopAsync,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,23 @@ describe(GuardEventRelay, () => {
|
||||
expect(relay.summary()).toEqual({ blocked: 3, logged: 0, distinct: 2, suppressed: 0 });
|
||||
});
|
||||
|
||||
it('omits the pid when the source has none and appends a note', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger);
|
||||
relay.handle({
|
||||
process: 'Android emulator',
|
||||
pid: null,
|
||||
function: 'udp',
|
||||
action: 'blocked',
|
||||
peer: '142.250.152.94:443',
|
||||
callers: [],
|
||||
note: 'QUIC',
|
||||
});
|
||||
expect(logger.lines.map(l => l.msg)).toEqual([
|
||||
'Local egress guard: refused udp from Android emulator to 142.250.152.94:443 (QUIC)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('words observed events differently and stops logging past the limit', () => {
|
||||
const logger = createLogger();
|
||||
const relay = new GuardEventRelay(logger, /* limit */ 1);
|
||||
|
||||
@@ -3,6 +3,9 @@ import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
clearTimeout as clearTimeoutCallback,
|
||||
setTimeout as setTimeoutCallback,
|
||||
@@ -15,24 +18,29 @@ import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
parseServeSimLaunchInputs,
|
||||
simulatorPreviewPageUrl,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startExpoDeviceHubWithTunnelAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
turnIceServersToWebPreviewArgs,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
simulatorPreviewPageUrl,
|
||||
startDeviceSessionHostAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
websiteOrigin,
|
||||
websiteOriginServeSimArgs,
|
||||
} from '../remoteDeviceRunSession';
|
||||
} from '../deviceSessionHost';
|
||||
import { parseNetworkCaptureFieldsInput, parseNetworkCaptureInputs } from '../networkCaptureFields';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
jest.mock('node:timers');
|
||||
@@ -41,6 +49,10 @@ jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../utils/retry', () => ({ sleepAsync: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../deviceRunSessionScreenRecordings', () => ({
|
||||
...jest.requireActual('../deviceRunSessionScreenRecordings'),
|
||||
uploadDeviceRunSessionScreenRecordingsAsync: jest.fn(),
|
||||
}));
|
||||
// Spyable so a test can stand in for the serve-sim state directory, which a local serve-sim owns.
|
||||
jest.mock('../serveSimMetricsRecorder', () => {
|
||||
const actual = jest.requireActual('../serveSimMetricsRecorder');
|
||||
@@ -56,6 +68,69 @@ function createLoggerMock(): bunyan {
|
||||
} as unknown as bunyan;
|
||||
}
|
||||
|
||||
describe(spawnDetached, () => {
|
||||
function mockProcess(promise: Promise<unknown>): void {
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValue(
|
||||
Object.assign(promise, { child: { pid: 1234, unref: jest.fn(), once: jest.fn() } }) as never
|
||||
);
|
||||
}
|
||||
|
||||
it('observes exit without waiting for inherited output pipes to close', () => {
|
||||
const child = Object.assign(new EventEmitter(), { pid: 1234, unref: jest.fn() });
|
||||
jest.mocked(spawn).mockReturnValue(Object.assign(new Promise(() => {}), { child }) as never);
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
child.emit('exit', 1, null);
|
||||
expect(handle.getExitError()?.message).toContain('code 1');
|
||||
expect(child.listenerCount('exit')).toBe(0);
|
||||
});
|
||||
|
||||
it('does not report an exit while the process is running', () => {
|
||||
mockProcess(new Promise(() => {}));
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
expect(handle.getExitError()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports a clean exit so startup does not keep waiting', async () => {
|
||||
const completion = Promise.resolve();
|
||||
mockProcess(completion);
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
await completion;
|
||||
expect(handle.getExitError()?.message).toContain('code 0');
|
||||
});
|
||||
|
||||
it.each([0, 1])('observes a real subprocess exiting with code %s', async exitCode => {
|
||||
const actualSpawn =
|
||||
jest.requireActual<typeof import('@expo/turtle-spawn')>('@expo/turtle-spawn').default;
|
||||
let completion: ReturnType<typeof actualSpawn>;
|
||||
jest.mocked(spawn).mockImplementationOnce((...args) => {
|
||||
completion = actualSpawn(...args);
|
||||
return completion;
|
||||
});
|
||||
const handle = spawnDetached({
|
||||
command: process.execPath,
|
||||
args: ['-e', `console.error('startup-marker'); process.exit(${exitCode});`],
|
||||
env: {},
|
||||
});
|
||||
await completion!.catch(() => {});
|
||||
expect(handle.getExitError()).toBeInstanceOf(Error);
|
||||
expect(handle.getOutput()).toContain('startup-marker');
|
||||
});
|
||||
|
||||
it.each(['spawn ENOENT', 'server exited with code 1', 'server terminated by SIGTERM'])(
|
||||
'preserves the process failure: %s',
|
||||
async message => {
|
||||
const error = new Error(message);
|
||||
const completion = Promise.reject(error);
|
||||
mockProcess(completion);
|
||||
const handle = spawnDetached({ command: 'server', args: [], env: {} });
|
||||
await completion.catch(() => {});
|
||||
expect(handle.getExitError()).toBe(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
function createCtxMock(): CustomBuildContext {
|
||||
return {
|
||||
env: {
|
||||
@@ -379,9 +454,94 @@ describe(createServeSimArgs, () => {
|
||||
expect(args.some(argument => argument.startsWith('--launch'))).toBe(false);
|
||||
expect(args).not.toContain('--open-url');
|
||||
});
|
||||
|
||||
it('omits --network-capture by default', () => {
|
||||
expect(createServeSimArgs({ port: 4321 })).not.toContain('--network-capture');
|
||||
expect(createServeSimArgs({ port: 4321, networkCapture: false })).not.toContain(
|
||||
'--network-capture'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends --network-capture when enabled, which also covers an already booted simulator', () => {
|
||||
const args = createServeSimArgs({ port: 4321, networkCapture: true });
|
||||
expect(args).toContain('--network-capture');
|
||||
expect(args).not.toContain('--enable');
|
||||
});
|
||||
|
||||
it('repeats --network-capture-field once per requested field', () => {
|
||||
expect(
|
||||
createServeSimArgs({
|
||||
port: 4321,
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header', 'query'],
|
||||
})
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--network-capture',
|
||||
'--network-capture-field',
|
||||
'header',
|
||||
'--network-capture-field',
|
||||
'query',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps capture metadata-only when no field is requested', () => {
|
||||
expect(createServeSimArgs({ port: 4321, networkCapture: true })).not.toContain(
|
||||
'--network-capture-field'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not pass fields when capture itself is off', () => {
|
||||
expect(
|
||||
createServeSimArgs({ port: 4321, networkCapture: false, networkCaptureFields: ['header'] })
|
||||
).not.toContain('--network-capture-field');
|
||||
});
|
||||
|
||||
it('rejects network capture on a runtime that has no serve-sim', () => {
|
||||
expect(() =>
|
||||
parseNetworkCaptureInputs(
|
||||
{ networkCapture: true },
|
||||
{ runtimePlatform: BuildRuntimePlatform.LINUX }
|
||||
)
|
||||
).toThrow('this session runs on linux');
|
||||
expect(() =>
|
||||
parseNetworkCaptureInputs(
|
||||
{ networkCaptureFields: ['header'] },
|
||||
{ runtimePlatform: BuildRuntimePlatform.DARWIN }
|
||||
)
|
||||
).toThrow('needs "network_capture: true"');
|
||||
expect(parseNetworkCaptureInputs({}, { runtimePlatform: BuildRuntimePlatform.LINUX })).toEqual({
|
||||
networkCapture: false,
|
||||
networkCaptureFields: [],
|
||||
});
|
||||
expect(
|
||||
parseNetworkCaptureInputs(
|
||||
{ networkCapture: true, networkCaptureFields: ['header'] },
|
||||
{ runtimePlatform: BuildRuntimePlatform.DARWIN }
|
||||
)
|
||||
).toEqual({ networkCapture: true, networkCaptureFields: ['header'] });
|
||||
});
|
||||
|
||||
it('rejects a step input that is not an array of strings', () => {
|
||||
// A JSON step input is whatever the workflow author wrote, so the shape has to be checked.
|
||||
expect(() => parseNetworkCaptureFieldsInput('header,query')).toThrow(UserError);
|
||||
expect(() => parseNetworkCaptureFieldsInput('header,query')).toThrow(
|
||||
/must be an array of strings/
|
||||
);
|
||||
expect(() => parseNetworkCaptureFieldsInput([1, 2])).toThrow(UserError);
|
||||
expect(parseNetworkCaptureFieldsInput(undefined)).toEqual([]);
|
||||
expect(parseNetworkCaptureFieldsInput(['header'])).toEqual(['header']);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createExpoDeviceHubArgs, () => {
|
||||
it('opts in to recording only when a directory is provided', () => {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321 })).not.toContain('--android-recording-directory');
|
||||
expect(
|
||||
createExpoDeviceHubArgs({ port: 4321, recordingDirectory: '/tmp/recordings' }).slice(-2)
|
||||
).toEqual(['--android-recording-directory', '/tmp/recordings']);
|
||||
});
|
||||
it('uses the latest Expo package and applies the EAS Android streaming policy', () => {
|
||||
expect(
|
||||
createExpoDeviceHubArgs({
|
||||
@@ -493,6 +653,7 @@ describe(waitForWebPreviewReadyAsync, () => {
|
||||
expect.objectContaining({ retries: 0 })
|
||||
);
|
||||
expect(sleepAsync).toHaveBeenCalledTimes(1);
|
||||
expect(sleepAsync).toHaveBeenCalledWith(250);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -531,7 +692,7 @@ describe(startNgrokTunnelAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
describe(startDeviceSessionHostAsync, () => {
|
||||
const baseDomain = 'eas-simulator.ngrok.dev';
|
||||
const turnArgs = [
|
||||
'--turn-url',
|
||||
@@ -548,6 +709,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
} as unknown as BuildStepEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(uploadDeviceRunSessionScreenRecordingsAsync).mockReset();
|
||||
jest.mocked(spawn).mockReset();
|
||||
jest.mocked(ngrok.forward).mockReset();
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
@@ -559,6 +721,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
child: {
|
||||
pid: undefined,
|
||||
unref: jest.fn(),
|
||||
once: jest.fn(),
|
||||
},
|
||||
});
|
||||
jest.mocked(spawn).mockReturnValue(spawnPromise as never);
|
||||
@@ -600,14 +763,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>)
|
||||
.mockReturnValueOnce(Promise.resolve({}) as unknown as ReturnType<typeof spawn>);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const spawnCalls = jest.mocked(spawn).mock.calls;
|
||||
expect(spawnCalls[0]).toEqual(['ffmpeg', ['-version'], { env }]);
|
||||
@@ -634,11 +797,17 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(['--yes', ...createExpoDeviceHubArgs({ port, turnArgs, packageVersion })]);
|
||||
expect(args).toContain('--android-recording-directory');
|
||||
const recordingDirectory = args[args.indexOf('--android-recording-directory') + 1];
|
||||
expect(args).toEqual([
|
||||
'--yes',
|
||||
...createExpoDeviceHubArgs({ port, turnArgs, packageVersion, recordingDirectory }),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://android-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
await fs.rm(recordingDirectory, { recursive: true, force: true });
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -648,13 +817,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.apiUrl).toBe('https://preview.example.test');
|
||||
@@ -677,13 +846,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: { ...env, EXPO_STAGING: '1' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
expect(preview.previewPageUrl).toMatch(
|
||||
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
|
||||
@@ -702,9 +871,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
.mockResolvedValue([{ udid: 'device-id', url: 'http://127.0.0.1:1' }]);
|
||||
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
@@ -720,13 +888,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
expect(preview.previewToken).toBeUndefined();
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
@@ -741,14 +909,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -767,33 +935,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not install ffmpeg before starting expo-device-hub outside Linux', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://android-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startExpoDeviceHubWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(jest.mocked(spawn).mock.calls[0][0]).toBe('npx');
|
||||
expect(jest.mocked(spawn)).not.toHaveBeenCalledWith(
|
||||
'ffmpeg',
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -805,14 +947,14 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
} as never);
|
||||
|
||||
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: bunEnv,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion: '4.5.6',
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -828,7 +970,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -839,13 +981,13 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const preview = await host.openPreviewAsync({ baseDomain });
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
@@ -860,7 +1002,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await preview.stopAsync();
|
||||
await host.finishAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -870,9 +1012,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
const host = await startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
@@ -880,6 +1021,7 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
});
|
||||
await host.finishAsync();
|
||||
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(args.slice(-8)).toEqual([
|
||||
@@ -896,9 +1038,8 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
|
||||
it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => {
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
startDeviceSessionHostAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { isProcessRunning, spawnDetached } from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
|
||||
it('resolves stopAsync only after a process that ignores SIGTERM is gone', async () => {
|
||||
const handle = spawnDetached({
|
||||
command: 'sh',
|
||||
args: ['-c', 'trap "" TERM; echo ready; sleep 30 & wait'],
|
||||
env: process.env as BuildStepEnv,
|
||||
stopGracePeriodMs: 300,
|
||||
});
|
||||
const pid = handle.pid;
|
||||
if (pid === undefined) {
|
||||
throw new Error('spawn failed');
|
||||
}
|
||||
while (!handle.getOutput().includes('ready')) {
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
await handle.stopAsync();
|
||||
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(300);
|
||||
expect(isProcessRunning(pid)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { Readable } from 'node:stream';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const filePath = path.join(os.tmpdir(), 'logs.ndjson');
|
||||
const args = { deviceRunSessionId: 'session', udid: 'A', filePath, logger };
|
||||
|
||||
it('uploads NDJSON with explicit user-app scope and destroys the stream afterward', async () => {
|
||||
await writeFile(filePath, '{"pid":42}\n');
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
kind: 'simulator-log',
|
||||
filename: 'app-logs.ndjson',
|
||||
size: 11,
|
||||
metadata: expect.objectContaining({ scope: 'user-apps', udid: 'A' }),
|
||||
})
|
||||
);
|
||||
expect(
|
||||
(jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls[0][1].stream as Readable).destroyed
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('skips empty and missing files', async () => {
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
await writeFile(filePath, '');
|
||||
await uploadServeSimLogsFileAsync(ctx, args);
|
||||
expect(uploadDeviceRunSessionArtifactAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles a file read error while upload allocation is pending without an unhandled error', async () => {
|
||||
await writeFile(filePath, '{}\n');
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream, signal }) => {
|
||||
const aborted = new Promise<void>((_resolve, reject) => {
|
||||
signal!.addEventListener('abort', () => reject(signal!.reason), { once: true });
|
||||
});
|
||||
(stream as Readable).destroy(new Error('read failure'));
|
||||
await aborted;
|
||||
});
|
||||
await expect(uploadServeSimLogsFileAsync(ctx, args)).resolves.toBeUndefined();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
expect.stringContaining('Could not upload')
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { type Server, createServer } from 'node:http';
|
||||
import { type AddressInfo } from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts';
|
||||
import { streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder';
|
||||
|
||||
// This test exercises actual sockets, UTF-8 decoding, files, and PUT uploads.
|
||||
// Only WWW's allocation response is substituted; it does not verify cloud storage.
|
||||
jest.mock('../../../sentry');
|
||||
jest.unmock('fs');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('fs/promises');
|
||||
jest.unmock('node:fs/promises');
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
|
||||
let directory: string;
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'simulator-logs-http-test-'));
|
||||
server = createServer();
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close(error => (error ? reject(error) : resolve()))
|
||||
);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('streams selected-device logs into NDJSON and uploads the exact bytes as a session artifact', async () => {
|
||||
const record = JSON.stringify({ eventMessage: 'Hello 서울 🌲', process: 'MyApp' });
|
||||
const expected = Buffer.from(record + '\n');
|
||||
const envelope = JSON.stringify({ seq: 7, at: 1000, raw: record });
|
||||
const wire = Buffer.from(
|
||||
`: heartbeat\n\ndata: ${envelope}\n\ndata: invalid json\n\ndata: {"partial":`
|
||||
);
|
||||
const splitAt = wire.indexOf(Buffer.from('서울')) + 1;
|
||||
let streamRequest: { url?: string; authorization?: string } | undefined;
|
||||
let uploaded: Buffer | undefined;
|
||||
let uploadedLength: string | undefined;
|
||||
server.on('request', (request, response) => {
|
||||
if (request.method === 'GET') {
|
||||
streamRequest = { url: request.url, authorization: request.headers.authorization };
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'X-Serve-Sim-Log-Scope': 'user-apps',
|
||||
});
|
||||
response.write(wire.subarray(0, splitAt));
|
||||
setImmediate(() => response.end(wire.subarray(splitAt)));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => chunks.push(Buffer.from(chunk)));
|
||||
request.on('end', () => {
|
||||
uploaded = Buffer.concat(chunks);
|
||||
uploadedLength = request.headers['content-length'];
|
||||
response.writeHead(200).end();
|
||||
});
|
||||
});
|
||||
const filePath = path.join(directory, 'simulator.ndjson');
|
||||
const result = await streamServeSimLogsToFileAsync({
|
||||
serveSimUrl: baseUrl,
|
||||
serveSimToken: 'local-test-token',
|
||||
serveSimDevice: 'device-B',
|
||||
filePath,
|
||||
signal: new AbortController().signal,
|
||||
logger,
|
||||
});
|
||||
expect(streamRequest).toEqual({
|
||||
url: '/logs?envelope=true&scope=user-apps&device=device-B',
|
||||
authorization: 'Bearer local-test-token',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
receivedData: true,
|
||||
limitReached: false,
|
||||
bytesWritten: expected.length,
|
||||
lastSequence: 7,
|
||||
});
|
||||
expect(await readFile(filePath)).toEqual(expected);
|
||||
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: `${baseUrl}/upload`,
|
||||
headers: { 'Content-Length': String(expected.length) },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
await uploadServeSimLogsFileAsync(
|
||||
{ graphqlClient: { mutation } } as unknown as CustomBuildContext,
|
||||
{ deviceRunSessionId: 'session-test', udid: 'device-B', filePath, logger }
|
||||
);
|
||||
expect(mutation.mock.calls[0][1]).toEqual({
|
||||
deviceRunSessionId: 'session-test',
|
||||
input: {
|
||||
name: 'App logs (device-B)',
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
metadata: {
|
||||
__eas_type: 'simulator-log',
|
||||
udid: 'device-B',
|
||||
scope: 'user-apps',
|
||||
source: 'serve-sim/logs',
|
||||
},
|
||||
size: expected.length,
|
||||
},
|
||||
});
|
||||
expect(uploaded).toEqual(expected);
|
||||
expect(uploadedLength).toBe(String(expected.length));
|
||||
// Only the invalid JSON record is reported; the partial trailing record is not a record yet.
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ malformedRecords: 1 },
|
||||
expect.stringContaining('not valid JSON')
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes a buffered stream without appending already persisted sequence numbers', async () => {
|
||||
const urls: string[] = [];
|
||||
const first = JSON.stringify({ eventMessage: 'first' });
|
||||
const second = JSON.stringify({ eventMessage: 'second' });
|
||||
const frame = (seq: number, raw: string): string =>
|
||||
`data: ${JSON.stringify({ seq, at: seq * 1000, raw })}\n\n`;
|
||||
server.on('request', (request, response) => {
|
||||
urls.push(request.url!);
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'X-Serve-Sim-Log-Scope': 'user-apps',
|
||||
});
|
||||
// Deliberately resend seq=1 on reconnect to exercise the client's guard too.
|
||||
response.end(frame(1, first) + (urls.length > 1 ? frame(2, second) : ''));
|
||||
});
|
||||
const filePath = path.join(directory, 'resumed.ndjson');
|
||||
const options = {
|
||||
serveSimUrl: baseUrl,
|
||||
serveSimDevice: 'device-A',
|
||||
filePath,
|
||||
signal: new AbortController().signal,
|
||||
logger,
|
||||
};
|
||||
const initial = await streamServeSimLogsToFileAsync(options);
|
||||
const resumed = await streamServeSimLogsToFileAsync({
|
||||
...options,
|
||||
since: initial.lastSequence,
|
||||
});
|
||||
expect(initial.lastSequence).toBe(1);
|
||||
expect(resumed.lastSequence).toBe(2);
|
||||
expect(resumed.bytesWritten).toBe(Buffer.byteLength(second + '\n'));
|
||||
expect(urls).toEqual([
|
||||
'/logs?envelope=true&scope=user-apps&device=device-A',
|
||||
'/logs?envelope=true&scope=user-apps&since=1&device=device-A',
|
||||
]);
|
||||
expect(await readFile(filePath, 'utf8')).toBe(first + '\n' + second + '\n');
|
||||
});
|
||||
|
||||
it('aborts a real open HTTP stream without waiting for the server to finish', async () => {
|
||||
const controller = new AbortController();
|
||||
let sawRequest: () => void = () => {};
|
||||
const requestStarted = new Promise<void>(resolve => (sawRequest = resolve));
|
||||
server.on('request', (_request, response) => {
|
||||
response.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'X-Serve-Sim-Log-Scope': 'user-apps',
|
||||
});
|
||||
response.write(': heartbeat\n\n');
|
||||
sawRequest();
|
||||
});
|
||||
const streaming = streamServeSimLogsToFileAsync({
|
||||
serveSimUrl: baseUrl,
|
||||
filePath: path.join(directory, 'aborted.ndjson'),
|
||||
signal: controller.signal,
|
||||
logger,
|
||||
});
|
||||
await requestStarted;
|
||||
controller.abort();
|
||||
await expect(streaming).resolves.toEqual({
|
||||
receivedData: false,
|
||||
limitReached: false,
|
||||
bytesWritten: 0,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fetch from 'node-fetch';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { ServeSimLogsRecorder, streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('../../../sentry');
|
||||
jest.unmock('node:fs');
|
||||
jest.unmock('node:fs/promises');
|
||||
const { Response: FetchResponse } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
// A compatible serve-sim explicitly acknowledges the requested log scope.
|
||||
class Response extends FetchResponse {
|
||||
constructor(...args: ConstructorParameters<typeof FetchResponse>) {
|
||||
super(...args);
|
||||
this.headers.set('x-serve-sim-log-scope', 'user-apps');
|
||||
}
|
||||
}
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
let directory: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
directory = await mkdtemp(path.join(os.tmpdir(), 'logs-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const files = await ServeSimLogsRecorder.finishAsync({ logger });
|
||||
for (const file of files) {
|
||||
await rm(path.dirname(file.filePath), { recursive: true, force: true });
|
||||
}
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function record(options: Partial<Parameters<typeof streamServeSimLogsToFileAsync>[0]> = {}) {
|
||||
return streamServeSimLogsToFileAsync({
|
||||
serveSimUrl: 'http://localhost:1234',
|
||||
filePath: path.join(directory, 'logs.ndjson'),
|
||||
signal: new AbortController().signal,
|
||||
logger,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
it('preserves split UTF-8 and CRLF records, skips heartbeat and reports invalid JSON, and selects a device', async () => {
|
||||
const input = Buffer.from(
|
||||
':\r\n\r\ndata: {"message":"안녕"}\r\n\r\ndata: bad\n\ndata: {"pid":42}\n\n'
|
||||
);
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(new Response(Readable.from([...input].map(byte => Buffer.from([byte])))));
|
||||
const result = await record({ serveSimToken: 'secret', serveSimDevice: 'device two' });
|
||||
expect(result.receivedData).toBe(true);
|
||||
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe(
|
||||
'{"message":"안녕"}\n{"pid":42}\n'
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith('serve-sim sent malformed simulator log records', {
|
||||
extras: { malformedRecords: 1, serveSimDevice: 'device two' },
|
||||
});
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:1234/logs?envelope=true&scope=user-apps&device=device+two',
|
||||
expect.objectContaining({ headers: { Authorization: 'Bearer secret' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('retains completed records when a connection errors', async () => {
|
||||
const stream = Readable.from(
|
||||
(async function* () {
|
||||
yield Buffer.from('data: {"pid":42}\n\n');
|
||||
await delay(10);
|
||||
throw new Error('disconnect');
|
||||
})()
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValue(new Response(stream));
|
||||
expect(await record()).toEqual({ receivedData: true, bytesWritten: 11, limitReached: false });
|
||||
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n');
|
||||
});
|
||||
|
||||
it.each([undefined, 'all'])('refuses an unconfirmed user-app scope (%s)', async scope => {
|
||||
const body = Readable.from(['data: {"eventMessage":"system noise"}\n\n']);
|
||||
const response = new FetchResponse(body);
|
||||
if (scope) {
|
||||
response.headers.set('x-serve-sim-log-scope', scope);
|
||||
}
|
||||
jest.mocked(fetch).mockResolvedValue(response);
|
||||
expect(await record()).toEqual({ receivedData: false, bytesWritten: 0, limitReached: false });
|
||||
expect(body.destroyed).toBe(true);
|
||||
await expect(readFile(path.join(directory, 'logs.ndjson'))).rejects.toMatchObject({
|
||||
code: 'ENOENT',
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('did not confirm user-app'));
|
||||
});
|
||||
|
||||
it('aborts a quiet live stream and drains preceding writes before returning', async () => {
|
||||
const controller = new AbortController();
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.push('data: {"pid":42}\n\n');
|
||||
jest.mocked(fetch).mockResolvedValue(new Response(stream));
|
||||
const done = record({ signal: controller.signal });
|
||||
await delay(20);
|
||||
controller.abort();
|
||||
expect((await done).receivedData).toBe(true);
|
||||
expect(stream.destroyed).toBe(true);
|
||||
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n');
|
||||
});
|
||||
|
||||
it('enforces the byte limit without writing a truncated record', async () => {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(new Response(Readable.from(['data: {"pid":42}\n\ndata: {"pid":43}\n\n'])));
|
||||
expect(await record({ maxBytes: 15 })).toEqual({
|
||||
receivedData: true,
|
||||
bytesWritten: 11,
|
||||
limitReached: true,
|
||||
});
|
||||
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":42}\n');
|
||||
});
|
||||
|
||||
it('drops an oversized partial SSE line and resumes at the next complete record', async () => {
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
new Response(
|
||||
Readable.from(['data: ' + 'x'.repeat(1024 * 1024 + 1), 'tail\n\ndata: {"pid":42}\n\n'])
|
||||
)
|
||||
);
|
||||
expect((await record()).bytesWritten).toBe(11);
|
||||
});
|
||||
|
||||
it('warns and resolves on HTTP and file failures', async () => {
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('missing', { status: 404 }));
|
||||
expect((await record()).receivedData).toBe(false);
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from(['data: {}\n\n'])));
|
||||
expect((await record({ filePath: '/missing-dir/log.ndjson' })).receivedData).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
async function register(): Promise<string> {
|
||||
const stateDir = path.join(directory, 'state');
|
||||
await mkdir(stateDir);
|
||||
await writeFile(
|
||||
path.join(stateDir, 'server-A.json'),
|
||||
JSON.stringify({ device: 'A', url: 'http://localhost:1234' })
|
||||
);
|
||||
return stateDir;
|
||||
}
|
||||
|
||||
it('caps bytes across reconnects and finalizes only once even concurrently', async () => {
|
||||
const stateDir = await register();
|
||||
let seq = 0;
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
Readable.from([`data: ${JSON.stringify({ seq: ++seq, raw: '{"pid":42}' })}\n\n`])
|
||||
)
|
||||
);
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxBytes: 22 });
|
||||
await delay(100);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const [first, second] = await Promise.all([
|
||||
ServeSimLogsRecorder.finishAsync({ logger }),
|
||||
ServeSimLogsRecorder.finishAsync({ logger }),
|
||||
]);
|
||||
expect(first).toHaveLength(1);
|
||||
expect(second).toEqual([]);
|
||||
expect(await readFile(first[0].filePath, 'utf8')).toBe('{"pid":42}\n{"pid":42}\n');
|
||||
expect(await ServeSimLogsRecorder.finishAsync({ logger })).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns immediately before a server exists and discovers a later registration', async () => {
|
||||
const stateDir = path.join(directory, 'state');
|
||||
jest.mocked(fetch).mockImplementation(async () => new Response(Readable.from(['data: {}\n\n'])));
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5 });
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
await register();
|
||||
await delay(30);
|
||||
expect(await ServeSimLogsRecorder.finishAsync({ logger })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stops discovery and active streams at the session time limit', async () => {
|
||||
const stateDir = await register();
|
||||
const stream = new Readable({ read() {} });
|
||||
stream.push('data: {}\n\n');
|
||||
jest.mocked(fetch).mockResolvedValue(new Response(stream));
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxDurationMs: 30 });
|
||||
await delay(80);
|
||||
expect(stream.destroyed).toBe(true);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(await ServeSimLogsRecorder.finishAsync({ logger })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stops retrying after ten consecutive failures', async () => {
|
||||
const stateDir = await register();
|
||||
jest.mocked(fetch).mockImplementation(async () => new Response('bad', { status: 503 }));
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5 });
|
||||
await delay(150);
|
||||
expect(fetch).toHaveBeenCalledTimes(10);
|
||||
expect(await ServeSimLogsRecorder.finishAsync({ logger })).toEqual([]);
|
||||
});
|
||||
|
||||
it('selects each registered device when two simulators share one server URL', async () => {
|
||||
const stateDir = await register();
|
||||
await writeFile(
|
||||
path.join(stateDir, 'server-B.json'),
|
||||
JSON.stringify({ device: 'B', url: 'http://localhost:1234' })
|
||||
);
|
||||
jest.mocked(fetch).mockImplementation(async () => new Response(Readable.from(['data: {}\n\n'])));
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxBytes: 3 });
|
||||
await delay(50);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:1234/logs?envelope=true&scope=user-apps&device=A',
|
||||
expect.anything()
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:1234/logs?envelope=true&scope=user-apps&device=B',
|
||||
expect.anything()
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('persists the envelope cursor only for written records and drops replayed sequences', async () => {
|
||||
const frames = [1, 2, 3].map(
|
||||
seq => `data: ${JSON.stringify({ seq, raw: JSON.stringify({ pid: seq }) })}\n\n`
|
||||
);
|
||||
jest.mocked(fetch).mockResolvedValue(new Response(Readable.from(frames)));
|
||||
expect(await record({ since: 1, maxBytes: 10 })).toEqual({
|
||||
receivedData: true,
|
||||
bytesWritten: 10,
|
||||
limitReached: true,
|
||||
lastSequence: 2,
|
||||
});
|
||||
expect(await readFile(path.join(directory, 'logs.ndjson'), 'utf8')).toBe('{"pid":2}\n');
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:1234/logs?envelope=true&scope=user-apps&since=1',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reconnect a legacy data stream without a replay cursor', async () => {
|
||||
const stateDir = await register();
|
||||
jest.mocked(fetch).mockImplementation(async () => new Response(Readable.from(['data: {}\n\n'])));
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5 });
|
||||
await delay(50);
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('without a replay cursor'));
|
||||
});
|
||||
|
||||
it('resets the replay cursor when a replacement server changes its token', async () => {
|
||||
const stateDir = await register();
|
||||
let calls = 0;
|
||||
jest.mocked(fetch).mockImplementation(async (_url, options) => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
await writeFile(
|
||||
path.join(stateDir, 'server-A.json'),
|
||||
JSON.stringify({
|
||||
device: 'A',
|
||||
url: 'http://localhost:1234',
|
||||
token: 'new-token',
|
||||
})
|
||||
);
|
||||
}
|
||||
const seq = options?.headers ? 1 : 50;
|
||||
return new Response(Readable.from([`data: ${JSON.stringify({ seq, raw: '{}' })}\n\n`]));
|
||||
});
|
||||
await ServeSimLogsRecorder.startAsync({ logger, stateDir, pollIntervalMs: 5, maxBytes: 6 });
|
||||
await delay(50);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(String(jest.mocked(fetch).mock.calls[1][0])).not.toContain('since=');
|
||||
expect(jest.mocked(fetch).mock.calls[1][1]?.headers).toEqual({
|
||||
Authorization: 'Bearer new-token',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
|
||||
import { createStartupTasks } from '../startupTasks';
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as unknown as bunyan;
|
||||
}
|
||||
|
||||
describe(createStartupTasks, () => {
|
||||
it('logs each task and sums them up in the order they finished', async () => {
|
||||
const logger = createLoggerMock();
|
||||
const tasks = createStartupTasks(logger);
|
||||
|
||||
let finishSlow!: () => void;
|
||||
const slow = tasks.run(
|
||||
'slow task',
|
||||
() =>
|
||||
new Promise<string>(resolve => {
|
||||
finishSlow = () => resolve('slow');
|
||||
})
|
||||
);
|
||||
const fast = tasks.run('fast task', async () => 'fast');
|
||||
|
||||
await expect(fast).resolves.toBe('fast');
|
||||
finishSlow();
|
||||
await expect(slow).resolves.toBe('slow');
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Starting slow task.');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^Finished fast task in \d+\.\d s\.$/)
|
||||
);
|
||||
expect(tasks.summary()).toMatch(
|
||||
/^Startup took \d+\.\d s \(fast task \d+\.\d s, slow task \d+\.\d s\)\.$/
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the error for awaiters without an unhandled rejection', async () => {
|
||||
const logger = createLoggerMock();
|
||||
const tasks = createStartupTasks(logger);
|
||||
|
||||
const failing = tasks.run('failing task', async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
// Nothing awaits the task yet. Jest fails the test on an unhandled rejection.
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
await expect(failing).rejects.toThrow('boom');
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringMatching(/^failing task failed after/));
|
||||
expect(tasks.summary()).toMatch(/\(\)\.$/);
|
||||
});
|
||||
|
||||
it('aborts the signal with the first failure and gives each task a child logger', async () => {
|
||||
const logger = createLoggerMock();
|
||||
const tasks = createStartupTasks(logger);
|
||||
|
||||
let taskLogger: unknown;
|
||||
await tasks.run('named task', async l => {
|
||||
taskLogger = l;
|
||||
});
|
||||
expect(logger.child).toHaveBeenCalledWith({ startupTask: 'named task' });
|
||||
expect(taskLogger).toBe(logger);
|
||||
expect(tasks.signal.aborted).toBe(false);
|
||||
|
||||
const first = new Error('first failure');
|
||||
await expect(tasks.run('first', async () => Promise.reject(first))).rejects.toBe(first);
|
||||
await expect(
|
||||
tasks.run('second', async () => Promise.reject(new Error('second failure')))
|
||||
).rejects.toThrow('second failure');
|
||||
expect(tasks.signal.aborted).toBe(true);
|
||||
expect(tasks.signal.reason).toBe(first);
|
||||
});
|
||||
|
||||
it('stops waiting on untilAborted when startup is aborted', async () => {
|
||||
const tasks = createStartupTasks(createLoggerMock());
|
||||
const never = new Promise<void>(() => {});
|
||||
const waiting = tasks.untilAborted(never);
|
||||
const failure = new Error('boot failed');
|
||||
|
||||
tasks.abort(failure);
|
||||
|
||||
await expect(waiting).rejects.toBe(failure);
|
||||
await expect(tasks.untilAborted(Promise.resolve('late'))).rejects.toBe(failure);
|
||||
await expect(
|
||||
createStartupTasks(createLoggerMock()).untilAborted(Promise.resolve('ok'))
|
||||
).resolves.toBe('ok');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,860 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import {
|
||||
type AndroidDeviceSerialId,
|
||||
type AndroidEmulatorLaunchGate,
|
||||
} from '../../utils/AndroidEmulatorUtils';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
import {
|
||||
LOCAL_EGRESS_HANDOFF_PATH,
|
||||
LOCAL_EGRESS_PROXY_HOST,
|
||||
readLocalEgressHandoffAsync,
|
||||
} from './localEgress';
|
||||
import {
|
||||
type GuardEvent,
|
||||
GuardEventRelay,
|
||||
registerLocalEgressGuardRelay,
|
||||
} from './localEgressGuard';
|
||||
|
||||
/**
|
||||
* Android emulator side of local egress, on Linux device hosts.
|
||||
*
|
||||
* - The emulator's `-http-proxy` points at a relay on LOCAL_EGRESS_RELAY_PORT.
|
||||
* The relay always listens, because qemu tests the proxy once at startup and
|
||||
* ignores it for the emulator's whole life if that test fails. It forwards
|
||||
* each connection to the chisel listener, which exists only while the EAS
|
||||
* CLI egress client is connected, and refuses the request otherwise.
|
||||
* - The guest's system proxy is the relay as well (10.0.2.2 is host loopback),
|
||||
* so proxy-aware clients send hostnames instead of IP addresses.
|
||||
* - Every emulator process runs in one systemd scope. An nftables table refuses
|
||||
* traffic from that scope that is not loopback TCP or DNS to
|
||||
* systemd-resolved: UDP, QUIC, ICMP and the emulator's
|
||||
* own direct connections. Kernel log lines from the fence become session log
|
||||
* lines through the guard relay.
|
||||
*
|
||||
* This keeps routing faithful; it is not a security boundary.
|
||||
*/
|
||||
|
||||
export const LOCAL_EGRESS_RELAY_PORT = 8898;
|
||||
export const ANDROID_EMULATOR_HOST_LOOPBACK_ALIAS = '10.0.2.2';
|
||||
/** TEST-NET-1; the relay answers CONNECTs to it itself, so it proves the proxy path is up. */
|
||||
export const LOCAL_EGRESS_READINESS_TARGET = { host: '192.0.2.1', port: 443 } as const;
|
||||
export const LOCAL_EGRESS_SESSION_UNIT = 'eas-egress-session';
|
||||
export const EGRESS_FENCE_TABLE = 'eas_fence';
|
||||
const FENCE_LOG_PREFIX = 'eas-egress';
|
||||
const FENCE_RELAY_KEY = 'android-emulator-fence';
|
||||
const FENCED_PROCESS_LABEL = 'Android emulator';
|
||||
const FENCED_PROCESS_PATTERN = /^(qemu-system|netsimd|crashpad_handle)/;
|
||||
const RELAY_MAX_HEAD_BYTES = 16 * 1024;
|
||||
const SCOPE_STARTUP_TIMEOUT_MS = 10_000;
|
||||
const ADB_REVERSE_INTERVAL_MS = 3_000;
|
||||
const GATE_SCRIPT = 'read -r _ || exit 1; exec "$@" < /dev/null';
|
||||
|
||||
const CONNECTION_ESTABLISHED = 'HTTP/1.1 200 Connection Established\r\n\r\n';
|
||||
const CLIENT_NOT_CONNECTED_BODY = 'The EAS CLI local egress client is not connected.\r\n';
|
||||
const CLIENT_NOT_CONNECTED =
|
||||
'HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Type: text/plain\r\n' +
|
||||
`Content-Length: ${Buffer.byteLength(CLIENT_NOT_CONNECTED_BODY)}\r\n\r\n${CLIENT_NOT_CONNECTED_BODY}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Relay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function parseConnectAuthority(head: string): { host: string; port: number } | null {
|
||||
const match = /^CONNECT\s+(\[[^\]]+\]|[^\s:]+):(\d{1,5})\s+HTTP\/1\.[01]\r?\n/i.exec(head);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return { host: match[1].replace(/^\[|\]$/g, '').toLowerCase(), port: Number(match[2]) };
|
||||
}
|
||||
|
||||
function isReadinessTarget({ host, port }: { host: string; port: number }): boolean {
|
||||
return host === LOCAL_EGRESS_READINESS_TARGET.host && port === LOCAL_EGRESS_READINESS_TARGET.port;
|
||||
}
|
||||
|
||||
/**
|
||||
* On the emulator's Wi-Fi path, a guest connection to the guest proxy
|
||||
* (10.0.2.2:<relay port>) is itself wrapped in `-http-proxy`'s CONNECT.
|
||||
*/
|
||||
function isRelayTarget({ host, port }: { host: string; port: number }, relayPort: number): boolean {
|
||||
return (
|
||||
port === relayPort &&
|
||||
(host === LOCAL_EGRESS_PROXY_HOST ||
|
||||
host === ANDROID_EMULATOR_HOST_LOOPBACK_ALIAS ||
|
||||
host === 'localhost')
|
||||
);
|
||||
}
|
||||
|
||||
export type LocalEgressRelay = {
|
||||
port: number;
|
||||
setLogger: (logger: bunyan) => void;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function startLocalEgressRelayAsync({
|
||||
port = LOCAL_EGRESS_RELAY_PORT,
|
||||
upstreamPort,
|
||||
logger,
|
||||
}: {
|
||||
port?: number;
|
||||
upstreamPort: number;
|
||||
logger: bunyan;
|
||||
}): Promise<LocalEgressRelay> {
|
||||
let currentLogger = logger;
|
||||
let refusedWhileDetached = 0;
|
||||
const sockets = new Set<net.Socket>();
|
||||
const track = (socket: net.Socket): void => {
|
||||
sockets.add(socket);
|
||||
socket.on('error', () => socket.destroy());
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
};
|
||||
const endWith = (client: net.Socket, response: string): void => {
|
||||
client.end(response, () => client.destroy());
|
||||
};
|
||||
|
||||
const refuse = (client: net.Socket): void => {
|
||||
if (refusedWhileDetached === 0) {
|
||||
currentLogger.warn(
|
||||
'Local egress: the Android emulator made a request while the EAS CLI egress client is not ' +
|
||||
'connected, so it was refused. Requests from the emulator fail until the client connects.'
|
||||
);
|
||||
}
|
||||
refusedWhileDetached++;
|
||||
endWith(client, CLIENT_NOT_CONNECTED);
|
||||
};
|
||||
|
||||
const attached = (): void => {
|
||||
if (refusedWhileDetached > 0) {
|
||||
currentLogger.info(
|
||||
`Local egress: the EAS CLI egress client is connected. ${refusedWhileDetached} request(s) ` +
|
||||
'from the Android emulator were refused while it was not.'
|
||||
);
|
||||
refusedWhileDetached = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const forward = (client: net.Socket, buffered: Buffer): void => {
|
||||
const upstream = net.connect({ host: LOCAL_EGRESS_PROXY_HOST, port: upstreamPort });
|
||||
track(upstream);
|
||||
let connected = false;
|
||||
upstream.once('connect', () => {
|
||||
connected = true;
|
||||
attached();
|
||||
if (buffered.length > 0) {
|
||||
upstream.write(buffered);
|
||||
}
|
||||
client.pipe(upstream);
|
||||
upstream.pipe(client);
|
||||
client.resume();
|
||||
});
|
||||
upstream.once('error', () => {
|
||||
if (!connected && !client.destroyed) {
|
||||
refuse(client);
|
||||
}
|
||||
});
|
||||
upstream.once('close', () => {
|
||||
if (connected) {
|
||||
client.destroy();
|
||||
}
|
||||
});
|
||||
client.once('close', () => upstream.destroy());
|
||||
};
|
||||
|
||||
const route = (client: net.Socket, initial: Buffer): void => {
|
||||
let buffered = initial;
|
||||
const decide = (): void => {
|
||||
const headEnd = buffered.indexOf('\r\n\r\n');
|
||||
if (headEnd === -1 && buffered.length < RELAY_MAX_HEAD_BYTES) {
|
||||
return;
|
||||
}
|
||||
client.off('data', onData);
|
||||
client.pause();
|
||||
const target =
|
||||
headEnd === -1
|
||||
? null
|
||||
: parseConnectAuthority(buffered.subarray(0, headEnd + 2).toString('latin1'));
|
||||
if (target && isReadinessTarget(target)) {
|
||||
endWith(client, CONNECTION_ESTABLISHED);
|
||||
} else if (target && isRelayTarget(target, port)) {
|
||||
client.write(CONNECTION_ESTABLISHED);
|
||||
route(client, buffered.subarray(headEnd + 4));
|
||||
} else {
|
||||
forward(client, buffered);
|
||||
}
|
||||
};
|
||||
const onData = (chunk: Buffer): void => {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
decide();
|
||||
};
|
||||
client.on('data', onData);
|
||||
client.resume();
|
||||
if (buffered.length > 0) {
|
||||
decide();
|
||||
}
|
||||
};
|
||||
|
||||
const server = net.createServer(client => {
|
||||
track(client);
|
||||
route(client, Buffer.alloc(0));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(port, LOCAL_EGRESS_PROXY_HOST, () => {
|
||||
server.removeListener('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
server.unref();
|
||||
|
||||
return {
|
||||
port,
|
||||
setLogger: nextLogger => {
|
||||
currentLogger = nextLogger;
|
||||
},
|
||||
stopAsync: async () => {
|
||||
await new Promise<void>(resolve => {
|
||||
server.close(() => resolve());
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildEgressFenceRuleset({ cgroupLevel }: { cgroupLevel: number }): string {
|
||||
return [
|
||||
`table inet ${EGRESS_FENCE_TABLE} {`,
|
||||
' set fenced { type cgroupsv2; }',
|
||||
' counter fence_tcp {}',
|
||||
' counter fence_udp {}',
|
||||
' counter fence_other {}',
|
||||
' chain out {',
|
||||
' type filter hook output priority filter; policy accept;',
|
||||
' ct state established,related accept',
|
||||
` socket cgroupv2 level ${cgroupLevel} @fenced jump fence`,
|
||||
' }',
|
||||
' chain fence {',
|
||||
// The emulator and netsimd talk to each other and to adb over loopback TCP;
|
||||
// guest TCP reaches loopback only through the proxy.
|
||||
' oif "lo" meta l4proto tcp accept',
|
||||
' oif "lo" ip daddr 127.0.0.53 udp dport 53 accept',
|
||||
` limit rate 50/second burst 200 packets log prefix "${FENCE_LOG_PREFIX} " level info`,
|
||||
' meta l4proto tcp counter name fence_tcp reject with tcp reset',
|
||||
' meta l4proto udp counter name fence_udp reject with icmpx admin-prohibited',
|
||||
' counter name fence_other reject with icmpx admin-prohibited',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function compressIpv6(address: string): string {
|
||||
try {
|
||||
return new URL(`http://[${address}]`).hostname.slice(1, -1);
|
||||
} catch {
|
||||
return address;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One kernel log line from the fence's `log` rule, for example
|
||||
* `eas-egress IN= OUT=ens3 SRC=10.0.0.2 DST=142.250.1.1 LEN=60 ... PROTO=TCP SPT=40000 DPT=443 ...`.
|
||||
* nft sees the emulator's sockets, not the guest app behind them, so events
|
||||
* carry no pid or callers.
|
||||
*/
|
||||
export function parseFenceLogLine(line: string): GuardEvent | null {
|
||||
const match = new RegExp(`${FENCE_LOG_PREFIX} (IN=.*)$`).exec(line);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const fields = new Map<string, string>();
|
||||
for (const token of match[1].split(/\s+/)) {
|
||||
const separator = token.indexOf('=');
|
||||
if (separator > 0) {
|
||||
fields.set(token.slice(0, separator), token.slice(separator + 1));
|
||||
}
|
||||
}
|
||||
const protocol = fields.get('PROTO')?.toLowerCase();
|
||||
const destination = fields.get('DST');
|
||||
if (!protocol || !destination) {
|
||||
return null;
|
||||
}
|
||||
const fn = protocol === 'icmpv6' ? 'icmp' : protocol;
|
||||
const host = net.isIPv6(destination) ? compressIpv6(destination) : destination;
|
||||
const destinationPort = fields.get('DPT');
|
||||
const peer = destinationPort
|
||||
? `${net.isIPv6(host) ? `[${host}]` : host}:${destinationPort}`
|
||||
: host;
|
||||
return {
|
||||
process: FENCED_PROCESS_LABEL,
|
||||
pid: null,
|
||||
function: fn,
|
||||
action: 'blocked',
|
||||
peer,
|
||||
callers: [],
|
||||
note: describeFenceEvent(fn, destinationPort),
|
||||
};
|
||||
}
|
||||
|
||||
function describeFenceEvent(fn: string, destinationPort: string | undefined): string | undefined {
|
||||
if (fn === 'tcp') {
|
||||
return 'a connection the emulator itself opened, such as its update check; app TCP goes through the proxy';
|
||||
}
|
||||
if (fn === 'udp' && destinationPort === '443') {
|
||||
return 'QUIC; clients usually retry over TCP through the proxy';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type FenceCounters = { tcp: number; udp: number; other: number };
|
||||
|
||||
/** `nft -j list counters table inet eas_fence` output. */
|
||||
export function parseFenceCounters(json: string): FenceCounters {
|
||||
const counters: FenceCounters = { tcp: 0, udp: 0, other: 0 };
|
||||
const parsed = JSON.parse(json) as {
|
||||
nftables?: { counter?: { name?: string; packets?: number } }[];
|
||||
};
|
||||
for (const entry of parsed.nftables ?? []) {
|
||||
const name = entry.counter?.name;
|
||||
const packets = entry.counter?.packets ?? 0;
|
||||
if (name === 'fence_tcp') {
|
||||
counters.tcp = packets;
|
||||
} else if (name === 'fence_udp') {
|
||||
counters.udp = packets;
|
||||
} else if (name === 'fence_other') {
|
||||
counters.other = packets;
|
||||
}
|
||||
}
|
||||
return counters;
|
||||
}
|
||||
|
||||
export function getCgroupLevel(cgroupPath: string): number {
|
||||
return cgroupPath.split('/').filter(Boolean).length;
|
||||
}
|
||||
|
||||
async function startSessionScopeAsync({ env }: { env: BuildStepEnv }): Promise<string> {
|
||||
const scope = spawn(
|
||||
'sudo',
|
||||
[
|
||||
'systemd-run',
|
||||
'--scope',
|
||||
'--quiet',
|
||||
`--unit=${LOCAL_EGRESS_SESSION_UNIT}`,
|
||||
'--',
|
||||
'sleep',
|
||||
'infinity',
|
||||
],
|
||||
{ env, detached: true, stdio: 'ignore' }
|
||||
);
|
||||
scope.child.unref();
|
||||
let exited = false;
|
||||
void asyncResult(scope).then(() => {
|
||||
exited = true;
|
||||
});
|
||||
|
||||
const deadline = Date.now() + SCOPE_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await asyncResult(
|
||||
spawn(
|
||||
'systemctl',
|
||||
['show', '-p', 'ControlGroup', '--value', `${LOCAL_EGRESS_SESSION_UNIT}.scope`],
|
||||
{ env, stdio: 'pipe' }
|
||||
)
|
||||
);
|
||||
const cgroupPath = result.ok ? result.value.stdout.trim().replace(/^\//, '') : '';
|
||||
if (cgroupPath) {
|
||||
return cgroupPath;
|
||||
}
|
||||
if (exited) {
|
||||
break;
|
||||
}
|
||||
await sleepAsync(200);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Could not start the ${LOCAL_EGRESS_SESSION_UNIT} systemd scope for the Android emulator, so ` +
|
||||
'local egress cannot keep its traffic on the tunnel. Retry the session; if it keeps failing, ' +
|
||||
'please contact support.'
|
||||
);
|
||||
}
|
||||
|
||||
async function loadFenceAsync({
|
||||
cgroupPath,
|
||||
workDir,
|
||||
env,
|
||||
}: {
|
||||
cgroupPath: string;
|
||||
workDir: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
const rulesetPath = path.join(workDir, 'fence.nft');
|
||||
await fs.promises.writeFile(
|
||||
rulesetPath,
|
||||
buildEgressFenceRuleset({ cgroupLevel: getCgroupLevel(cgroupPath) })
|
||||
);
|
||||
await asyncResult(
|
||||
spawn('sudo', ['nft', 'delete', 'table', 'inet', EGRESS_FENCE_TABLE], { env, stdio: 'pipe' })
|
||||
);
|
||||
try {
|
||||
await spawn('sudo', ['nft', '-f', rulesetPath], { env, stdio: 'pipe' });
|
||||
await spawn(
|
||||
'sudo',
|
||||
['nft', 'add', 'element', 'inet', EGRESS_FENCE_TABLE, 'fenced', `{ "${cgroupPath}" }`],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
} catch (err) {
|
||||
const failed = err as { stderr?: string };
|
||||
throw new SystemError(
|
||||
'Could not install the network fence for the Android emulator, so local egress cannot keep its ' +
|
||||
'traffic on the tunnel. Retry the session; if it keeps failing, please contact support.' +
|
||||
(failed.stderr ? ` nft output: ${failed.stderr.trim()}` : ''),
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function readFenceCountersAsync({ env }: { env: BuildStepEnv }): Promise<FenceCounters> {
|
||||
const result = await spawn(
|
||||
'sudo',
|
||||
['nft', '-j', 'list', 'counters', 'table', 'inet', EGRESS_FENCE_TABLE],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
return parseFenceCounters(result.stdout);
|
||||
}
|
||||
|
||||
function startFenceLogReader({
|
||||
env,
|
||||
sinceEpochSeconds,
|
||||
relay,
|
||||
getLogger,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
sinceEpochSeconds: number;
|
||||
relay: GuardEventRelay;
|
||||
getLogger: () => bunyan;
|
||||
}): { stopAsync: () => Promise<void> } {
|
||||
const reader = spawn(
|
||||
'sudo',
|
||||
['journalctl', '-k', '-f', '-o', 'cat', '--since', `@${sinceEpochSeconds}`],
|
||||
{ env, stdio: ['ignore', 'pipe', 'pipe'], ignoreStdio: true }
|
||||
);
|
||||
let stopping = false;
|
||||
const lines = reader.child.stdout
|
||||
? readline.createInterface({ input: reader.child.stdout }).on('line', line => {
|
||||
const event = parseFenceLogLine(line);
|
||||
if (event) {
|
||||
relay.handle(event);
|
||||
}
|
||||
})
|
||||
: undefined;
|
||||
const finished = asyncResult(reader).then(() => {
|
||||
if (!stopping) {
|
||||
getLogger().warn(
|
||||
'Local egress: stopped reading the kernel log, so further connections the Android emulator ' +
|
||||
'fence refuses are not listed here. They are still refused.'
|
||||
);
|
||||
}
|
||||
});
|
||||
reader.child.unref();
|
||||
for (const stream of [reader.child.stdout, reader.child.stderr]) {
|
||||
if (stream instanceof net.Socket) {
|
||||
stream.unref();
|
||||
}
|
||||
}
|
||||
return {
|
||||
stopAsync: async () => {
|
||||
stopping = true;
|
||||
// Kernel log lines arrive slightly after the packet; give the last ones a moment.
|
||||
await sleepAsync(500);
|
||||
reader.child.kill('SIGTERM');
|
||||
await finished;
|
||||
lines?.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Process coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type EmulatorProcess = { pid: number; comm: string; cgroup: string };
|
||||
|
||||
export async function listEmulatorProcessesAsync(procRoot = '/proc'): Promise<EmulatorProcess[]> {
|
||||
const entries = await fs.promises.readdir(procRoot);
|
||||
const processes: EmulatorProcess[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!/^\d+$/.test(entry)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const comm = (await fs.promises.readFile(path.join(procRoot, entry, 'comm'), 'utf8')).trim();
|
||||
if (!FENCED_PROCESS_PATTERN.test(comm)) {
|
||||
continue;
|
||||
}
|
||||
const cgroupFile = await fs.promises.readFile(path.join(procRoot, entry, 'cgroup'), 'utf8');
|
||||
const cgroup = /^0::(\S*)$/m.exec(cgroupFile)?.[1] ?? '';
|
||||
processes.push({ pid: Number(entry), comm, cgroup });
|
||||
} catch {
|
||||
// The process exited while it was being read.
|
||||
}
|
||||
}
|
||||
return processes;
|
||||
}
|
||||
|
||||
export function findProcessesOutsideScope(
|
||||
processes: readonly EmulatorProcess[],
|
||||
cgroupPath: string
|
||||
): EmulatorProcess[] {
|
||||
const scope = `/${cgroupPath}`;
|
||||
return processes.filter(({ cgroup }) => cgroup !== scope && !cgroup.startsWith(`${scope}/`));
|
||||
}
|
||||
|
||||
async function assertEmulatorProcessesFencedAsync({
|
||||
cgroupPath,
|
||||
when,
|
||||
}: {
|
||||
cgroupPath: string;
|
||||
when: string;
|
||||
}): Promise<void> {
|
||||
const outside = findProcessesOutsideScope(await listEmulatorProcessesAsync(), cgroupPath);
|
||||
if (outside.length === 0) {
|
||||
return;
|
||||
}
|
||||
throw new SystemError(
|
||||
`Android emulator processes are running outside the local egress fence ${when}: ${outside
|
||||
.map(({ comm, pid, cgroup }) => `${comm} (pid ${pid}, ${cgroup || 'unknown cgroup'})`)
|
||||
.join(
|
||||
', '
|
||||
)}. Their traffic would not use the tunnel, so the session was stopped. Retry the ` +
|
||||
'session; if it keeps failing, please contact support.'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// adb reverse for --egress-allow localhost:<port>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Loopback ports `pid` listens on, from `ss -Hltnp`. */
|
||||
export function parseLoopbackListenerPorts(ssOutput: string, pid: number): number[] {
|
||||
const ports = new Set<number>();
|
||||
for (const line of ssOutput.split('\n')) {
|
||||
if (!line.includes(`pid=${pid},`)) {
|
||||
continue;
|
||||
}
|
||||
const match = /\s127\.0\.0\.1:(\d+)\s/.exec(line);
|
||||
if (match) {
|
||||
ports.add(Number(match[1]));
|
||||
}
|
||||
}
|
||||
return [...ports].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function parseAdbReversePorts(output: string): Set<number> {
|
||||
const ports = new Set<number>();
|
||||
for (const match of output.matchAll(/tcp:(\d+)\s+tcp:\d+/g)) {
|
||||
ports.add(Number(match[1]));
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
/**
|
||||
* The EAS CLI forwards each `--egress-allow localhost:<port>` to the same
|
||||
* port on this host's loopback, where the guest cannot see it. `adb reverse`
|
||||
* makes guest `localhost:<port>` reach it. Reverse mappings are lost when an
|
||||
* emulator or the adb server restarts, so this runs on a timer.
|
||||
*/
|
||||
async function reconcileAdbReverseAsync({
|
||||
env,
|
||||
logger,
|
||||
chiselPid,
|
||||
tunnelPorts,
|
||||
reported,
|
||||
serialIds,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
chiselPid: number;
|
||||
/** chisel's own listeners: the proxy port and the control port ngrok forwards to. */
|
||||
tunnelPorts: readonly number[];
|
||||
reported: Set<string>;
|
||||
serialIds?: AndroidDeviceSerialId[];
|
||||
}): Promise<void> {
|
||||
const listeners = await spawn('ss', ['-Hltnp'], { env, stdio: 'pipe' });
|
||||
const ports = parseLoopbackListenerPorts(listeners.stdout, chiselPid).filter(
|
||||
port => !tunnelPorts.includes(port)
|
||||
);
|
||||
if (ports.length === 0) {
|
||||
return;
|
||||
}
|
||||
const serials =
|
||||
serialIds ??
|
||||
(await spawn('adb', ['devices'], { env, stdio: 'pipe' })).stdout
|
||||
.split('\n')
|
||||
.map(line => /^(emulator-\d+)\s+device\b/.exec(line)?.[1])
|
||||
.filter((serial): serial is AndroidDeviceSerialId => !!serial);
|
||||
for (const serial of serials) {
|
||||
const list = await asyncResult(
|
||||
spawn('adb', ['-s', serial, 'reverse', '--list'], { env, stdio: 'pipe' })
|
||||
);
|
||||
const existing = list.ok ? parseAdbReversePorts(list.value.stdout) : new Set<number>();
|
||||
for (const port of ports) {
|
||||
if (existing.has(port)) {
|
||||
continue;
|
||||
}
|
||||
const result = await asyncResult(
|
||||
spawn('adb', ['-s', serial, 'reverse', `tcp:${port}`, `tcp:${port}`], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
);
|
||||
const key = `${serial}:${port}`;
|
||||
if (result.ok && !reported.has(key)) {
|
||||
reported.add(key);
|
||||
logger.info(
|
||||
`Local egress: localhost:${port} in ${serial} now reaches port ${port} on the EAS CLI machine.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AndroidLocalEgressSession = {
|
||||
cgroupPath: string;
|
||||
env: BuildStepEnv;
|
||||
chiselPid: number;
|
||||
tunnelPorts: number[];
|
||||
relay: LocalEgressRelay;
|
||||
reportedReverse: Set<string>;
|
||||
reverseTimer?: NodeJS.Timeout;
|
||||
logger: bunyan;
|
||||
};
|
||||
|
||||
let activeSession: AndroidLocalEgressSession | undefined;
|
||||
|
||||
/**
|
||||
* Start the relay, the session scope and the fence, and begin reporting fence
|
||||
* events. Runs in `eas/start_local_egress`, before any emulator starts. The
|
||||
* fence and scope stay in place until the job ends: removing them while an
|
||||
* emulator still runs would let its traffic leave from this host.
|
||||
*/
|
||||
export async function startAndroidLocalEgressAsync({
|
||||
env,
|
||||
logger,
|
||||
workDir,
|
||||
chiselPid,
|
||||
proxyPort,
|
||||
controlPort,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
workDir: string;
|
||||
chiselPid: number;
|
||||
proxyPort: number;
|
||||
controlPort: number;
|
||||
}): Promise<{ stopAsync: () => Promise<void> }> {
|
||||
if (activeSession) {
|
||||
throw new SystemError('Android local egress is already started for this job.');
|
||||
}
|
||||
const relay = await startLocalEgressRelayAsync({ upstreamPort: proxyPort, logger });
|
||||
try {
|
||||
const cgroupPath = await startSessionScopeAsync({ env });
|
||||
const sinceEpochSeconds = Math.floor(Date.now() / 1000) - 1;
|
||||
await loadFenceAsync({ cgroupPath, workDir, env });
|
||||
|
||||
const guardRelay = new GuardEventRelay(logger);
|
||||
let guardLogger = logger;
|
||||
const reader = startFenceLogReader({
|
||||
env,
|
||||
sinceEpochSeconds,
|
||||
relay: guardRelay,
|
||||
getLogger: () => guardLogger,
|
||||
});
|
||||
registerLocalEgressGuardRelay(FENCE_RELAY_KEY, {
|
||||
source: reader,
|
||||
relay: guardRelay,
|
||||
onRebind: nextLogger => {
|
||||
guardLogger = nextLogger;
|
||||
relay.setLogger(nextLogger);
|
||||
},
|
||||
afterSummaryAsync: async () => {
|
||||
const { tcp, udp, other } = await readFenceCountersAsync({ env });
|
||||
guardLogger.info(
|
||||
`Local egress fence: refused ${tcp} TCP, ${udp} UDP and ${other} other packet(s) from the ` +
|
||||
'Android emulator in total, including retries and any not listed above.'
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const session: AndroidLocalEgressSession = {
|
||||
cgroupPath,
|
||||
env,
|
||||
chiselPid,
|
||||
tunnelPorts: [proxyPort, controlPort],
|
||||
relay,
|
||||
reportedReverse: new Set(),
|
||||
logger,
|
||||
};
|
||||
session.reverseTimer = setInterval(() => {
|
||||
void reconcileAdbReverseAsync({
|
||||
env,
|
||||
logger: guardLogger,
|
||||
chiselPid,
|
||||
tunnelPorts: session.tunnelPorts,
|
||||
reported: session.reportedReverse,
|
||||
}).catch(() => {});
|
||||
}, ADB_REVERSE_INTERVAL_MS);
|
||||
session.reverseTimer.unref();
|
||||
activeSession = session;
|
||||
logger.info(
|
||||
`Local egress: Android emulator processes run in ${cgroupPath}, and the network fence ` +
|
||||
'refuses traffic that cannot use the tunnel and reports it here.'
|
||||
);
|
||||
} catch (err) {
|
||||
await relay.stopAsync();
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
stopAsync: async () => {
|
||||
const session = activeSession;
|
||||
activeSession = undefined;
|
||||
if (session?.reverseTimer) {
|
||||
clearInterval(session.reverseTimer);
|
||||
}
|
||||
await relay.stopAsync();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type AndroidEmulatorLocalEgress = {
|
||||
launchGate: AndroidEmulatorLaunchGate;
|
||||
networkReadyTarget: { host: string; port: number };
|
||||
configureBootedEmulatorAsync: (args: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
logger: bunyan;
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* What `eas/start_android_emulator` changes for a local egress session, or
|
||||
* null for every other session, which then starts the emulator exactly as
|
||||
* before.
|
||||
*/
|
||||
export async function resolveAndroidEmulatorLocalEgressAsync({
|
||||
handoffPath = LOCAL_EGRESS_HANDOFF_PATH,
|
||||
}: { handoffPath?: string } = {}): Promise<AndroidEmulatorLocalEgress | null> {
|
||||
const handoff = await readLocalEgressHandoffAsync(handoffPath);
|
||||
if (!handoff) {
|
||||
return null;
|
||||
}
|
||||
const session = activeSession;
|
||||
if (handoff.platform !== 'android' || !session) {
|
||||
throw new SystemError(
|
||||
'This session uses local egress, but the Android emulator fence was not started, so the ' +
|
||||
"emulator's traffic would not use the tunnel. Please contact support."
|
||||
);
|
||||
}
|
||||
const relayAddress = `${LOCAL_EGRESS_PROXY_HOST}:${session.relay.port}`;
|
||||
return {
|
||||
launchGate: {
|
||||
emulatorArgs: ['-http-proxy', relayAddress, '-no-metrics'],
|
||||
wrapperCommand: 'bash',
|
||||
wrapperArgs: ['-c', GATE_SCRIPT, 'eas-egress-gate'],
|
||||
admitAsync: async pid => {
|
||||
await assertEmulatorProcessesFencedAsync({
|
||||
cgroupPath: session.cgroupPath,
|
||||
when: 'before the emulator started',
|
||||
});
|
||||
await admitToScopeAsync({ pid, cgroupPath: session.cgroupPath, env: session.env });
|
||||
},
|
||||
},
|
||||
networkReadyTarget: LOCAL_EGRESS_READINESS_TARGET,
|
||||
configureBootedEmulatorAsync: async ({ serialId, logger }) => {
|
||||
await assertEmulatorProcessesFencedAsync({
|
||||
cgroupPath: session.cgroupPath,
|
||||
when: 'after boot',
|
||||
});
|
||||
const guestProxy = `${ANDROID_EMULATOR_HOST_LOOPBACK_ALIAS}:${session.relay.port}`;
|
||||
const proxyResult = await asyncResult(
|
||||
spawn(
|
||||
'adb',
|
||||
['-s', serialId, 'shell', 'settings', 'put', 'global', 'http_proxy', guestProxy],
|
||||
{
|
||||
env: session.env,
|
||||
stdio: 'pipe',
|
||||
}
|
||||
)
|
||||
);
|
||||
if (proxyResult.ok) {
|
||||
logger.info(
|
||||
`Local egress: the system proxy in ${serialId} is ${guestProxy}, so apps that honor it ` +
|
||||
"resolve hostnames on the EAS CLI machine. Other TCP still exits from that machine through the emulator's proxy."
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
{ err: proxyResult.reason },
|
||||
`Local egress: could not set the system proxy in ${serialId}. Traffic still exits from the ` +
|
||||
"EAS CLI machine through the emulator's proxy, but hostnames resolve on this worker."
|
||||
);
|
||||
}
|
||||
await reconcileAdbReverseAsync({
|
||||
env: session.env,
|
||||
logger,
|
||||
chiselPid: session.chiselPid,
|
||||
tunnelPorts: session.tunnelPorts,
|
||||
reported: session.reportedReverse,
|
||||
serialIds: [serialId],
|
||||
}).catch(err =>
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Local egress: could not forward allowed localhost ports to ${serialId}.`
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function admitToScopeAsync({
|
||||
pid,
|
||||
cgroupPath,
|
||||
env,
|
||||
}: {
|
||||
pid: number;
|
||||
cgroupPath: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
const procsPath = `/sys/fs/cgroup/${cgroupPath}/cgroup.procs`;
|
||||
try {
|
||||
await spawn('sudo', ['sh', '-c', 'echo "$1" > "$2"', 'sh', String(pid), procsPath], {
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const cgroupFile = await fs.promises.readFile(`/proc/${pid}/cgroup`, 'utf8');
|
||||
if (!cgroupFile.includes(`0::/${cgroupPath}\n`)) {
|
||||
throw new Error(`/proc/${pid}/cgroup is ${cgroupFile.trim()}`);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
'Could not move the Android emulator into the local egress fence, so its traffic would not use ' +
|
||||
'the tunnel. Retry the session; if it keeps failing, please contact support.',
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch, { Headers } from 'node-fetch';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import {
|
||||
DeviceRunSessionTimeoutError,
|
||||
withDeviceRunSessionTimeoutAsync,
|
||||
} from './deviceRunSessionTimeout';
|
||||
|
||||
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateDeviceRunSessionArtifactUploadSession(
|
||||
@@ -20,82 +27,178 @@ const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
}
|
||||
`);
|
||||
|
||||
type ArtifactMetadata = {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
};
|
||||
|
||||
type ArtifactSource = { stream: Readable; reopenStream?: () => Readable };
|
||||
type ArtifactUploadSession = Awaited<
|
||||
ReturnType<typeof createDeviceRunSessionArtifactUploadSessionAsync>
|
||||
>;
|
||||
|
||||
export async function uploadDeviceRunSessionArtifactAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
stream,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
stream: NodeJS.ReadableStream;
|
||||
}
|
||||
options: ArtifactMetadata & ArtifactSource & { signal?: AbortSignal }
|
||||
): Promise<void> {
|
||||
const uploadSession = await createDeviceRunSessionArtifactUploadSessionAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
});
|
||||
const response = await fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
headers: new Headers(uploadSession.headers as Record<string, string>),
|
||||
body: stream,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Failed to upload device run session artifact ${artifactId}: ${response.status} ${response.statusText}`,
|
||||
{ cause: response }
|
||||
try {
|
||||
const uploadSession = await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Artifact upload session creation', timeoutMs: 15_000, signal: options.signal },
|
||||
async signal => await createDeviceRunSessionArtifactUploadSessionAsync(ctx, options, signal)
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
await putArtifactWithRetriesAsync({
|
||||
uploadSession,
|
||||
source: options,
|
||||
artifactId: options.artifactId,
|
||||
signal: options.signal,
|
||||
});
|
||||
} finally {
|
||||
// One-shot callers may have opened their stream before upload-session creation failed.
|
||||
options.stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function putArtifactWithRetriesAsync({
|
||||
uploadSession,
|
||||
source,
|
||||
artifactId,
|
||||
signal,
|
||||
}: {
|
||||
uploadSession: ArtifactUploadSession;
|
||||
source: ArtifactSource;
|
||||
artifactId: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const attempts = source.reopenStream ? 3 : 1;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
signal?.throwIfAborted();
|
||||
const stream = attempt > 0 && source.reopenStream ? source.reopenStream() : source.stream;
|
||||
try {
|
||||
// Upload time scales with file size, so the deadline bounds stalls, not the whole request.
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Artifact PUT', timeoutMs: 90_000, signal },
|
||||
async (putSignal, resetDeadline) =>
|
||||
await putArtifactAsync({
|
||||
uploadSession,
|
||||
stream,
|
||||
artifactId,
|
||||
signal: putSignal,
|
||||
onProgress: resetDeadline,
|
||||
})
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
const retryable =
|
||||
error instanceof ArtifactPutError
|
||||
? [408, 429, 500, 502, 503, 504].includes(error.status)
|
||||
: error instanceof DeviceRunSessionTimeoutError ||
|
||||
error instanceof ArtifactPutNetworkError;
|
||||
if (!retryable || attempt + 1 === attempts) {
|
||||
throw error;
|
||||
}
|
||||
await delay(500 * (attempt + 1), undefined, { signal });
|
||||
} finally {
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function putArtifactAsync({
|
||||
uploadSession,
|
||||
stream,
|
||||
artifactId,
|
||||
signal,
|
||||
onProgress,
|
||||
}: {
|
||||
uploadSession: ArtifactUploadSession;
|
||||
stream: Readable;
|
||||
artifactId: string;
|
||||
signal: AbortSignal;
|
||||
onProgress: () => void;
|
||||
}): Promise<void> {
|
||||
const requestController = new AbortController();
|
||||
try {
|
||||
const responding = fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
headers: new Headers(uploadSession.headers as Record<string, string>),
|
||||
body: stream,
|
||||
signal: AbortSignal.any([signal, requestController.signal]),
|
||||
});
|
||||
// fetch has piped the body already, so this listener observes flow without starting it.
|
||||
stream.on('data', onProgress);
|
||||
let response;
|
||||
try {
|
||||
response = await responding;
|
||||
} catch (error) {
|
||||
throw error instanceof Error && error.name === 'FetchError'
|
||||
? new ArtifactPutNetworkError(artifactId, error)
|
||||
: error;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
if (!response.ok) {
|
||||
throw new ArtifactPutError(artifactId, response);
|
||||
}
|
||||
} finally {
|
||||
// node-fetch destroys the request body on abort, and its response stream is a PassThrough
|
||||
// whose destruction leaves the socket open, so the request itself is aborted here.
|
||||
requestController.abort();
|
||||
}
|
||||
}
|
||||
|
||||
class ArtifactPutError extends SystemError {
|
||||
readonly status: number;
|
||||
|
||||
constructor(artifactId: string, response: { status: number; statusText: string }) {
|
||||
super(
|
||||
`Failed to upload device run session artifact ${artifactId}: HTTP ${response.status} ${response.statusText}.`
|
||||
);
|
||||
this.status = response.status;
|
||||
}
|
||||
}
|
||||
|
||||
// node-fetch names the request URL in its message, and here that URL is signed. The message keeps
|
||||
// only the error code, and the original is not kept as the cause because loggers and Sentry print causes.
|
||||
class ArtifactPutNetworkError extends SystemError {
|
||||
readonly code: string | undefined;
|
||||
|
||||
constructor(artifactId: string, error: Error & { code?: unknown }) {
|
||||
const code = typeof error.code === 'string' ? error.code : undefined;
|
||||
super(
|
||||
`Failed to upload device run session artifact ${artifactId}: ${code ?? 'network error'}.`
|
||||
);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeviceRunSessionArtifactUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
kind,
|
||||
metadata,
|
||||
size,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
kind: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
size: number;
|
||||
}
|
||||
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION, {
|
||||
deviceRunSessionId,
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
.mutation(
|
||||
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
...(kind !== undefined ? { kind } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
size,
|
||||
},
|
||||
},
|
||||
})
|
||||
graphqlAbortContext(signal)
|
||||
)
|
||||
.toPromise();
|
||||
signal.throwIfAborted();
|
||||
if (result.error) {
|
||||
throw new SystemError(
|
||||
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import limitFactory from 'promise-limit';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
const RecordingsSchema = z.array(
|
||||
z.object({
|
||||
udid: z.string(),
|
||||
deviceName: z.string(),
|
||||
runtimeDisplayName: z.string(),
|
||||
directory: z.string(),
|
||||
})
|
||||
);
|
||||
|
||||
const RecordingManifestSchema = z.object({
|
||||
firstFrameWallClock: z.object({
|
||||
iso8601: z.string(),
|
||||
}),
|
||||
width: z.number().int().positive(),
|
||||
height: z.number().int().positive(),
|
||||
recording: z.string(),
|
||||
status: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
const UnlistedRecordingManifestSchema = z.object({
|
||||
udid: z.string(),
|
||||
deviceName: z.string(),
|
||||
runtimeDisplayName: z.string(),
|
||||
status: z.enum(['recording', 'failed', 'complete']),
|
||||
recording: z.string(),
|
||||
});
|
||||
|
||||
const recordingStartTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3,
|
||||
hourCycle: 'h23',
|
||||
timeZone: 'UTC',
|
||||
timeZoneName: 'short',
|
||||
});
|
||||
|
||||
export function parseDeviceScreenRecordings(input: unknown): z.infer<typeof RecordingsSchema> {
|
||||
const result = RecordingsSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
throw new SystemError('Invalid device screen recordings input.', {
|
||||
cause: result.error,
|
||||
});
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Hub that was killed never lists its recording, even one it had already completed at the
|
||||
* duration limit. session.json is at "recording" or "failed" with a fragmented .partial that plays
|
||||
* up to its last keyframe, or at "complete" with the finished file. Return the ones ffprobe can read.
|
||||
*/
|
||||
export async function findUnlistedDeviceScreenRecordingsAsync({
|
||||
root,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
root: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<z.infer<typeof RecordingsSchema>> {
|
||||
const found: z.infer<typeof RecordingsSchema> = [];
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const directory = path.join(root, entry.name);
|
||||
const manifest = UnlistedRecordingManifestSchema.safeParse(
|
||||
await readFile(path.join(directory, 'session.json'), 'utf-8')
|
||||
.then(text => JSON.parse(text))
|
||||
.catch(() => null)
|
||||
);
|
||||
if (!manifest.success || path.basename(manifest.data.recording) !== manifest.data.recording) {
|
||||
continue;
|
||||
}
|
||||
const file = path.join(directory, manifest.data.recording);
|
||||
const name = path.join(entry.name, manifest.data.recording);
|
||||
let duration = 0;
|
||||
try {
|
||||
const probe = await spawn(
|
||||
'ffprobe',
|
||||
['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file],
|
||||
{ env, stdio: 'pipe' }
|
||||
);
|
||||
duration = Number(probe.stdout.trim());
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
logger.warn(`ffprobe is not available; cannot recover the unlisted recording ${name}.`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!(duration > 0)) {
|
||||
logger.warn(`Unlisted recording ${name} does not decode; skipping it.`);
|
||||
continue;
|
||||
}
|
||||
const { udid, deviceName, runtimeDisplayName } = manifest.data;
|
||||
found.push({ udid, deviceName, runtimeDisplayName, directory });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
export async function uploadDeviceRunSessionScreenRecordingsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
logger,
|
||||
deviceRunSessionId,
|
||||
recordings,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
deviceRunSessionId: string;
|
||||
recordings: z.infer<typeof RecordingsSchema>;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
if (recordings.length === 0) {
|
||||
logger.info('No device screen recordings found; skipping uploads.');
|
||||
return true;
|
||||
}
|
||||
|
||||
const limit = limitFactory(5);
|
||||
const uploaded = await Promise.all(
|
||||
recordings.map(recording =>
|
||||
limit(async () => {
|
||||
try {
|
||||
const metadata = RecordingManifestSchema.parse(
|
||||
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
|
||||
);
|
||||
if (path.basename(metadata.recording) !== metadata.recording) {
|
||||
throw new Error('Recording filename must not contain a directory.');
|
||||
}
|
||||
const startedAt = recordingStartTimeFormatter.format(
|
||||
new Date(metadata.firstFrameWallClock.iso8601)
|
||||
);
|
||||
const partial = metadata.status !== undefined && metadata.status !== 'complete';
|
||||
const shortUdid = `${recording.udid.slice(0, 8)}-…`;
|
||||
const displayName = `${recording.deviceName} screen recording (${shortUdid}, started at ${startedAt}${partial ? ', partial' : ''})`;
|
||||
const recordingPath = path.join(recording.directory, metadata.recording);
|
||||
const { size } = await stat(recordingPath);
|
||||
const recordingId = path.basename(recording.directory);
|
||||
logger.info(
|
||||
`Uploading screen recording for ${recording.deviceName} (${formatBytes(size)}).`
|
||||
);
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: recordingId,
|
||||
name: displayName,
|
||||
filename: `${recordingId}.mp4`,
|
||||
kind: 'screen-recording',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording',
|
||||
__eas_screen_recording: '1',
|
||||
udid: recording.udid,
|
||||
deviceName: recording.deviceName,
|
||||
runtimeDisplayName: recording.runtimeDisplayName,
|
||||
firstFrameAt: metadata.firstFrameWallClock.iso8601,
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
...(partial
|
||||
? {
|
||||
partial: true,
|
||||
partialReason:
|
||||
metadata.error ??
|
||||
'The Device Hub stopped before it could finalize the recording.',
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
size,
|
||||
stream: createReadStream(recordingPath),
|
||||
reopenStream: () => createReadStream(recordingPath),
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload device screen recording', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not upload screen recording for ${recording.deviceName}.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
return uploaded.every(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CustomBuildContext } from '../../customBuildContext';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
|
||||
const SHORT_COMMIT_HASH_LENGTH = 7;
|
||||
const MAX_APP_SLUG_LENGTH = 80;
|
||||
const SESSION_LOOKUP_TIMEOUT_MS = 5_000;
|
||||
// Runs of characters that are not letters, digits, dots, underscores, or hyphens; each run becomes one hyphen.
|
||||
const FILENAME_UNSAFE_RUN = /[^a-zA-Z0-9._-]+/g;
|
||||
// Dots and hyphens at either end of the slug, dropped so the name cannot start with a hidden-file dot.
|
||||
const LEADING_OR_TRAILING_DOTS_AND_HYPHENS = /^[.-]+|[.-]+$/g;
|
||||
// A full git commit hash: 40 hex characters for SHA-1 or 64 for SHA-256.
|
||||
const FULL_GIT_COMMIT_HASH = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
|
||||
|
||||
const SCREENSHOT_SESSION_QUERY = graphql(`
|
||||
query ScreenshotSession($deviceRunSessionId: ID!) {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
platform
|
||||
app { slug }
|
||||
build {
|
||||
id
|
||||
gitCommitHash
|
||||
app { slug }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const AppSchema = z.object({ slug: z.string() });
|
||||
const ScreenshotSessionSchema = z.object({
|
||||
platform: z.enum(['IOS', 'ANDROID']),
|
||||
app: AppSchema,
|
||||
build: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
gitCommitHash: z.string().nullable(),
|
||||
app: AppSchema,
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type ScreenshotSession = z.infer<typeof ScreenshotSessionSchema>;
|
||||
|
||||
export async function loadScreenshotSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
deviceRunSessionId: string,
|
||||
logger: bunyan
|
||||
): Promise<ScreenshotSession | null> {
|
||||
try {
|
||||
const result = await ctx.graphqlClient
|
||||
.query(
|
||||
SCREENSHOT_SESSION_QUERY,
|
||||
{ deviceRunSessionId },
|
||||
graphqlAbortContext(AbortSignal.timeout(SESSION_LOOKUP_TIMEOUT_MS))
|
||||
)
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return ScreenshotSessionSchema.parse(result.data?.deviceRunSessions.byId);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, deviceRunSessionId },
|
||||
'Could not load screenshot session details; using capture timestamps.'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function screenshotArtifactDetails(
|
||||
timestamp: string,
|
||||
session: ScreenshotSession | null
|
||||
): { name: string; filename: string; metadata: Record<string, string> } {
|
||||
const name = screenshotArtifactName(timestamp);
|
||||
if (!session) {
|
||||
return {
|
||||
name,
|
||||
filename: `screenshot-${timestamp}.png`,
|
||||
metadata: { __eas_type: 'screenshot' },
|
||||
};
|
||||
}
|
||||
|
||||
const app = session.build?.app ?? session.app;
|
||||
const appSlug =
|
||||
app.slug
|
||||
.replace(FILENAME_UNSAFE_RUN, '-')
|
||||
.replace(LEADING_OR_TRAILING_DOTS_AND_HYPHENS, '')
|
||||
.slice(0, MAX_APP_SLUG_LENGTH) || 'app';
|
||||
const platform = session.platform.toLowerCase();
|
||||
const filenameParts = [appSlug, platform];
|
||||
const metadata: Record<string, string> = {
|
||||
__eas_type: 'screenshot',
|
||||
appSlug: app.slug,
|
||||
platform,
|
||||
};
|
||||
|
||||
if (session.build) {
|
||||
metadata.buildId = session.build.id;
|
||||
const commitHash = session.build.gitCommitHash;
|
||||
const hasValidCommitHash = commitHash !== null && FULL_GIT_COMMIT_HASH.test(commitHash);
|
||||
if (hasValidCommitHash) {
|
||||
filenameParts.push(commitHash.slice(0, SHORT_COMMIT_HASH_LENGTH).toLowerCase());
|
||||
metadata.gitCommitHash = commitHash;
|
||||
}
|
||||
}
|
||||
filenameParts.push(timestamp);
|
||||
return { name, filename: `${filenameParts.join('-')}.png`, metadata };
|
||||
}
|
||||
|
||||
function screenshotArtifactName(timestamp: string): string {
|
||||
// The caller already matched the timestamp against the filename pattern, so this split cannot fail.
|
||||
const [date, time] = timestamp.split('T');
|
||||
const [hours, minutes, seconds] = time.split('-');
|
||||
return `Screenshot ${date} ${hours}:${minutes}:${seconds} UTC`;
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { type FSWatcher, createReadStream, watch } from 'node:fs';
|
||||
import { mkdtemp, readFile, readdir, rm, rmdir, stat } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
import {
|
||||
type ScreenshotSession,
|
||||
loadScreenshotSessionAsync,
|
||||
screenshotArtifactDetails,
|
||||
} from './deviceRunSessionScreenshotNames';
|
||||
|
||||
// The device-hub repository writes these files from
|
||||
// packages/serve-sim/packages/serve-sim/src/screenshot-artifacts.ts and
|
||||
// packages/serve-emu/packages/serve-emu/src/screenshot-artifacts.ts.
|
||||
// When a capture cannot be saved, the same files write a failure record in its place.
|
||||
// These patterns, the record schema and the producer code must change together.
|
||||
const SCREENSHOT_FILENAME_PATTERN =
|
||||
/^screenshot-(?<timestamp>\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)-[a-f0-9]{12}\.png$/;
|
||||
const SCREENSHOT_FAILURE_RECORD_PATTERN =
|
||||
/^screenshot-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z-[a-f0-9]{12}\.failed\.json$/;
|
||||
const ScreenshotFailureRecordSchema = z.object({
|
||||
file: z.string(),
|
||||
error: z.string(),
|
||||
at: z.string(),
|
||||
});
|
||||
|
||||
async function reportScreenshotFailureRecordAsync(file: string, logger: bunyan): Promise<void> {
|
||||
try {
|
||||
const record = ScreenshotFailureRecordSchema.parse(JSON.parse(await readFile(file, 'utf8')));
|
||||
logger.warn(
|
||||
{ file: record.file, error: record.error, at: record.at },
|
||||
'The session host could not save a preview screenshot.'
|
||||
);
|
||||
Sentry.capture(
|
||||
'The session host could not save a preview screenshot',
|
||||
new Error(record.error),
|
||||
{
|
||||
extras: { file: record.file, at: record.at },
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn({ err, file }, 'Could not read a preview screenshot failure record.');
|
||||
}
|
||||
await rm(file, { force: true });
|
||||
}
|
||||
|
||||
type FailedUploads = Map<string, { attempts: number; lastError: Error }>;
|
||||
|
||||
type ScreenshotEntry = { kind: 'failureRecord' } | { kind: 'screenshot'; timestamp: string };
|
||||
|
||||
function classifyScreenshotEntry(name: string): ScreenshotEntry | null {
|
||||
if (SCREENSHOT_FAILURE_RECORD_PATTERN.test(name)) {
|
||||
return { kind: 'failureRecord' };
|
||||
}
|
||||
const timestamp = SCREENSHOT_FILENAME_PATTERN.exec(name)?.groups?.timestamp;
|
||||
return timestamp ? { kind: 'screenshot', timestamp } : null;
|
||||
}
|
||||
|
||||
async function uploadScreenshotAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
file,
|
||||
name,
|
||||
timestamp,
|
||||
size,
|
||||
deviceRunSessionId,
|
||||
session,
|
||||
signal,
|
||||
failedUploads,
|
||||
logger,
|
||||
}: {
|
||||
file: string;
|
||||
name: string;
|
||||
timestamp: string;
|
||||
size: number;
|
||||
deviceRunSessionId: string;
|
||||
session: Promise<ScreenshotSession | null>;
|
||||
signal: AbortSignal;
|
||||
failedUploads: FailedUploads;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
const artifactDetails = screenshotArtifactDetails(timestamp, await session);
|
||||
const startedAt = Date.now();
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: name.slice(0, -4),
|
||||
name: artifactDetails.name,
|
||||
filename: artifactDetails.filename,
|
||||
metadata: artifactDetails.metadata,
|
||||
kind: 'screenshot',
|
||||
size,
|
||||
stream: createReadStream(file),
|
||||
reopenStream: () => createReadStream(file),
|
||||
signal,
|
||||
});
|
||||
await rm(file);
|
||||
const previousFailures = failedUploads.get(name)?.attempts ?? 0;
|
||||
failedUploads.delete(name);
|
||||
// Each earlier failed attempt may have left an artifact row without a file on the server.
|
||||
// The API deletes such rows when the job run finishes (expo/universe#31539).
|
||||
const retryNote =
|
||||
previousFailures > 0
|
||||
? ` after ${previousFailures} failed attempt${previousFailures === 1 ? '' : 's'}`
|
||||
: '';
|
||||
logger.info(
|
||||
{ file },
|
||||
`Uploaded preview screenshot ${artifactDetails.filename} (${formatBytes(size)}) in ${Date.now() - startedAt} ms${retryNote}.`
|
||||
);
|
||||
}
|
||||
|
||||
function recordUploadFailure(
|
||||
err: unknown,
|
||||
{
|
||||
file,
|
||||
name,
|
||||
size,
|
||||
signal,
|
||||
failedUploads,
|
||||
logger,
|
||||
}: {
|
||||
file: string;
|
||||
name: string;
|
||||
size: number | undefined;
|
||||
signal: AbortSignal;
|
||||
failedUploads: FailedUploads;
|
||||
logger: bunyan;
|
||||
}
|
||||
): 'aborted' | 'recorded' {
|
||||
if (signal.aborted) {
|
||||
logger.warn(
|
||||
{ file, size },
|
||||
'Shutdown deadline reached before this preview screenshot uploaded. Keeping the file.'
|
||||
);
|
||||
return 'aborted';
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const attempt = (failedUploads.get(name)?.attempts ?? 0) + 1;
|
||||
failedUploads.set(name, { attempts: attempt, lastError: error });
|
||||
// A sustained outage would otherwise warn for every waiting file on each 30 s scan.
|
||||
if (attempt <= 3 || attempt % 10 === 0) {
|
||||
logger.warn(
|
||||
{ err: error, file, attempt, size },
|
||||
`Could not upload preview screenshot (attempt ${attempt}). Keeping it for retry.`
|
||||
);
|
||||
}
|
||||
return 'recorded';
|
||||
}
|
||||
|
||||
/**
|
||||
* Only atomically completed PNGs are eligible; failed uploads stay on disk for retry.
|
||||
* Failure records are reported once and deleted.
|
||||
*/
|
||||
export async function uploadDeviceRunSessionScreenshotsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
directory,
|
||||
deviceRunSessionId,
|
||||
logger,
|
||||
signal,
|
||||
failedUploads,
|
||||
session = Promise.resolve(null),
|
||||
}: {
|
||||
directory: string;
|
||||
deviceRunSessionId: string;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
failedUploads: FailedUploads;
|
||||
session?: Promise<ScreenshotSession | null>;
|
||||
}
|
||||
): Promise<{ uploaded: number; saveFailures: number }> {
|
||||
let uploadedCount = 0;
|
||||
let saveFailures = 0;
|
||||
// Names start with the capture time. Captures that never failed go first, oldest first, so a
|
||||
// capture that keeps stalling cannot hold back newer ones or use up the shutdown budget.
|
||||
const entries = (await readdir(directory, { withFileTypes: true })).sort(
|
||||
(a, b) =>
|
||||
Number(failedUploads.has(a.name)) - Number(failedUploads.has(b.name)) ||
|
||||
(a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
|
||||
);
|
||||
for (const entry of entries) {
|
||||
if (signal.aborted) {
|
||||
break;
|
||||
}
|
||||
const classified = entry.isFile() ? classifyScreenshotEntry(entry.name) : null;
|
||||
if (!classified) {
|
||||
continue;
|
||||
}
|
||||
const file = path.join(directory, entry.name);
|
||||
if (classified.kind === 'failureRecord') {
|
||||
await reportScreenshotFailureRecordAsync(file, logger);
|
||||
saveFailures++;
|
||||
continue;
|
||||
}
|
||||
let size: number | undefined;
|
||||
try {
|
||||
size = (await stat(file)).size;
|
||||
await uploadScreenshotAsync(ctx, {
|
||||
file,
|
||||
name: entry.name,
|
||||
timestamp: classified.timestamp,
|
||||
size,
|
||||
deviceRunSessionId,
|
||||
session,
|
||||
signal,
|
||||
failedUploads,
|
||||
logger,
|
||||
});
|
||||
uploadedCount++;
|
||||
} catch (err) {
|
||||
if (size === undefined && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
continue;
|
||||
}
|
||||
const outcome = recordUploadFailure(err, {
|
||||
file,
|
||||
name: entry.name,
|
||||
size,
|
||||
signal,
|
||||
failedUploads,
|
||||
logger,
|
||||
});
|
||||
if (outcome === 'aborted') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { uploaded: uploadedCount, saveFailures };
|
||||
}
|
||||
|
||||
function watchScreenshotDirectory(
|
||||
directory: string,
|
||||
onChange: () => void,
|
||||
logger: bunyan
|
||||
): () => void {
|
||||
let watcher: FSWatcher | undefined;
|
||||
try {
|
||||
watcher = watch(directory, { persistent: false }, (_event, filename) => {
|
||||
if (
|
||||
filename === null ||
|
||||
SCREENSHOT_FILENAME_PATTERN.test(filename) ||
|
||||
SCREENSHOT_FAILURE_RECORD_PATTERN.test(filename)
|
||||
) {
|
||||
onChange();
|
||||
}
|
||||
});
|
||||
watcher.on('error', err => {
|
||||
logger.warn({ err, directory }, 'Screenshot watcher failed; using periodic scans.');
|
||||
watcher?.close();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ err, directory }, 'Could not watch screenshots; using periodic scans.');
|
||||
}
|
||||
return () => watcher?.close();
|
||||
}
|
||||
|
||||
// rmdir refuses a non-empty directory, so a file still there at the end is reported as not uploaded,
|
||||
// never deleted.
|
||||
async function removeEmptyDirectoryAsync(directory: string): Promise<boolean> {
|
||||
try {
|
||||
await rmdir(directory);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOTEMPTY') {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function reportNotUploadedScreenshotsAsync({
|
||||
directory,
|
||||
hostStopped,
|
||||
failedUploads,
|
||||
logger,
|
||||
}: {
|
||||
directory: string;
|
||||
hostStopped: boolean;
|
||||
failedUploads: FailedUploads;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (hostStopped && (await removeEmptyDirectoryAsync(directory))) {
|
||||
return;
|
||||
}
|
||||
const files = await readdir(directory);
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
const notUploaded = files.map(name => {
|
||||
const failure = failedUploads.get(name);
|
||||
return failure
|
||||
? { name, attempts: failure.attempts, lastError: failure.lastError.message }
|
||||
: { name };
|
||||
});
|
||||
const message = `${files.length} preview screenshot${files.length === 1 ? ' was' : 's were'} not uploaded.`;
|
||||
logger.warn({ directory, files: notUploaded }, message);
|
||||
Sentry.capture('Preview screenshots were not uploaded', new Error(message), {
|
||||
extras: { files: notUploaded },
|
||||
});
|
||||
}
|
||||
|
||||
async function finishCollectionAsync({
|
||||
directory,
|
||||
hostStopped,
|
||||
logger,
|
||||
stopWatching,
|
||||
timer,
|
||||
controller,
|
||||
drainAsync,
|
||||
totals,
|
||||
failedUploads,
|
||||
}: {
|
||||
directory: string;
|
||||
hostStopped: boolean;
|
||||
logger: bunyan;
|
||||
stopWatching: () => void;
|
||||
timer: NodeJS.Timeout;
|
||||
controller: AbortController;
|
||||
drainAsync: () => Promise<void>;
|
||||
totals: { uploaded: number; saveFailures: number };
|
||||
failedUploads: FailedUploads;
|
||||
}): Promise<void> {
|
||||
stopWatching();
|
||||
clearInterval(timer);
|
||||
// This shutdown budget wins over the uploader's own 90 s stall deadline, so a slow upload
|
||||
// at shutdown is reported as not uploaded rather than delaying teardown.
|
||||
const deadline = setTimeout(() => controller.abort(), 30_000);
|
||||
try {
|
||||
await drainAsync();
|
||||
if (controller.signal.aborted) {
|
||||
logger.warn({ directory }, 'Preview screenshot flush timed out after 30 s.');
|
||||
}
|
||||
const saveFailureNote =
|
||||
totals.saveFailures > 0 ? `; the session host could not save ${totals.saveFailures}` : '';
|
||||
logger.info(
|
||||
`Uploaded ${totals.uploaded} preview screenshots during the session${saveFailureNote}.`
|
||||
);
|
||||
if (!hostStopped) {
|
||||
logger.warn(
|
||||
{ directory },
|
||||
'The session host is still running, so preview screenshots it saves from now on are not uploaded.'
|
||||
);
|
||||
}
|
||||
await reportNotUploadedScreenshotsAsync({ directory, hostStopped, failedUploads, logger });
|
||||
} catch (err) {
|
||||
logger.warn({ err, directory }, 'Could not finish preview screenshot uploads.');
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDeviceRunSessionScreenshotsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
options: { deviceRunSessionId: string; logger: bunyan }
|
||||
): Promise<{ directory: string; finishAsync: (hostStopped: boolean) => Promise<void> }> {
|
||||
const session = loadScreenshotSessionAsync(ctx, options.deviceRunSessionId, options.logger);
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'device-session-screenshots-'));
|
||||
const failedUploads: FailedUploads = new Map();
|
||||
const totals = { uploaded: 0, saveFailures: 0 };
|
||||
const controller = new AbortController();
|
||||
let pending: Promise<void> | null = null;
|
||||
let scanRequested = false;
|
||||
|
||||
const collectRequestedScreenshotsAsync = async (): Promise<void> => {
|
||||
while (scanRequested && !controller.signal.aborted) {
|
||||
scanRequested = false;
|
||||
try {
|
||||
const { uploaded, saveFailures } = await uploadDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
...options,
|
||||
session,
|
||||
directory,
|
||||
failedUploads,
|
||||
signal: controller.signal,
|
||||
});
|
||||
totals.uploaded += uploaded;
|
||||
totals.saveFailures += saveFailures;
|
||||
} catch (err) {
|
||||
options.logger.warn({ err, directory }, 'Could not collect preview screenshots.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const flush = (): Promise<void> => {
|
||||
scanRequested = true;
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
pending = collectRequestedScreenshotsAsync().finally(() => {
|
||||
pending = null;
|
||||
});
|
||||
return pending;
|
||||
};
|
||||
|
||||
const stopWatching = watchScreenshotDirectory(directory, () => void flush(), options.logger);
|
||||
const timer = setInterval(() => {
|
||||
void flush();
|
||||
}, 30_000);
|
||||
timer.unref();
|
||||
let finishTask: Promise<void> | null = null;
|
||||
return {
|
||||
directory,
|
||||
finishAsync(hostStopped) {
|
||||
finishTask ??= finishCollectionAsync({
|
||||
directory,
|
||||
hostStopped,
|
||||
logger: options.logger,
|
||||
stopWatching,
|
||||
timer,
|
||||
controller,
|
||||
drainAsync: async () => {
|
||||
await pending;
|
||||
await flush();
|
||||
},
|
||||
totals,
|
||||
failedUploads,
|
||||
});
|
||||
return finishTask;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export class DeviceRunSessionTimeoutError extends Error {
|
||||
constructor(name: string, timeoutMs: number) {
|
||||
super(`${name} timed out after ${timeoutMs}ms.`);
|
||||
this.name = 'DeviceRunSessionTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds waiting and signals cancellation. Operations must check the signal before later effects.
|
||||
* Calling resetDeadline restarts the timer, so the bound applies to stalls instead of total time.
|
||||
*/
|
||||
export async function withDeviceRunSessionTimeoutAsync<T>(
|
||||
{ name, timeoutMs, signal: parent }: { name: string; timeoutMs: number; signal?: AbortSignal },
|
||||
operation: (signal: AbortSignal, resetDeadline: () => void) => Promise<T>
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const signal = parent ? AbortSignal.any([parent, controller.signal]) : controller.signal;
|
||||
signal.throwIfAborted();
|
||||
let rejectAbort: (reason: unknown) => void = () => {};
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
rejectAbort = reject;
|
||||
});
|
||||
const onAbort = () => rejectAbort(signal.reason);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
const expire = () => controller.abort(new DeviceRunSessionTimeoutError(name, timeoutMs));
|
||||
let timer = setTimeout(expire, timeoutMs);
|
||||
let settled = false;
|
||||
const resetDeadline = () => {
|
||||
clearTimeout(timer);
|
||||
if (!settled && !signal.aborted) {
|
||||
timer = setTimeout(expire, timeoutMs);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const result = await Promise.race([operation(signal, resetDeadline), aborted]);
|
||||
signal.throwIfAborted();
|
||||
return result;
|
||||
} finally {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import {
|
||||
findUnlistedDeviceScreenRecordingsAsync,
|
||||
parseDeviceScreenRecordings,
|
||||
uploadDeviceRunSessionScreenRecordingsAsync,
|
||||
} from './deviceRunSessionScreenRecordings';
|
||||
import { startDeviceRunSessionScreenshotsAsync } from './deviceRunSessionScreenshots';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
type ServeSimLaunchOptions,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
findAvailablePortAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
isProcessRunning,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
} from './remoteDeviceRunSession';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1600';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '10000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
// On SIGTERM the Hub finalizes the recording itself, with this deadline before it force-exits,
|
||||
// so a stop request that failed here still gets one more chance to write the MP4.
|
||||
const EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS = 60_000;
|
||||
const EXPO_DEVICE_HUB_EXIT_LEEWAY_MS = 10_000;
|
||||
const RECORDING_STOP_GRACE_PERIOD_MS =
|
||||
EXPO_DEVICE_HUB_SIGTERM_FINALIZE_DEADLINE_MS + EXPO_DEVICE_HUB_EXIT_LEEWAY_MS;
|
||||
const HOST_OUTPUT_TAIL_CHARS = 8_000;
|
||||
const WEB_PREVIEW_READY_POLL_INTERVAL_MS = 250;
|
||||
|
||||
export function websiteOrigin(env: BuildStepEnv): string {
|
||||
return env.EXPO_LOCAL
|
||||
? 'https://expo.test'
|
||||
: env.EXPO_STAGING
|
||||
? 'https://staging.expo.dev'
|
||||
: 'https://expo.dev';
|
||||
}
|
||||
|
||||
export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string): string {
|
||||
return new URL(`/simulator-preview/${subdomainId}`, websiteOrigin(env)).toString();
|
||||
}
|
||||
|
||||
// Website dev servers on expo.test use staging sessions, and CORS matches ports exactly.
|
||||
const WEBSITE_DEV_ORIGINS = [
|
||||
'https://expo.test',
|
||||
'https://expo.test:13001',
|
||||
...Array.from({ length: 16 }, (_, index) => `https://expo.test:${13200 + index}`),
|
||||
];
|
||||
|
||||
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
|
||||
const origins = new Set([websiteOrigin(env)]);
|
||||
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
|
||||
origins.add('https://*.expo.dev');
|
||||
}
|
||||
if (env.EXPO_LOCAL || env.EXPO_STAGING) {
|
||||
for (const origin of WEBSITE_DEV_ORIGINS) {
|
||||
origins.add(origin);
|
||||
}
|
||||
}
|
||||
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
shareUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
networkCapture = false,
|
||||
networkCaptureFields = [],
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
shareUrl?: string;
|
||||
packageVersion?: string;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
SERVE_SIM_MAX_DIMENSION,
|
||||
'--mjpeg-quality',
|
||||
SERVE_SIM_MJPEG_QUALITY,
|
||||
'--video-bitrate',
|
||||
SERVE_SIM_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...(shareUrl ? ['--share-url', shareUrl] : []),
|
||||
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
|
||||
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
|
||||
...(openUrl ? ['--open-url', openUrl] : []),
|
||||
// `--network-capture` also covers an already booted simulator. Fields are repeated, not
|
||||
// comma-joined, so serve-sim's error names the bad value.
|
||||
...(networkCapture
|
||||
? [
|
||||
'--network-capture',
|
||||
...networkCaptureFields.flatMap(field => ['--network-capture-field', field]),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
packageVersion,
|
||||
recordingDirectory,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
packageVersion?: string;
|
||||
recordingDirectory?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
EXPO_DEVICE_HUB_MAX_DIMENSION,
|
||||
'--video-bitrate',
|
||||
EXPO_DEVICE_HUB_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
EXPO_DEVICE_HUB_VIDEO_FPS,
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
...(recordingDirectory ? ['--android-recording-directory', recordingDirectory] : []),
|
||||
...turnArgs,
|
||||
];
|
||||
}
|
||||
|
||||
const WebPreviewReadyResponseSchema = z.object({
|
||||
status: z.literal('ready'),
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
}: {
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
throw new SystemError(
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await turtleFetch(readyUrl, 'GET', {
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleepAsync(WEB_PREVIEW_READY_POLL_INTERVAL_MS);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
return servers.find(server => server.udid === udid)?.token;
|
||||
}
|
||||
|
||||
export type DeviceWebPreview = {
|
||||
previewPageUrl: string;
|
||||
apiUrl: string;
|
||||
previewToken?: string;
|
||||
/** Closes this tunnel only. The session host and recording remain running. */
|
||||
closeAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
export type DeviceSessionHost = {
|
||||
openPreviewAsync(options: { baseDomain: string }): Promise<DeviceWebPreview>;
|
||||
/**
|
||||
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
|
||||
* and reported, so callers need no error handling around it.
|
||||
*/
|
||||
finishAsync(): Promise<void>;
|
||||
};
|
||||
|
||||
type AndroidSessionRecording = {
|
||||
deviceRunSessionId: string;
|
||||
directory: string;
|
||||
controlToken: string;
|
||||
env: BuildStepEnv;
|
||||
};
|
||||
|
||||
export async function startDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
networkCapture = false,
|
||||
networkCaptureFields = [],
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<DeviceSessionHost> {
|
||||
const isAndroid = runtimePlatform === BuildRuntimePlatform.LINUX;
|
||||
// Unreachable from the step functions, which reject a non-Darwin launch while parsing.
|
||||
// Kept because this function is exported and expo-device-hub cannot launch.
|
||||
if (isAndroid && networkCapture) {
|
||||
throw new UserError(
|
||||
'EAS_NETWORK_CAPTURE_INVALID_INPUT',
|
||||
`Cannot record network traffic: capture runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
|
||||
);
|
||||
}
|
||||
if (isAndroid && launchAppIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
|
||||
);
|
||||
}
|
||||
if (isAndroid) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
const recording: AndroidSessionRecording | null = isAndroid
|
||||
? {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
directory: await fs.promises.mkdtemp(path.join(os.tmpdir(), 'android-session-recordings-')),
|
||||
controlToken: randomBytes(32).toString('hex'),
|
||||
env,
|
||||
}
|
||||
: null;
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
|
||||
const port = await findAvailablePortAsync();
|
||||
const serverName = isAndroid ? 'expo-device-hub' : 'serve-sim';
|
||||
const packageSpec = isAndroid
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
: createServeSimPackageSpec(packageVersion);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
isAndroid
|
||||
? createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
recordingDirectory: recording?.directory,
|
||||
})
|
||||
: createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
packageVersion,
|
||||
websiteArgs: websiteOriginServeSimArgs(env),
|
||||
shareUrl: previewPageUrl,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
})
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
const screenshots = await startDeviceRunSessionScreenshotsAsync(ctx, {
|
||||
deviceRunSessionId: getDeviceRunSessionIdOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
let previewServer: DetachedProcessHandle;
|
||||
try {
|
||||
previewServer = spawnDetached({
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env: {
|
||||
...env,
|
||||
EXPO_DEVICE_HUB_SCREENSHOT_DIRECTORY: screenshots.directory,
|
||||
...(recording ? { EXPO_DEVICE_HUB_RECORDING_CONTROL_TOKEN: recording.controlToken } : {}),
|
||||
},
|
||||
stopGracePeriodMs: recording ? RECORDING_STOP_GRACE_PERIOD_MS : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
// Nothing was spawned, so nothing can still write into the directory.
|
||||
await screenshots.finishAsync(true);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let previewToken: string | undefined;
|
||||
let previewTask: Promise<DeviceWebPreview> | null = null;
|
||||
let finishTask: Promise<void> | null = null;
|
||||
let hostReady = false;
|
||||
|
||||
const host: DeviceSessionHost = {
|
||||
openPreviewAsync({ baseDomain }) {
|
||||
if (finishTask) {
|
||||
return Promise.reject(
|
||||
new SystemError('Cannot open a preview after session host finalization.')
|
||||
);
|
||||
}
|
||||
if (previewTask) {
|
||||
return previewTask;
|
||||
}
|
||||
const opening = (async (): Promise<DeviceWebPreview> => {
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
if (finishTask) {
|
||||
await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Late preview tunnel close', timeoutMs: 5_000 },
|
||||
async () => await tunnel.stopAsync()
|
||||
).catch(err => logger.warn({ err }, 'Could not close a late preview tunnel.'));
|
||||
throw new SystemError('Session host finalized while the preview was opening.');
|
||||
}
|
||||
let closeTask: Promise<void> | null = null;
|
||||
return {
|
||||
previewPageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
closeAsync() {
|
||||
return (closeTask ??= (async () => {
|
||||
try {
|
||||
await tunnel.stopAsync();
|
||||
} finally {
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
}
|
||||
})());
|
||||
},
|
||||
};
|
||||
})();
|
||||
previewTask = opening;
|
||||
// A failed tunnel may be retried without restarting capture.
|
||||
void opening.catch(() => {
|
||||
if (previewTask === opening) {
|
||||
previewTask = null;
|
||||
}
|
||||
});
|
||||
return opening;
|
||||
},
|
||||
finishAsync() {
|
||||
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
|
||||
previewTask,
|
||||
previewServer,
|
||||
screenshots,
|
||||
serverName,
|
||||
port,
|
||||
// A host that never answered /readyz has nothing to finalize or upload.
|
||||
recording: hostReady ? recording : null,
|
||||
logger,
|
||||
}));
|
||||
},
|
||||
};
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
hostReady = true;
|
||||
if (!isAndroid) {
|
||||
previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
);
|
||||
}
|
||||
}
|
||||
return host;
|
||||
} catch (error) {
|
||||
await host.finishAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function finishDeviceSessionHostAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
previewTask,
|
||||
previewServer,
|
||||
screenshots,
|
||||
serverName,
|
||||
port,
|
||||
recording,
|
||||
logger,
|
||||
}: {
|
||||
previewTask: Promise<DeviceWebPreview> | null;
|
||||
previewServer: DetachedProcessHandle;
|
||||
screenshots: { finishAsync(hostStopped: boolean): Promise<void> };
|
||||
serverName: string;
|
||||
port: number;
|
||||
recording: AndroidSessionRecording | null;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
// Native ngrok operations have no scoped cancellation. Retire a late listener too.
|
||||
const retirePreview = withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Preview tunnel retirement', timeoutMs: 5_000 },
|
||||
async () => {
|
||||
// A preview that failed to open, or was closed by its opener after finish began, has no tunnel left.
|
||||
const preview = await previewTask?.catch(() => null);
|
||||
await preview?.closeAsync();
|
||||
}
|
||||
).catch(err => {
|
||||
logger.warn({ err }, `Could not close the ${serverName} preview tunnel within its deadline.`);
|
||||
});
|
||||
let finalization: AndroidRecordingFinalization | null = null;
|
||||
if (recording) {
|
||||
// stopAsync signals the whole process group, including capture's encoder.
|
||||
// Finalize the MP4 first; the token protects this route on the preview server.
|
||||
finalization = await finalizeAndroidRecordingAsync({
|
||||
port,
|
||||
controlToken: recording.controlToken,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
let hostStopped = false;
|
||||
try {
|
||||
await previewServer.stopAsync();
|
||||
if (previewServer.pid !== undefined && isProcessRunning(previewServer.pid)) {
|
||||
throw new Error('Session host is still running after shutdown.');
|
||||
}
|
||||
hostStopped = true;
|
||||
} catch (err) {
|
||||
logger.warn({ err }, `Could not stop the ${serverName} session host.`);
|
||||
}
|
||||
await retirePreview;
|
||||
await screenshots.finishAsync(hostStopped);
|
||||
// A Hub that never recorded has logged its reason and left nothing to upload.
|
||||
const captured = finalization !== 'not-recording';
|
||||
let uploaded = false;
|
||||
if (recording && captured && hostStopped) {
|
||||
uploaded = await uploadFinishedAndroidRecordingAsync(ctx, { recording, logger });
|
||||
}
|
||||
if (recording && captured && (finalization === 'failed' || !uploaded)) {
|
||||
// The Hub reports capture failures on stderr; the stop route answers with only a summary.
|
||||
logger.warn(
|
||||
{ hostOutput: previewServer.getOutput().slice(-HOST_OUTPUT_TAIL_CHARS) || '<empty>' },
|
||||
'Session host output around the recording failure.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type AndroidRecordingFinalization = 'finalized' | 'not-recording' | 'failed';
|
||||
|
||||
async function finalizeAndroidRecordingAsync({
|
||||
port,
|
||||
controlToken,
|
||||
logger,
|
||||
}: {
|
||||
port: number;
|
||||
controlToken: string;
|
||||
logger: bunyan;
|
||||
}): Promise<AndroidRecordingFinalization> {
|
||||
try {
|
||||
const response = await withDeviceRunSessionTimeoutAsync(
|
||||
{ name: 'Android recording finalization', timeoutMs: 60_000 },
|
||||
async signal =>
|
||||
await turtleFetch(
|
||||
`http://${WEB_PREVIEW_HOST}:${port}/_eas/android-recording/stop`,
|
||||
'POST',
|
||||
{
|
||||
headers: { Authorization: `Bearer ${controlToken}` },
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
signal,
|
||||
}
|
||||
)
|
||||
);
|
||||
if (response.status === 409) {
|
||||
// The Hub answers 409 with the reason nothing was recorded, such as the emulator count.
|
||||
logger.warn(`Android recording was not captured: ${await response.text()}`);
|
||||
return 'not-recording';
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Android recording finalization returned HTTP ${response.status}: ${await response.text()}`
|
||||
);
|
||||
}
|
||||
return 'finalized';
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not finalize Android recording before shutdown', error, {
|
||||
level: 'warning',
|
||||
});
|
||||
logger.warn({ err: error }, 'Could not finalize Android recording before shutdown.');
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFinishedAndroidRecordingAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ recording, logger }: { recording: AndroidSessionRecording; logger: bunyan }
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
let recordings = parseDeviceScreenRecordings(
|
||||
JSON.parse(
|
||||
await fs.promises.readFile(path.join(recording.directory, 'recordings.json'), 'utf8')
|
||||
)
|
||||
);
|
||||
if (recordings.length === 0) {
|
||||
// recordings.json is written at finish; a killed Hub leaves what it had on disk unlisted.
|
||||
recordings = await findUnlistedDeviceScreenRecordingsAsync({
|
||||
root: recording.directory,
|
||||
env: recording.env,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
for (const item of recordings) {
|
||||
if (
|
||||
path.dirname(path.resolve(item.directory)) !== recording.directory ||
|
||||
(await fs.promises.lstat(item.directory)).isSymbolicLink()
|
||||
) {
|
||||
throw new Error('Recording directory is not an owned session child.');
|
||||
}
|
||||
}
|
||||
const uploaded = await uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: recording.deviceRunSessionId,
|
||||
recordings,
|
||||
});
|
||||
if (recordings.length > 0 && uploaded) {
|
||||
await fs.promises.rm(recording.directory, { recursive: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload the Android session recording', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error, recordingDirectory: recording.directory },
|
||||
'Could not upload the Android session recording.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import * as jose from 'jose';
|
||||
import fetch from 'node-fetch';
|
||||
import { ZodError, z } from 'zod';
|
||||
|
||||
const TOKEN_LIFETIME_SECONDS = 20 /* minutes */ * 60 /* seconds */;
|
||||
const TOKEN_REFRESH_MARGIN_SECONDS = 60 /* seconds */;
|
||||
|
||||
type ApiSchema = {
|
||||
[Path in string]: {
|
||||
path?: z.ZodType<Record<string, string>>;
|
||||
@@ -14,7 +19,7 @@ type ApiSchema = {
|
||||
const AscErrorResponseSchema = z.object({
|
||||
errors: z
|
||||
.array(
|
||||
z.object({
|
||||
z.looseObject({
|
||||
id: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
@@ -27,6 +32,62 @@ const AscErrorResponseSchema = z.object({
|
||||
});
|
||||
|
||||
const GetApi = {
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-builds-_id_-app
|
||||
'/v1/builds/:id/app': {
|
||||
path: z.object({ id: z.string() }),
|
||||
request: z.object({}),
|
||||
response: z.object({
|
||||
data: z.object({
|
||||
id: z.string(),
|
||||
attributes: z.object({ primaryLocale: z.string().optional() }).optional(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-builds-_id_-betabuildlocalizations
|
||||
'/v1/builds/:id/betaBuildLocalizations': {
|
||||
path: z.object({ id: z.string() }),
|
||||
request: z.object({ limit: z.number().int().max(200) }),
|
||||
response: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
attributes: z.object({ locale: z.string().optional() }).optional(),
|
||||
})
|
||||
),
|
||||
links: z.object({ next: z.string().nullish() }).optional(),
|
||||
}),
|
||||
},
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/get-v1-betagroups
|
||||
'/v1/betaGroups': {
|
||||
path: z.object({}),
|
||||
request: z.union([
|
||||
z.object({
|
||||
'filter[app]': z.string(),
|
||||
'filter[builds]': z.never().optional(),
|
||||
limit: z.number().int().max(200),
|
||||
}),
|
||||
z.object({
|
||||
'filter[app]': z.never().optional(),
|
||||
'filter[builds]': z.string(),
|
||||
limit: z.number().int().max(200),
|
||||
}),
|
||||
]),
|
||||
response: z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
attributes: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
isInternalGroup: z.boolean().optional(),
|
||||
hasAccessToAllBuilds: z.boolean().nullish(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
),
|
||||
links: z.object({ next: z.string().nullish() }).optional(),
|
||||
}),
|
||||
},
|
||||
'/v1/apps': {
|
||||
path: z.object({}),
|
||||
request: z.object({
|
||||
@@ -119,12 +180,40 @@ const GetApi = {
|
||||
warnings: z.array(z.object({ code: z.string(), description: z.string() })).optional(),
|
||||
}),
|
||||
}),
|
||||
relationships: z
|
||||
.object({
|
||||
build: z.object({
|
||||
data: z.object({ type: z.literal('builds'), id: z.string() }).nullable(),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
} satisfies ApiSchema;
|
||||
|
||||
const PostApi = {
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/post-v1-builds-_id_-relationships-betagroups
|
||||
'/v1/builds/:id/relationships/betaGroups': {
|
||||
path: z.object({ id: z.string() }),
|
||||
request: z.object({
|
||||
data: z.array(z.object({ type: z.literal('betaGroups'), id: z.string() })),
|
||||
}),
|
||||
response: z.undefined(),
|
||||
},
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/post-v1-betabuildlocalizations
|
||||
'/v1/betaBuildLocalizations': {
|
||||
request: z.object({
|
||||
data: z.object({
|
||||
type: z.literal('betaBuildLocalizations'),
|
||||
attributes: z.object({ locale: z.string(), whatsNew: z.string().nullish() }),
|
||||
relationships: z.object({
|
||||
build: z.object({ data: z.object({ type: z.literal('builds'), id: z.string() }) }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
response: z.object({ data: z.object({ id: z.string() }) }),
|
||||
},
|
||||
'/v1/buildUploads': {
|
||||
request: z.object({
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadcreaterequest/data-data.dictionary
|
||||
@@ -223,6 +312,18 @@ const PostApi = {
|
||||
} satisfies ApiSchema;
|
||||
|
||||
const PatchApi = {
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/patch-v1-betabuildlocalizations-_id_
|
||||
'/v1/betaBuildLocalizations/:id': {
|
||||
path: z.object({ id: z.string() }),
|
||||
request: z.object({
|
||||
data: z.object({
|
||||
type: z.literal('betaBuildLocalizations'),
|
||||
id: z.string(),
|
||||
attributes: z.object({ whatsNew: z.string().nullish() }).optional(),
|
||||
}),
|
||||
}),
|
||||
response: z.object({ data: z.object({ id: z.string() }) }),
|
||||
},
|
||||
// https://developer.apple.com/documentation/appstoreconnectapi/patch-v1-builduploadfiles-_id_
|
||||
'/v1/buildUploadFiles/:id': {
|
||||
path: z.object({
|
||||
@@ -287,23 +388,65 @@ export class AscApiRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
public readonly responseJson: z.output<typeof AscErrorResponseSchema>,
|
||||
public readonly responseJson: z.output<typeof AscErrorResponseSchema>['errors'][number],
|
||||
options?: { cause?: unknown }
|
||||
) {
|
||||
super(message, { cause: options?.cause });
|
||||
}
|
||||
|
||||
get code(): string | undefined {
|
||||
return this.responseJson.code;
|
||||
}
|
||||
}
|
||||
|
||||
export type AscApiKey = {
|
||||
keyId: string;
|
||||
issuerId?: string | null;
|
||||
privateKey: jose.KeyLike;
|
||||
};
|
||||
|
||||
export class AscApiClient {
|
||||
private readonly baseUrl = 'https://api.appstoreconnect.apple.com';
|
||||
private readonly token: string;
|
||||
private readonly key: AscApiKey;
|
||||
private cachedToken?: { value: Promise<string>; expiresAt: number };
|
||||
private readonly logger?: bunyan;
|
||||
|
||||
constructor({ token, logger }: { token: string; logger?: bunyan }) {
|
||||
this.token = token;
|
||||
constructor({ key, logger }: { key: AscApiKey; logger?: bunyan }) {
|
||||
this.key = key;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
private async getTokenAsync(): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Reuse valid tokens, but leave time for a request to reach Apple before expiry.
|
||||
if (!this.cachedToken || this.cachedToken.expiresAt <= now + TOKEN_REFRESH_MARGIN_SECONDS) {
|
||||
const expiresAt = now + TOKEN_LIFETIME_SECONDS;
|
||||
this.cachedToken = { value: this.signTokenAsync(expiresAt), expiresAt };
|
||||
}
|
||||
const cachedToken = this.cachedToken;
|
||||
try {
|
||||
return await cachedToken.value;
|
||||
} catch (error) {
|
||||
if (this.cachedToken === cachedToken) {
|
||||
this.cachedToken = undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async signTokenAsync(expiresAt: number): Promise<string> {
|
||||
const jwt = new jose.SignJWT({})
|
||||
.setProtectedHeader({ alg: 'ES256', kid: this.key.keyId })
|
||||
.setAudience('appstoreconnect-v1')
|
||||
.setExpirationTime(expiresAt);
|
||||
if (this.key.issuerId) {
|
||||
jwt.setIssuer(this.key.issuerId);
|
||||
} else {
|
||||
jwt.setSubject('user');
|
||||
}
|
||||
return await jwt.sign(this.key.privateKey);
|
||||
}
|
||||
|
||||
public async getAsync<TPath extends keyof typeof GetApi>(
|
||||
path: TPath,
|
||||
body: z.input<(typeof GetApi)[TPath]['request']>,
|
||||
@@ -334,14 +477,40 @@ export class AscApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
public async getNextPageAsync<TPath extends keyof typeof GetApi>(
|
||||
path: TPath,
|
||||
next: string
|
||||
): Promise<z.output<(typeof GetApi)[TPath]['response']>> {
|
||||
const nextUrl = new URL(next, this.baseUrl);
|
||||
if (nextUrl.origin !== this.baseUrl) {
|
||||
throw new Error('App Store Connect returned a pagination link on another host.');
|
||||
}
|
||||
return await this.sendRequestAsync({
|
||||
method: 'GET',
|
||||
path: nextUrl.toString(),
|
||||
body: {},
|
||||
requestSchema: z.object({}),
|
||||
responseSchema: GetApi[path].response,
|
||||
});
|
||||
}
|
||||
|
||||
public async postAsync<TPath extends keyof typeof PostApi>(
|
||||
path: TPath,
|
||||
body: z.input<(typeof PostApi)[TPath]['request']>
|
||||
body: z.input<(typeof PostApi)[TPath]['request']>,
|
||||
// Require path params if the API schema defines them.
|
||||
...params: (typeof PostApi)[TPath] extends { path: infer TSchema extends z.ZodType }
|
||||
? [params: z.input<TSchema>]
|
||||
: []
|
||||
): Promise<z.output<(typeof PostApi)[TPath]['response']>> {
|
||||
const schema = PostApi[path];
|
||||
let effectivePath: string = path;
|
||||
const pathParams = 'path' in schema ? schema.path.parse(params[0]) : {};
|
||||
for (const [key, value] of Object.entries(pathParams)) {
|
||||
effectivePath = effectivePath.replaceAll(`:${key}`, encodeURIComponent(String(value)));
|
||||
}
|
||||
return await this.sendRequestAsync({
|
||||
method: 'POST',
|
||||
path,
|
||||
path: effectivePath,
|
||||
body,
|
||||
requestSchema: schema.request,
|
||||
responseSchema: schema.response,
|
||||
@@ -397,7 +566,7 @@ export class AscApiClient {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Authorization: `Bearer ${await this.getTokenAsync()}`,
|
||||
},
|
||||
body: method === 'GET' ? undefined : JSON.stringify(parsedBody.value),
|
||||
});
|
||||
@@ -408,10 +577,31 @@ export class AscApiClient {
|
||||
(async () => AscErrorResponseSchema.parse(JSON.parse(text)))()
|
||||
);
|
||||
if (parsedAscErrorResponse.ok) {
|
||||
throw new AscApiRequestError(
|
||||
`Unexpected response (${response.status}) from App Store Connect: ${text}`,
|
||||
response.status,
|
||||
parsedAscErrorResponse.value,
|
||||
const errors = parsedAscErrorResponse.value.errors.map(ascError => {
|
||||
if (ascError.code === 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED') {
|
||||
return new UserError(
|
||||
'EAS_ASC_REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED',
|
||||
'A required Apple agreement is missing or has expired. ' +
|
||||
"Ask your Apple Developer team's Account Holder to review and accept the required agreements " +
|
||||
'in App Store Connect: https://appstoreconnect.apple.com/business. ' +
|
||||
'If prompted, also accept the Apple Developer Program License Agreement at https://developer.apple.com/account. ' +
|
||||
'Then try again.',
|
||||
{ cause: ascError }
|
||||
);
|
||||
}
|
||||
return new AscApiRequestError(
|
||||
`Unexpected response (${response.status}) from App Store Connect: ${JSON.stringify(ascError)}`,
|
||||
response.status,
|
||||
ascError,
|
||||
{ cause: response }
|
||||
);
|
||||
});
|
||||
if (errors.length === 1) {
|
||||
throw errors[0];
|
||||
}
|
||||
throw new AggregateError(
|
||||
errors,
|
||||
`App Store Connect returned multiple errors:\n${errors.map(error => error.message).join('\n')}`,
|
||||
{ cause: response }
|
||||
);
|
||||
}
|
||||
@@ -421,6 +611,9 @@ export class AscApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return responseSchema.parse(undefined);
|
||||
}
|
||||
const text = await response.text();
|
||||
const parsedJson = await asyncResult((async () => JSON.parse(text))());
|
||||
if (!parsedJson.ok) {
|
||||
|
||||
@@ -1,14 +1,81 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import fs from 'fs-extra';
|
||||
import * as jose from 'jose';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
AscApiClient,
|
||||
AscApiClientGetApi,
|
||||
AscApiClientPostApi,
|
||||
AscApiKey,
|
||||
AscApiRequestError,
|
||||
AscPlatform,
|
||||
} from './AscApiClient';
|
||||
|
||||
export namespace AscApiUtils {
|
||||
export async function getAllBetaBuildLocalizationsAsync({
|
||||
client,
|
||||
buildId,
|
||||
}: {
|
||||
client: AscApiClient;
|
||||
buildId: string;
|
||||
}) {
|
||||
let response = await client.getAsync(
|
||||
'/v1/builds/:id/betaBuildLocalizations',
|
||||
{ limit: 200 },
|
||||
{ id: buildId }
|
||||
);
|
||||
const localizations = [...response.data];
|
||||
for (let page = 1; response.links?.next; page++) {
|
||||
if (page === 20) {
|
||||
throw new SystemError(
|
||||
'We only support TestFlight localization lists with up to 20 pages (4,000 localizations). Contact Expo support if you need a larger localization list.'
|
||||
);
|
||||
}
|
||||
response = await client.getNextPageAsync(
|
||||
'/v1/builds/:id/betaBuildLocalizations',
|
||||
response.links.next
|
||||
);
|
||||
localizations.push(...response.data);
|
||||
}
|
||||
return localizations;
|
||||
}
|
||||
|
||||
export async function getAllBetaGroupsAsync({
|
||||
client,
|
||||
appId,
|
||||
buildId,
|
||||
}: {
|
||||
client: AscApiClient;
|
||||
} & ({ appId: string; buildId?: never } | { appId?: never; buildId: string })) {
|
||||
let response = await client.getAsync('/v1/betaGroups', {
|
||||
...(buildId !== undefined ? { 'filter[builds]': buildId } : { 'filter[app]': appId }),
|
||||
limit: 200,
|
||||
});
|
||||
const groups = [...response.data];
|
||||
for (let page = 1; response.links?.next; page++) {
|
||||
if (page === 20) {
|
||||
throw new SystemError(
|
||||
'We only support TestFlight group lists with up to 20 pages (4,000 groups). Contact Expo support if you need a larger group list.'
|
||||
);
|
||||
}
|
||||
response = await client.getNextPageAsync('/v1/betaGroups', response.links.next);
|
||||
groups.push(...response.data);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export async function loadApiKeyAsync({ keyPath }: { keyPath: string }): Promise<AscApiKey> {
|
||||
const keyJson = z
|
||||
.object({ issuer_id: z.string().nullish(), key_id: z.string(), key: z.string() })
|
||||
.parse(await fs.readJson(keyPath));
|
||||
return {
|
||||
keyId: keyJson.key_id,
|
||||
issuerId: keyJson.issuer_id,
|
||||
privateKey: await jose.importPKCS8(keyJson.key, 'ES256'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a bundle's `DTPlatformName` (from its Info.plist) to the App Store
|
||||
* Connect platform used for a build upload. Unknown or missing values fall
|
||||
@@ -57,12 +124,13 @@ export namespace AscApiUtils {
|
||||
{ id: appleAppIdentifier }
|
||||
);
|
||||
} catch (error) {
|
||||
const notFoundErrors =
|
||||
error instanceof AscApiRequestError && error.status === 404
|
||||
? error.responseJson.errors
|
||||
: [];
|
||||
const errors = error instanceof AggregateError ? error.errors : [error];
|
||||
const isAppNotFoundError =
|
||||
notFoundErrors.length > 0 && notFoundErrors.every(item => item.code === 'NOT_FOUND');
|
||||
errors.length > 0 &&
|
||||
errors.every(
|
||||
item =>
|
||||
item instanceof AscApiRequestError && item.status === 404 && item.code === 'NOT_FOUND'
|
||||
);
|
||||
if (!isAppNotFoundError) {
|
||||
throw error;
|
||||
}
|
||||
@@ -124,13 +192,15 @@ export namespace AscApiUtils {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const errors =
|
||||
error instanceof AscApiRequestError && error.status === 409
|
||||
? error.responseJson.errors
|
||||
: [];
|
||||
const errors = error instanceof AggregateError ? error.errors : [error];
|
||||
const isDuplicateVersionError =
|
||||
errors.length > 0 &&
|
||||
errors.every(item => item.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE');
|
||||
errors.every(
|
||||
item =>
|
||||
item instanceof AscApiRequestError &&
|
||||
item.status === 409 &&
|
||||
item.code === 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE'
|
||||
);
|
||||
|
||||
if (isDuplicateVersionError) {
|
||||
throw new UserError(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import * as jose from 'jose';
|
||||
import nock from 'nock';
|
||||
|
||||
import { AscApiClient, AscApiRequestError } from '../AscApiClient';
|
||||
@@ -6,21 +8,83 @@ import { AscApiClient, AscApiRequestError } from '../AscApiClient';
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
describe(AscApiClient, () => {
|
||||
const token = 'test-token';
|
||||
const client = new AscApiClient({ token });
|
||||
let signingKey: jose.KeyLike;
|
||||
let client: AscApiClient;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
const { privateKey } = await jose.generateKeyPair('ES256');
|
||||
signingKey = privateKey;
|
||||
nock.disableNetConnect();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
client = new AscApiClient({ key: { keyId: 'TESTKEY', privateKey: signingKey } });
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
nock.enableNetConnect();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it.each([undefined, 'test-issuer'])(
|
||||
'reuses tokens and refreshes before expiry (issuer: %s)',
|
||||
async issuerId => {
|
||||
const { privateKey, publicKey } = await jose.generateKeyPair('ES256');
|
||||
const startTime = Date.now();
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'setTimeout', 'setInterval'] });
|
||||
jest.setSystemTime(startTime);
|
||||
const refreshingClient = new AscApiClient({
|
||||
key: { keyId: 'TESTKEY', issuerId, privateKey },
|
||||
});
|
||||
const responseFixture = require('./fixtures/buildUploads/get-buildUploads-200.json');
|
||||
const tokens: string[] = [];
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/buildUploads/upload-id')
|
||||
.query(true)
|
||||
.times(5)
|
||||
.reply(function () {
|
||||
tokens.push(String(this.req.headers.authorization).replace(/^Bearer /, ''));
|
||||
return [200, responseFixture];
|
||||
});
|
||||
|
||||
const requestAsync = async (): Promise<unknown> =>
|
||||
await refreshingClient.getAsync(
|
||||
'/v1/buildUploads/:id',
|
||||
{ 'fields[buildUploads]': ['build', 'state'], include: ['build'] },
|
||||
{ id: 'upload-id' }
|
||||
);
|
||||
await Promise.all([requestAsync(), requestAsync()]);
|
||||
jest.setSystemTime(startTime + 18 * 60 * 1000);
|
||||
await requestAsync();
|
||||
expect(new Set(tokens).size).toBe(1);
|
||||
|
||||
jest.setSystemTime(startTime + 19 * 60 * 1000);
|
||||
await requestAsync();
|
||||
expect(tokens[3]).not.toBe(tokens[0]);
|
||||
jest.setSystemTime(startTime + 21 * 60 * 1000);
|
||||
await requestAsync();
|
||||
expect(tokens[4]).toBe(tokens[3]);
|
||||
|
||||
const verificationOptions = {
|
||||
currentDate: new Date(Date.now()),
|
||||
audience: 'appstoreconnect-v1',
|
||||
};
|
||||
await expect(jose.jwtVerify(tokens[0], publicKey, verificationOptions)).rejects.toThrow(
|
||||
'"exp" claim timestamp check failed'
|
||||
);
|
||||
await expect(
|
||||
jose.jwtVerify(tokens[4], publicKey, verificationOptions)
|
||||
).resolves.toMatchObject({
|
||||
payload: issuerId ? { iss: issuerId } : { sub: 'user' },
|
||||
});
|
||||
expect(scope.isDone()).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it('fetches app info', async () => {
|
||||
const appId = '1491144534';
|
||||
const responseFixture = require('./fixtures/apps/get-apps-200.json');
|
||||
@@ -49,6 +113,45 @@ describe(AscApiClient, () => {
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('accepts null or missing hasAccessToAllBuilds for TestFlight groups', async () => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/betaGroups')
|
||||
.query({ 'filter[app]': 'app', limit: '200' })
|
||||
.reply(200, {
|
||||
data: [
|
||||
{
|
||||
type: 'betaGroups',
|
||||
id: 'external',
|
||||
attributes: {
|
||||
name: 'test external',
|
||||
isInternalGroup: false,
|
||||
hasAccessToAllBuilds: null,
|
||||
publicLinkEnabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'betaGroups',
|
||||
id: 'internal',
|
||||
attributes: { name: 'Internal', isInternalGroup: true },
|
||||
},
|
||||
],
|
||||
links: { self: 'https://api.appstoreconnect.apple.com/v1/betaGroups' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.getAsync('/v1/betaGroups', { 'filter[app]': 'app', limit: 200 })
|
||||
).resolves.toEqual({
|
||||
data: [
|
||||
{
|
||||
id: 'external',
|
||||
attributes: { name: 'test external', isInternalGroup: false, hasAccessToAllBuilds: null },
|
||||
},
|
||||
{ id: 'internal', attributes: { name: 'Internal', isInternalGroup: true } },
|
||||
],
|
||||
links: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('creates build upload', async () => {
|
||||
const buildUploadId = 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805';
|
||||
const responseFixture = require('./fixtures/buildUploads/post-buildUploads-200.json');
|
||||
@@ -102,6 +205,13 @@ describe(AscApiClient, () => {
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('requires path parameters for a POST route that has them', async () => {
|
||||
await expect(
|
||||
// @ts-expect-error The build ID is required by this route.
|
||||
client.postAsync('/v1/builds/:id/relationships/betaGroups', { data: [] })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('creates build upload file', async () => {
|
||||
const buildUploadId = 'fdf9c476-aaa4-4ead-b91c-6e3cc3a47805';
|
||||
const fileId = '5b110930-f947-4998-a129-5926ffcedde5';
|
||||
@@ -245,6 +355,7 @@ describe(AscApiClient, () => {
|
||||
attributes: {
|
||||
state: responseFixture.data.attributes.state,
|
||||
},
|
||||
relationships: { build: { data: null } },
|
||||
},
|
||||
});
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
@@ -281,6 +392,25 @@ describe(AscApiClient, () => {
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('explains how to resolve a missing agreement', async () => {
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/6817395749')
|
||||
.query({ 'fields[apps]': 'bundleId,name' })
|
||||
.reply(403, {
|
||||
errors: [{ code: 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED' }],
|
||||
});
|
||||
|
||||
const request = client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{ 'fields[apps]': ['bundleId', 'name'] },
|
||||
{ id: '6817395749' }
|
||||
);
|
||||
await expect(request).rejects.toBeInstanceOf(UserError);
|
||||
await expect(request).rejects.toThrow(
|
||||
/Account Holder.*https:\/\/appstoreconnect.apple.com\/business/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws AscApiRequestError for structured ASC error payload', async () => {
|
||||
const appId = '1491144534';
|
||||
const responseFixture = {
|
||||
@@ -291,6 +421,7 @@ describe(AscApiClient, () => {
|
||||
title:
|
||||
'The provided entity includes an attribute with a value that has already been used',
|
||||
detail: 'The bundle version must be higher than the previously uploaded version.',
|
||||
links: { see: '/business' },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -302,7 +433,36 @@ describe(AscApiClient, () => {
|
||||
|
||||
await expect(
|
||||
client.getAsync('/v1/apps/:id', { 'fields[apps]': ['bundleId', 'name'] }, { id: appId })
|
||||
).rejects.toBeInstanceOf(AscApiRequestError);
|
||||
).rejects.toMatchObject({
|
||||
message: `Unexpected response (409) from App Store Connect: ${JSON.stringify(responseFixture.errors[0])}`,
|
||||
status: 409,
|
||||
code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE',
|
||||
responseJson: responseFixture.errors[0],
|
||||
});
|
||||
});
|
||||
|
||||
it('aggregates agreement and generic errors without losing either', async () => {
|
||||
const forbidden = { code: 'FORBIDDEN', detail: 'Access denied.' };
|
||||
nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/6817395749')
|
||||
.query({ 'fields[apps]': 'bundleId,name' })
|
||||
.reply(403, {
|
||||
errors: [{ code: 'FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED' }, forbidden],
|
||||
});
|
||||
|
||||
const request = client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{ 'fields[apps]': ['bundleId', 'name'] },
|
||||
{ id: '6817395749' }
|
||||
);
|
||||
await expect(request).rejects.toBeInstanceOf(AggregateError);
|
||||
await expect(request).rejects.toHaveProperty('errors', [
|
||||
expect.any(UserError),
|
||||
expect.any(AscApiRequestError),
|
||||
]);
|
||||
await expect(request).rejects.toThrow(/Account Holder/);
|
||||
await expect(request).rejects.toThrow(/Access denied/);
|
||||
await expect(request).rejects.toHaveProperty('errors.1.responseJson', forbidden);
|
||||
});
|
||||
|
||||
it('throws regular Error for non-structured ASC error payload', async () => {
|
||||
|
||||
@@ -1,7 +1,54 @@
|
||||
import { AscApiRequestError } from '../AscApiClient';
|
||||
import fs from 'fs-extra';
|
||||
import * as jose from 'jose';
|
||||
import nock from 'nock';
|
||||
|
||||
import { AscApiClient, AscApiRequestError } from '../AscApiClient';
|
||||
import { AscApiUtils } from '../AscApiUtils';
|
||||
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
describe('AscApiUtils', () => {
|
||||
describe('loadApiKeyAsync', () => {
|
||||
beforeAll(() => nock.disableNetConnect());
|
||||
afterAll(() => nock.enableNetConnect());
|
||||
afterEach(() => nock.cleanAll());
|
||||
|
||||
it.each([undefined, 'test-issuer'])(
|
||||
'loads a key that authenticates ASC requests (issuer: %s)',
|
||||
async issuerId => {
|
||||
const { privateKey, publicKey } = await jose.generateKeyPair('ES256');
|
||||
const keyPath = '/asc-api-key.json';
|
||||
await fs.writeJson(keyPath, {
|
||||
key_id: 'TESTKEY',
|
||||
issuer_id: issuerId,
|
||||
key: await jose.exportPKCS8(privateKey),
|
||||
});
|
||||
const client = new AscApiClient({ key: await AscApiUtils.loadApiKeyAsync({ keyPath }) });
|
||||
let token = '';
|
||||
const scope = nock('https://api.appstoreconnect.apple.com')
|
||||
.get('/v1/apps/app')
|
||||
.query(true)
|
||||
.reply(function () {
|
||||
token = String(this.req.headers.authorization).replace(/^Bearer /, '');
|
||||
return [200, require('./fixtures/apps/get-apps-200.json')];
|
||||
});
|
||||
|
||||
await client.getAsync(
|
||||
'/v1/apps/:id',
|
||||
{ 'fields[apps]': ['bundleId', 'name'] },
|
||||
{ id: 'app' }
|
||||
);
|
||||
await expect(
|
||||
jose.jwtVerify(token, publicKey, { audience: 'appstoreconnect-v1' })
|
||||
).resolves.toMatchObject({
|
||||
protectedHeader: { kid: 'TESTKEY', alg: 'ES256' },
|
||||
payload: issuerId ? { iss: issuerId } : { sub: 'user' },
|
||||
});
|
||||
expect(scope.isDone()).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('getAppInfoAsync', () => {
|
||||
it('returns app info when lookup succeeds', async () => {
|
||||
const response = {
|
||||
@@ -24,48 +71,53 @@ describe('AscApiUtils', () => {
|
||||
).resolves.toEqual(response);
|
||||
});
|
||||
|
||||
it('throws UserError with visible apps when app id is not found', async () => {
|
||||
const notFoundPayload = {
|
||||
errors: [
|
||||
{
|
||||
status: '404',
|
||||
code: 'NOT_FOUND',
|
||||
detail: "There is no resource of type 'apps' with id '1234567890'",
|
||||
},
|
||||
],
|
||||
};
|
||||
const notFoundError = new AscApiRequestError(
|
||||
'Unexpected response (404) from App Store Connect',
|
||||
404,
|
||||
notFoundPayload
|
||||
);
|
||||
const client = {
|
||||
getAsync: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(notFoundError)
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
type: 'apps',
|
||||
id: '1111111111',
|
||||
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
it.each([false, true])(
|
||||
'throws UserError with visible apps (aggregate: %s)',
|
||||
async aggregate => {
|
||||
const notFoundPayload = {
|
||||
errors: [
|
||||
{
|
||||
status: '404',
|
||||
code: 'NOT_FOUND',
|
||||
detail: "There is no resource of type 'apps' with id '1234567890'",
|
||||
},
|
||||
],
|
||||
};
|
||||
const notFoundError = new AscApiRequestError(
|
||||
'Unexpected response (404) from App Store Connect',
|
||||
404,
|
||||
notFoundPayload.errors[0]
|
||||
);
|
||||
const client = {
|
||||
getAsync: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
aggregate ? new AggregateError([notFoundError, notFoundError]) : notFoundError
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
type: 'apps',
|
||||
id: '1111111111',
|
||||
attributes: { name: 'Visible App', bundleId: 'com.visible.app' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(
|
||||
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
message: expect.stringMatching(
|
||||
/App Store Connect app for application identifier 1234567890 was not found[\s\S]*- Visible App \(com\.visible\.app\) \(ID: 1111111111\)/
|
||||
),
|
||||
})
|
||||
);
|
||||
});
|
||||
await expect(
|
||||
AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier: '1234567890' })
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
errorCode: 'EAS_UPLOAD_TO_ASC_APP_NOT_FOUND',
|
||||
docsUrl: 'https://expo.fyi/asc-app-id',
|
||||
message: expect.stringMatching(
|
||||
/App Store Connect app for application identifier 1234567890 was not found[\s\S]*- Visible App \(com\.visible\.app\) \(ID: 1111111111\)/
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('rethrows original not-found error when app-list lookup fails', async () => {
|
||||
const notFoundPayload = {
|
||||
@@ -79,7 +131,7 @@ describe('AscApiUtils', () => {
|
||||
const notFoundError = new AscApiRequestError(
|
||||
'Unexpected response (404) from App Store Connect',
|
||||
404,
|
||||
notFoundPayload
|
||||
notFoundPayload.errors[0]
|
||||
);
|
||||
|
||||
const listingError = new Error('listing failed');
|
||||
@@ -122,7 +174,7 @@ describe('AscApiUtils', () => {
|
||||
});
|
||||
|
||||
describe('createBuildUploadAsync', () => {
|
||||
it('throws UserError when ASC duplicate version error is returned', async () => {
|
||||
it.each([false, true])('explains duplicate versions (aggregate: %s)', async aggregate => {
|
||||
const payload = {
|
||||
errors: [
|
||||
{
|
||||
@@ -135,11 +187,15 @@ describe('AscApiUtils', () => {
|
||||
const duplicateError = new AscApiRequestError(
|
||||
'Unexpected response (409) from App Store Connect',
|
||||
409,
|
||||
payload
|
||||
payload.errors[0]
|
||||
);
|
||||
|
||||
const client = {
|
||||
postAsync: jest.fn().mockRejectedValue(duplicateError),
|
||||
postAsync: jest
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
aggregate ? new AggregateError([duplicateError, duplicateError]) : duplicateError
|
||||
),
|
||||
};
|
||||
|
||||
await expect(
|
||||
@@ -211,7 +267,7 @@ describe('AscApiUtils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rethrows when error payload includes mixed error codes', async () => {
|
||||
it.each([false, true])('rethrows unrelated errors (aggregate: %s)', async aggregate => {
|
||||
const payload = {
|
||||
errors: [
|
||||
{
|
||||
@@ -224,11 +280,8 @@ describe('AscApiUtils', () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
const mixedError = new AscApiRequestError(
|
||||
'Unexpected response (409) from App Store Connect',
|
||||
409,
|
||||
payload
|
||||
);
|
||||
const errors = payload.errors.map(error => new AscApiRequestError('API error', 409, error));
|
||||
const mixedError = aggregate ? new AggregateError(errors) : errors[1];
|
||||
const client = {
|
||||
postAsync: jest.fn().mockRejectedValue(mixedError),
|
||||
};
|
||||
|
||||
@@ -56,6 +56,8 @@ const EGRESS_MONITOR_INTERVAL_MS = 2_000;
|
||||
const EGRESS_ESCAPE_SCAN_INTERVAL_MS = 5_000;
|
||||
const EGRESS_ESCAPE_LOG_LIMIT = 50;
|
||||
|
||||
export type LocalEgressPlatform = 'ios' | 'android';
|
||||
|
||||
export type LocalEgressHandoff = {
|
||||
/** Public URL of the reverse tunnel server, reachable through ngrok. */
|
||||
url: string;
|
||||
@@ -65,6 +67,8 @@ export type LocalEgressHandoff = {
|
||||
fingerprint: string;
|
||||
/** Loopback port on this host that the client must serve. */
|
||||
port: number;
|
||||
/** Missing means `ios`. */
|
||||
platform?: LocalEgressPlatform;
|
||||
};
|
||||
|
||||
export function getChiselAssetName({
|
||||
@@ -448,7 +452,8 @@ export async function readLocalEgressHandoffAsync(
|
||||
typeof parsed.url !== 'string' ||
|
||||
typeof parsed.token !== 'string' ||
|
||||
typeof parsed.fingerprint !== 'string' ||
|
||||
typeof parsed.port !== 'number'
|
||||
typeof parsed.port !== 'number' ||
|
||||
(parsed.platform !== undefined && parsed.platform !== 'ios' && parsed.platform !== 'android')
|
||||
) {
|
||||
throw new SystemError(`Local egress handoff at ${handoffPath} is malformed.`);
|
||||
}
|
||||
@@ -457,6 +462,7 @@ export async function readLocalEgressHandoffAsync(
|
||||
token: parsed.token,
|
||||
fingerprint: parsed.fingerprint,
|
||||
port: parsed.port,
|
||||
...(parsed.platform ? { platform: parsed.platform } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -729,15 +735,18 @@ export async function monitorLocalEgressAsync({
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
scanSimulatorConnections = true,
|
||||
}: {
|
||||
port: number;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal: AbortSignal;
|
||||
/** The scan follows launchd_sim descendants, so it only applies to iOS Simulators. */
|
||||
scanSimulatorConnections?: boolean;
|
||||
}): Promise<void> {
|
||||
let connected = false;
|
||||
let lastEscapeScanAt = 0;
|
||||
let escapeScanBroken = false;
|
||||
let escapeScanBroken = !scanSimulatorConnections;
|
||||
const reportedEscapes = new Set<string>();
|
||||
const lifetimeSignal = activeLocalEgressResources?.controller.signal;
|
||||
try {
|
||||
|
||||
@@ -54,12 +54,14 @@ export function buildGuardLaunchdEnvironment({
|
||||
|
||||
export type GuardEvent = {
|
||||
process: string;
|
||||
pid: number;
|
||||
/** Null when the event source cannot attribute the attempt to a process id. */
|
||||
pid: number | null;
|
||||
function: string;
|
||||
action: 'blocked' | 'logged';
|
||||
peer: string;
|
||||
/** Image names above the interposer, innermost first. */
|
||||
callers: string[];
|
||||
note?: string;
|
||||
};
|
||||
|
||||
/** One tab-separated line written by the guard; see policy.h for the format. */
|
||||
@@ -168,9 +170,11 @@ export class GuardEventRelay {
|
||||
return;
|
||||
}
|
||||
const verb = event.action === 'blocked' ? 'refused' : 'observed';
|
||||
const pid = event.pid === null ? '' : ` (pid ${event.pid})`;
|
||||
const callers = event.callers.length ? `; callers: ${event.callers.join(', ')}` : '';
|
||||
const note = event.note ? ` (${event.note})` : '';
|
||||
this.logger.info(
|
||||
`Local egress guard: ${verb} ${event.function} from ${event.process} (pid ${event.pid}) to ${event.peer}${callers}`
|
||||
`Local egress guard: ${verb} ${event.function} from ${event.process}${pid} to ${event.peer}${note}${callers}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -314,9 +318,26 @@ export async function resolveLocalEgressBootEnvironmentAsync({
|
||||
};
|
||||
}
|
||||
|
||||
type ActiveRelay = { tailer: GuardLogTailer; relay: GuardEventRelay };
|
||||
type ActiveRelay = {
|
||||
source: { stopAsync: () => Promise<void> };
|
||||
relay: GuardEventRelay;
|
||||
onRebind?: (logger: bunyan) => void;
|
||||
afterSummaryAsync?: () => Promise<void>;
|
||||
};
|
||||
const activeRelays = new Map<string, ActiveRelay>();
|
||||
|
||||
/**
|
||||
* Report events from another source, such as the Android emulator's packet
|
||||
* fence, through the same session log lines, step rebinding and summary as the
|
||||
* Simulator guard.
|
||||
*/
|
||||
export function registerLocalEgressGuardRelay(key: string, entry: ActiveRelay): void {
|
||||
if (activeRelays.has(key)) {
|
||||
throw new SystemError(`A local egress guard relay is already registered for ${key}.`);
|
||||
}
|
||||
activeRelays.set(key, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the guard into a simulator when a local egress session is active,
|
||||
* and start relaying its events into the session log. Returns false when
|
||||
@@ -416,7 +437,7 @@ export async function installLocalEgressGuardAsync({
|
||||
},
|
||||
});
|
||||
tailer.start();
|
||||
activeRelays.set(logPath, { tailer, relay });
|
||||
activeRelays.set(logPath, { source: tailer, relay });
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -676,8 +697,9 @@ export async function verifyLocalEgressGuardAsync({
|
||||
* finished.
|
||||
*/
|
||||
export function rebindLocalEgressGuardRelays(logger: bunyan): void {
|
||||
for (const { relay } of activeRelays.values()) {
|
||||
for (const { relay, onRebind } of activeRelays.values()) {
|
||||
relay.setLogger(logger);
|
||||
onRebind?.(logger);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -685,9 +707,9 @@ export function rebindLocalEgressGuardRelays(logger: bunyan): void {
|
||||
export async function stopLocalEgressGuardRelaysAsync(logger: bunyan): Promise<void> {
|
||||
const relays = [...activeRelays.values()];
|
||||
activeRelays.clear();
|
||||
for (const { tailer, relay } of relays) {
|
||||
for (const { source, relay, afterSummaryAsync } of relays) {
|
||||
try {
|
||||
await tailer.stopAsync();
|
||||
await source.stopAsync();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
@@ -695,5 +717,8 @@ export async function stopLocalEgressGuardRelaysAsync(logger: bunyan): Promise<v
|
||||
);
|
||||
}
|
||||
relay.logSummary();
|
||||
await afterSummaryAsync?.().catch(err =>
|
||||
logger.warn({ err }, 'Local egress guard: could not report the final totals.')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
logger: options.logger,
|
||||
signal: signal ?? new AbortController().signal,
|
||||
...(localEgress.platform === 'android' ? { scanSimulatorConnections: false } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { BuildRuntimePlatform, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
|
||||
/** The inputs that turn on network capture for a simulator session. */
|
||||
export function createNetworkCaptureInputProviders(): ReturnType<
|
||||
typeof BuildStepInput.createProvider
|
||||
>[] {
|
||||
return [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'network_capture',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'network_capture_fields',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** A JSON step input is whatever the workflow author wrote, so its shape has to be checked. */
|
||||
export function parseNetworkCaptureFieldsInput(value: unknown): string[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(value) || !value.every(field => typeof field === 'string')) {
|
||||
throw new UserError(
|
||||
'EAS_NETWORK_CAPTURE_INVALID_INPUT',
|
||||
'Input "network_capture_fields" must be an array of strings.'
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}: { networkCapture?: unknown; networkCaptureFields?: unknown },
|
||||
{ runtimePlatform }: { runtimePlatform: BuildRuntimePlatform }
|
||||
): { networkCapture: boolean; networkCaptureFields: string[] } {
|
||||
const fields = parseNetworkCaptureFieldsInput(networkCaptureFields);
|
||||
if (fields.length > 0 && networkCapture !== true) {
|
||||
throw new UserError(
|
||||
'EAS_NETWORK_CAPTURE_INVALID_INPUT',
|
||||
'Input "network_capture_fields" narrows what a recording keeps, so it needs "network_capture: true". Turn capture on, or drop "network_capture_fields".'
|
||||
);
|
||||
}
|
||||
if (networkCapture === true && runtimePlatform !== BuildRuntimePlatform.DARWIN) {
|
||||
throw new UserError(
|
||||
'EAS_NETWORK_CAPTURE_INVALID_INPUT',
|
||||
`Input "network_capture" records traffic through serve-sim on an iOS simulator, and this session runs on ${runtimePlatform}. Run the session on an iOS simulator, or drop "network_capture".`
|
||||
);
|
||||
}
|
||||
return { networkCapture: networkCapture === true, networkCaptureFields: fields };
|
||||
}
|
||||
@@ -26,26 +26,10 @@ import {
|
||||
parseOpenUrlInput,
|
||||
} from '../functions/launchApplication';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1600';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '10000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
const EXPO_DEVICE_HUB_VIDEO_BITRATE = '6000000';
|
||||
const EXPO_DEVICE_HUB_VIDEO_FPS = '60';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
@@ -547,10 +531,11 @@ export type DetachedProcessHandle = {
|
||||
/** PID of the directly spawned process, if the OS assigned one. */
|
||||
pid: number | undefined;
|
||||
getOutput: () => string;
|
||||
getExitError: () => Error | undefined;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
export function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
@@ -559,7 +544,10 @@ function isProcessRunning(pid: number): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDetachedProcessAsync(pid: number | undefined): Promise<void> {
|
||||
async function stopDetachedProcessAsync(
|
||||
pid: number | undefined,
|
||||
gracePeriodMs = 5_000
|
||||
): Promise<void> {
|
||||
if (pid === undefined || !isProcessRunning(pid)) {
|
||||
return;
|
||||
}
|
||||
@@ -575,7 +563,7 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 5_000;
|
||||
const deadline = Date.now() + gracePeriodMs;
|
||||
while (Date.now() < deadline && isProcessRunning(pid)) {
|
||||
await sleepAsync(100);
|
||||
}
|
||||
@@ -589,6 +577,43 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
// kill(pid, 0) succeeds on the zombie until libuv reaps it on a later loop turn.
|
||||
const killDeadline = Date.now() + 5_000;
|
||||
while (Date.now() < killDeadline && isProcessRunning(pid)) {
|
||||
await sleepAsync(100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs every named teardown to completion and logs each failure. The first failure is rethrown
|
||||
* only when the session body succeeded, so a teardown error cannot replace the error that ended it.
|
||||
*/
|
||||
export async function finishRemoteSessionAsync({
|
||||
teardown,
|
||||
sessionFailed,
|
||||
logger,
|
||||
}: {
|
||||
teardown: [name: string, task: Promise<unknown> | undefined][];
|
||||
sessionFailed: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const results = await Promise.allSettled(teardown.map(([, task]) => task));
|
||||
const failures = results.flatMap((result, index) =>
|
||||
result.status === 'rejected' ? [{ name: teardown[index][0], err: result.reason }] : []
|
||||
);
|
||||
for (const { name, err } of failures) {
|
||||
logger.warn({ err }, `Could not stop the ${name} during remote session teardown.`);
|
||||
if (sessionFailed) {
|
||||
// The session error is what the step reports, so a swallowed teardown failure goes to Sentry.
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture(`Could not stop the ${name} after the remote session failed`, error, {
|
||||
level: 'warning',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!sessionFailed && failures.length > 0) {
|
||||
throw failures[0].err;
|
||||
}
|
||||
}
|
||||
|
||||
export function spawnDetached({
|
||||
@@ -596,11 +621,13 @@ export function spawnDetached({
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
stopGracePeriodMs,
|
||||
}: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env: BuildStepEnv;
|
||||
stopGracePeriodMs?: number;
|
||||
}): DetachedProcessHandle {
|
||||
const promise = spawn(command, args, {
|
||||
cwd,
|
||||
@@ -608,9 +635,24 @@ export function spawnDetached({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
// We don't await the process — it should outlive this step. Failures show
|
||||
// up in the captured output; suppress unhandled rejections here.
|
||||
promise.catch(() => {});
|
||||
// Observe completion without rejecting in the background. Startup callers can
|
||||
// distinguish a dead process from one that is still preparing its state file.
|
||||
let exitError: Error | undefined;
|
||||
// The spawn promise waits for stdio to close. Descendants may keep those
|
||||
// pipes open after the launcher exits, so observe the exit itself as well.
|
||||
promise.child.once('exit', (code, signal) => {
|
||||
exitError = new Error(
|
||||
signal ? `Process exited with signal ${signal}.` : `Process exited with code ${code}.`
|
||||
);
|
||||
});
|
||||
void promise.then(
|
||||
() => {
|
||||
exitError ??= new Error('Process exited with code 0.');
|
||||
},
|
||||
error => {
|
||||
exitError ??= error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
);
|
||||
promise.child.unref();
|
||||
|
||||
let output = '';
|
||||
@@ -624,50 +666,11 @@ export function spawnDetached({
|
||||
return {
|
||||
pid,
|
||||
getOutput: () => output,
|
||||
stopAsync: async () => await stopDetachedProcessAsync(pid),
|
||||
getExitError: () => exitError,
|
||||
stopAsync: async () => await stopDetachedProcessAsync(pid, stopGracePeriodMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function websiteOrigin(env: BuildStepEnv): string {
|
||||
return env.EXPO_LOCAL
|
||||
? 'https://expo.test'
|
||||
: env.EXPO_STAGING
|
||||
? 'https://staging.expo.dev'
|
||||
: 'https://expo.dev';
|
||||
}
|
||||
|
||||
export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string): string {
|
||||
return new URL(`/simulator-preview/${subdomainId}`, websiteOrigin(env)).toString();
|
||||
}
|
||||
|
||||
// Website dev servers on expo.test use staging sessions, and CORS matches ports exactly.
|
||||
const WEBSITE_DEV_ORIGINS = [
|
||||
'https://expo.test',
|
||||
'https://expo.test:13001',
|
||||
...Array.from({ length: 16 }, (_, index) => `https://expo.test:${13200 + index}`),
|
||||
];
|
||||
|
||||
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
|
||||
const origins = new Set([websiteOrigin(env)]);
|
||||
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
|
||||
origins.add('https://*.expo.dev');
|
||||
}
|
||||
if (env.EXPO_LOCAL || env.EXPO_STAGING) {
|
||||
for (const origin of WEBSITE_DEV_ORIGINS) {
|
||||
origins.add(origin);
|
||||
}
|
||||
}
|
||||
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): string {
|
||||
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
export interface ServeSimLaunchOptions {
|
||||
launchAppIdentifier?: string;
|
||||
launchArgs?: string[];
|
||||
@@ -742,91 +745,12 @@ export function describeServeSimLaunch({
|
||||
return `serve-sim will launch ${launchAppIdentifier}${withArguments}${thenOpen}.`;
|
||||
}
|
||||
|
||||
export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
websiteArgs = [],
|
||||
shareUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
shareUrl?: string;
|
||||
packageVersion?: string;
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--require-token',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
SERVE_SIM_MAX_DIMENSION,
|
||||
'--mjpeg-quality',
|
||||
SERVE_SIM_MJPEG_QUALITY,
|
||||
'--video-bitrate',
|
||||
SERVE_SIM_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...websiteArgs,
|
||||
...(shareUrl ? ['--share-url', shareUrl] : []),
|
||||
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
|
||||
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
|
||||
...(openUrl ? ['--open-url', openUrl] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export function createExpoDeviceHubArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
packageVersion,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
WEB_PREVIEW_HOST,
|
||||
'--platform',
|
||||
'android',
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'h264',
|
||||
'--webrtc-ice-policy',
|
||||
'all',
|
||||
'--max-dimension',
|
||||
EXPO_DEVICE_HUB_MAX_DIMENSION,
|
||||
'--video-bitrate',
|
||||
EXPO_DEVICE_HUB_VIDEO_BITRATE,
|
||||
'--video-fps',
|
||||
EXPO_DEVICE_HUB_VIDEO_FPS,
|
||||
'--hide-sidebar',
|
||||
'--hide-boot-device',
|
||||
...turnArgs,
|
||||
];
|
||||
}
|
||||
|
||||
export async function findAvailablePortAsync(): Promise<number> {
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, WEB_PREVIEW_HOST, () => resolve());
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -838,270 +762,6 @@ export async function findAvailablePortAsync(): Promise<number> {
|
||||
return address.port;
|
||||
}
|
||||
|
||||
const WebPreviewReadyResponseSchema = z.object({
|
||||
status: z.literal('ready'),
|
||||
device: z.string(),
|
||||
});
|
||||
|
||||
export async function waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
}: {
|
||||
previewServer: Pick<DetachedProcessHandle, 'pid' | 'getOutput'>;
|
||||
serverName: string;
|
||||
port: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<string> {
|
||||
const readyUrl = `http://${WEB_PREVIEW_HOST}:${port}/readyz`;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
if (previewServer.pid !== undefined && !isProcessRunning(previewServer.pid)) {
|
||||
throw new SystemError(
|
||||
`${serverName} exited before becoming ready. Last output:\n${
|
||||
previewServer.getOutput() || '<empty>'
|
||||
}`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await turtleFetch(readyUrl, 'GET', {
|
||||
retries: 0,
|
||||
timeout: 2_000,
|
||||
});
|
||||
const ready = WebPreviewReadyResponseSchema.parse(await response.json());
|
||||
return ready.device;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${serverName} readiness at ${readyUrl}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}. Last output:\n${previewServer.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export type DeviceWebPreviewHandle = {
|
||||
previewPageUrl: string;
|
||||
apiUrl: string;
|
||||
/** Session token gating the preview. Only serve-sim mints one. */
|
||||
previewToken?: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type ServeSimPreviewHandle = DeviceWebPreviewHandle;
|
||||
|
||||
async function startWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName,
|
||||
packageSpec,
|
||||
createArgs,
|
||||
readPreviewTokenAsync,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
serverName: string;
|
||||
packageSpec: string;
|
||||
createArgs: (port: number, turnArgs: string[], previewPageUrl: string) => string[];
|
||||
readPreviewTokenAsync?: (device: string) => Promise<string>;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
|
||||
const port = await findAvailablePortAsync();
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
createArgs(port, turnArgs, previewPageUrl)
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
const previewServer = spawnDetached({
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env,
|
||||
});
|
||||
|
||||
try {
|
||||
logger.info(`Waiting for ${serverName} to become ready.`);
|
||||
const device = await waitForWebPreviewReadyAsync({
|
||||
previewServer,
|
||||
serverName,
|
||||
port,
|
||||
timeoutMs,
|
||||
});
|
||||
const previewToken = await readPreviewTokenAsync?.(device);
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
previewPageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn({ err: result.reason }, `Could not stop a ${serverName} preview resource.`);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await previewServer.stopAsync();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readServeSimPreviewTokenAsync(
|
||||
udid: string,
|
||||
stateDir: string = SERVE_SIM_STATE_DIR
|
||||
): Promise<string | undefined> {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
return servers.find(server => server.udid === udid)?.token;
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<ServeSimPreviewHandle> {
|
||||
const websiteArgs = websiteOriginServeSimArgs(env);
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'serve-sim',
|
||||
packageSpec: createServeSimPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs, previewPageUrl) =>
|
||||
createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
websiteArgs,
|
||||
shareUrl: previewPageUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
}),
|
||||
readPreviewTokenAsync: async device => {
|
||||
const previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
// A serve-sim that does not know --require-token fails earlier, in the readiness check, so
|
||||
// reaching here means it started and left no token in its state file.
|
||||
throw new SystemError(
|
||||
`serve-sim became ready but wrote no session token for device ${device}. The preview is ` +
|
||||
'on a public tunnel and would be reachable without one, so the session cannot continue. ' +
|
||||
'This usually means the state file was not written as expected; retry the session, and ' +
|
||||
'report it if it repeats.'
|
||||
);
|
||||
}
|
||||
return previewToken;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function startExpoDeviceHubWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
if (runtimePlatform === BuildRuntimePlatform.LINUX) {
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
}
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
serverName: 'expo-device-hub',
|
||||
packageSpec: createExpoDeviceHubPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) => createExpoDeviceHubArgs({ port, turnArgs, packageVersion }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startDeviceWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
...options
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
return await startServeSimWithTunnelAsync(ctx, {
|
||||
...options,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
});
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
// Unreachable from the three step functions, which reject a non-Darwin launch while
|
||||
// parsing. Kept because this function is exported and expo-device-hub cannot launch.
|
||||
if (launchAppIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
|
||||
);
|
||||
}
|
||||
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
|
||||
}
|
||||
}
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
export async function uploadServeSimLogsFileAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
udid,
|
||||
filePath,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
udid: string;
|
||||
filePath: string;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<void> {
|
||||
let stream: ReturnType<typeof createReadStream> | undefined;
|
||||
try {
|
||||
const { size } = await stat(filePath);
|
||||
if (size === 0) {
|
||||
return;
|
||||
}
|
||||
const fileController = new AbortController();
|
||||
stream = createReadStream(filePath);
|
||||
// A read error can occur while the upload session is still being allocated.
|
||||
stream.on('error', err => fileController.abort(err));
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: `simulator-log-${udid}`,
|
||||
name: `App logs (${udid.slice(0, 8)})`,
|
||||
filename: 'app-logs.ndjson',
|
||||
kind: 'simulator-log',
|
||||
metadata: { __eas_type: 'simulator-log', udid, scope: 'user-apps', source: 'serve-sim/logs' },
|
||||
size,
|
||||
stream,
|
||||
signal: AbortSignal.any([fileController.signal, ...(signal ? [signal] : [])]),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
`Could not upload simulator logs for ${udid}; other artifacts will continue.`
|
||||
);
|
||||
} finally {
|
||||
stream?.destroy();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fetch from 'node-fetch';
|
||||
import { mkdtemp, open } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { type Readable } from 'node:stream';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
|
||||
// Require user-app scope acknowledgement; replay cursors avoid duplicate records
|
||||
// on reconnect. Older servers that ignore the filter are not safe to collect from.
|
||||
const MAX_BYTES_PER_DEVICE = 20 * 1024 * 1024;
|
||||
const MAX_DURATION_MS = 30 * 60 * 1000;
|
||||
const MAX_LINE_LENGTH = 1024 * 1024;
|
||||
const MAX_CONSECUTIVE_FAILURES = 10;
|
||||
|
||||
const ServeSimLogEnvelopeSchema = z.object({
|
||||
seq: z.number().int().nonnegative(),
|
||||
raw: z.string(),
|
||||
});
|
||||
|
||||
type CollectedLog = { udid: string; filePath: string };
|
||||
type Session = {
|
||||
controller: AbortController;
|
||||
timer: NodeJS.Timeout;
|
||||
polling: Promise<void>;
|
||||
streams: Map<string, Promise<void>>;
|
||||
files: Map<string, CollectedLog>;
|
||||
finishing?: Promise<CollectedLog[]>;
|
||||
};
|
||||
let activeSession: Session | undefined;
|
||||
|
||||
export namespace ServeSimLogsRecorder {
|
||||
export async function startAsync({
|
||||
logger,
|
||||
stateDir = SERVE_SIM_STATE_DIR,
|
||||
pollIntervalMs = 2_000,
|
||||
maxBytes = MAX_BYTES_PER_DEVICE,
|
||||
maxDurationMs = MAX_DURATION_MS,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
stateDir?: string;
|
||||
pollIntervalMs?: number;
|
||||
maxBytes?: number;
|
||||
maxDurationMs?: number;
|
||||
}): Promise<void> {
|
||||
if (activeSession) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const session: Session = {
|
||||
controller,
|
||||
timer: setTimeout(() => controller.abort(), maxDurationMs),
|
||||
polling: Promise.resolve(),
|
||||
streams: new Map(),
|
||||
files: new Map(),
|
||||
};
|
||||
session.timer.unref();
|
||||
activeSession = session;
|
||||
// Reserve the session before asynchronous setup so concurrent starts are harmless.
|
||||
session.polling = (async () => {
|
||||
const outputDirectory = await mkdtemp(path.join(os.tmpdir(), 'serve-sim-logs-'));
|
||||
const failures = new Map<string, number>();
|
||||
const bytes = new Map<string, number>();
|
||||
const capped = new Set<string>();
|
||||
const cursors = new Map<string, { identity: string; sequence?: number }>();
|
||||
while (!controller.signal.aborted) {
|
||||
const servers = await readServeSimServersAsync(stateDir);
|
||||
const present = new Set(servers.map(server => server.udid));
|
||||
for (const udid of failures.keys()) {
|
||||
if (!present.has(udid)) {
|
||||
failures.delete(udid);
|
||||
cursors.delete(udid);
|
||||
}
|
||||
}
|
||||
for (const server of servers) {
|
||||
if (controller.signal.aborted) {
|
||||
break;
|
||||
}
|
||||
if (session.streams.has(server.udid) || capped.has(server.udid)) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(outputDirectory, `${encodeURIComponent(server.udid)}.ndjson`);
|
||||
const identity = JSON.stringify([server.url, server.token]);
|
||||
let cursor = cursors.get(server.udid);
|
||||
if (cursor?.identity !== identity) {
|
||||
cursor = { identity };
|
||||
cursors.set(server.udid, cursor);
|
||||
failures.delete(server.udid);
|
||||
}
|
||||
if ((failures.get(server.udid) ?? 0) >= MAX_CONSECUTIVE_FAILURES) {
|
||||
continue;
|
||||
}
|
||||
failures.set(server.udid, (failures.get(server.udid) ?? 0) + 1);
|
||||
const done = streamServeSimLogsToFileAsync({
|
||||
serveSimUrl: server.url,
|
||||
serveSimToken: server.token,
|
||||
serveSimDevice: server.udid,
|
||||
since: cursor?.sequence,
|
||||
filePath,
|
||||
signal: controller.signal,
|
||||
logger,
|
||||
maxBytes: maxBytes - (bytes.get(server.udid) ?? 0),
|
||||
})
|
||||
.then(result => {
|
||||
bytes.set(server.udid, (bytes.get(server.udid) ?? 0) + result.bytesWritten);
|
||||
if (result.lastSequence !== undefined && cursors.get(server.udid) === cursor) {
|
||||
cursors.set(server.udid, { identity, sequence: result.lastSequence });
|
||||
}
|
||||
if (result.receivedData) {
|
||||
session.files.set(server.udid, { udid: server.udid, filePath });
|
||||
failures.set(server.udid, 0);
|
||||
if (result.lastSequence === undefined) {
|
||||
capped.add(server.udid);
|
||||
if (!controller.signal.aborted && !result.limitReached) {
|
||||
logger.warn(
|
||||
`Simulator log stream for ${server.udid} ended without a replay cursor; retaining collected logs without reconnecting.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.limitReached) {
|
||||
capped.add(server.udid);
|
||||
logger.warn(`serve-sim simulator logs reached the size limit for ${server.udid}.`);
|
||||
}
|
||||
})
|
||||
.finally(() => session.streams.delete(server.udid));
|
||||
session.streams.set(server.udid, done);
|
||||
}
|
||||
await delay(pollIntervalMs, undefined, { signal: controller.signal }).catch(() => {});
|
||||
}
|
||||
})().catch(err => {
|
||||
logger.warn({ err }, 'Could not collect serve-sim simulator logs.');
|
||||
controller.abort();
|
||||
});
|
||||
logger.info('Started collecting user-app logs (up to 20 MiB per device and 30 minutes).');
|
||||
}
|
||||
|
||||
export async function finishAsync({ logger }: { logger: bunyan }): Promise<CollectedLog[]> {
|
||||
const session = activeSession;
|
||||
if (!session || session.finishing) {
|
||||
// Only the first finalizer receives files, preventing duplicate uploads.
|
||||
await session?.finishing;
|
||||
return [];
|
||||
}
|
||||
session.finishing = (async () => {
|
||||
clearTimeout(session.timer);
|
||||
session.controller.abort();
|
||||
await session.polling;
|
||||
await Promise.all(session.streams.values());
|
||||
logger.info('Finished collecting serve-sim simulator logs.');
|
||||
return [...session.files.values()];
|
||||
})();
|
||||
try {
|
||||
return await session.finishing;
|
||||
} finally {
|
||||
activeSession = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamServeSimLogsToFileAsync({
|
||||
serveSimUrl,
|
||||
serveSimToken,
|
||||
serveSimDevice,
|
||||
since,
|
||||
filePath,
|
||||
signal,
|
||||
logger,
|
||||
maxBytes = MAX_BYTES_PER_DEVICE,
|
||||
}: {
|
||||
serveSimUrl: string;
|
||||
serveSimToken?: string;
|
||||
serveSimDevice?: string;
|
||||
since?: number;
|
||||
filePath: string;
|
||||
signal: AbortSignal;
|
||||
logger: bunyan;
|
||||
maxBytes?: number;
|
||||
}): Promise<{
|
||||
receivedData: boolean;
|
||||
limitReached: boolean;
|
||||
bytesWritten: number;
|
||||
lastSequence?: number;
|
||||
}> {
|
||||
let bytesWritten = 0;
|
||||
let limitReached = maxBytes <= 0;
|
||||
let lastSequence = since;
|
||||
let malformedRecords = 0;
|
||||
let file: Awaited<ReturnType<typeof open>> | undefined;
|
||||
let body: NodeJS.ReadableStream | undefined;
|
||||
const closeBody = (): void => {
|
||||
(body as Readable | undefined)?.destroy?.();
|
||||
};
|
||||
try {
|
||||
if (signal.aborted || limitReached) {
|
||||
return { receivedData: false, limitReached, bytesWritten };
|
||||
}
|
||||
const url = new URL('/logs', serveSimUrl);
|
||||
url.searchParams.set('envelope', 'true');
|
||||
url.searchParams.set('scope', 'user-apps');
|
||||
if (since !== undefined) {
|
||||
url.searchParams.set('since', String(since));
|
||||
}
|
||||
if (serveSimDevice) {
|
||||
url.searchParams.set('device', serveSimDevice);
|
||||
}
|
||||
const response = await fetch(url.toString(), {
|
||||
signal,
|
||||
...(serveSimToken ? { headers: { Authorization: `Bearer ${serveSimToken}` } } : {}),
|
||||
});
|
||||
body = response.body;
|
||||
signal.addEventListener('abort', closeBody, { once: true });
|
||||
if (signal.aborted) {
|
||||
closeBody();
|
||||
return { receivedData: false, limitReached, bytesWritten };
|
||||
}
|
||||
if (!response.ok || !response.body) {
|
||||
logger.warn(`serve-sim /logs responded ${response.status}; simulator logs will be retried.`);
|
||||
return { receivedData: false, limitReached, bytesWritten };
|
||||
}
|
||||
// Older servers ignore unknown query parameters and would return system logs.
|
||||
if (response.headers.get('x-serve-sim-log-scope') !== 'user-apps') {
|
||||
logger.warn(
|
||||
'serve-sim did not confirm user-app log filtering. Skipping this stream; use a serve-sim version that supports /logs?scope=user-apps.'
|
||||
);
|
||||
return { receivedData: false, limitReached, bytesWritten };
|
||||
}
|
||||
file = await open(filePath, 'a');
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let droppingLine = false;
|
||||
stream: for await (const chunk of response.body) {
|
||||
buffer +=
|
||||
typeof chunk === 'string' ? chunk : decoder.decode(chunk as Buffer, { stream: true });
|
||||
let newline: number;
|
||||
while ((newline = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, newline).replace(/\r$/, '');
|
||||
buffer = buffer.slice(newline + 1);
|
||||
const skip = droppingLine || line.length > MAX_LINE_LENGTH;
|
||||
droppingLine = false;
|
||||
if (skip || !line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
let payload = line.slice(5).trim();
|
||||
let sequence: number | undefined;
|
||||
try {
|
||||
const envelope = ServeSimLogEnvelopeSchema.safeParse(JSON.parse(payload));
|
||||
if (envelope.success) {
|
||||
sequence = envelope.data.seq;
|
||||
if (lastSequence !== undefined && sequence <= lastSequence) {
|
||||
continue;
|
||||
}
|
||||
// Keep the downloaded artifact homogeneous across old and new servers.
|
||||
payload = JSON.stringify(JSON.parse(envelope.data.raw));
|
||||
}
|
||||
} catch {
|
||||
// Skip the record so the rest of the stream is kept; the count is reported below.
|
||||
malformedRecords += 1;
|
||||
continue;
|
||||
}
|
||||
const record = payload + '\n';
|
||||
const size = Buffer.byteLength(record);
|
||||
if (bytesWritten + size > maxBytes) {
|
||||
limitReached = true;
|
||||
break stream;
|
||||
}
|
||||
// Await writes to bound buffering, including during teardown; close waits for these writes.
|
||||
await file.writeFile(record);
|
||||
bytesWritten += size;
|
||||
if (sequence !== undefined) {
|
||||
lastSequence = sequence;
|
||||
}
|
||||
if (bytesWritten >= maxBytes) {
|
||||
limitReached = true;
|
||||
break stream;
|
||||
}
|
||||
}
|
||||
if (buffer.length > MAX_LINE_LENGTH) {
|
||||
buffer = '';
|
||||
droppingLine = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!signal.aborted) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'serve-sim simulator log stream ended; collected logs will be retained.'
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (malformedRecords > 0) {
|
||||
Sentry.capture('serve-sim sent malformed simulator log records', {
|
||||
extras: { malformedRecords, serveSimDevice },
|
||||
});
|
||||
logger.warn(
|
||||
{ malformedRecords },
|
||||
'serve-sim sent simulator log records that are not valid JSON; they were skipped.'
|
||||
);
|
||||
}
|
||||
signal.removeEventListener('abort', closeBody);
|
||||
closeBody();
|
||||
await file?.close().catch(err => {
|
||||
logger.warn({ err }, 'Could not close the simulator log file.');
|
||||
});
|
||||
}
|
||||
return {
|
||||
receivedData: bytesWritten > 0,
|
||||
limitReached,
|
||||
bytesWritten,
|
||||
...(lastSequence !== undefined ? { lastSequence } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
export type StartupTasks = {
|
||||
/**
|
||||
* Aborted when the first task fails, with that task's error as the reason. Long or
|
||||
* unbounded work (for example a download) passes it on, and tasks check it before
|
||||
* each next stage, so a failed startup stops quickly and does no more work.
|
||||
*/
|
||||
readonly signal: AbortSignal;
|
||||
/**
|
||||
* Starts `fn` now and returns its promise. Logs when it starts, when it finishes, and
|
||||
* how long it took, so the timing of each part stays visible when parts run in
|
||||
* parallel inside one step. `fn` gets a child logger with a `startupTask` field, so
|
||||
* the interleaved lines of parallel tasks can be told apart.
|
||||
*
|
||||
* If the task fails, startup is aborted. The returned promise is marked as handled: a
|
||||
* task that fails before anything awaits it must not crash the worker (it has no
|
||||
* unhandledRejection handler). Awaiting the promise still throws the error.
|
||||
*/
|
||||
run<T>(name: string, fn: (taskLogger: bunyan) => Promise<T>): Promise<T>;
|
||||
/**
|
||||
* Resolves like `promise`, or rejects with the abort reason as soon as startup is
|
||||
* aborted. For waiting on work that cannot be cancelled, such as a device boot. The
|
||||
* returned promise is marked as handled.
|
||||
*/
|
||||
untilAborted<T>(promise: Promise<T>): Promise<T>;
|
||||
/** Aborts startup, for a failure outside of `run`. The first reason wins. */
|
||||
abort(reason: unknown): void;
|
||||
/** One line with the duration of every finished task, in the order they finished. */
|
||||
summary(): string;
|
||||
};
|
||||
|
||||
export function createStartupTasks(logger: bunyan): StartupTasks {
|
||||
const startedAt = Date.now();
|
||||
const finished: { name: string; ms: number }[] = [];
|
||||
const controller = new AbortController();
|
||||
const abort = (reason: unknown): void => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(reason);
|
||||
}
|
||||
};
|
||||
return {
|
||||
signal: controller.signal,
|
||||
run<T>(name: string, fn: (taskLogger: bunyan) => Promise<T>): Promise<T> {
|
||||
const taskStartedAt = Date.now();
|
||||
logger.info(`Starting ${name}.`);
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await fn(logger.child({ startupTask: name }));
|
||||
const ms = Date.now() - taskStartedAt;
|
||||
finished.push({ name, ms });
|
||||
logger.info(`Finished ${name} in ${formatSeconds(ms)}.`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
logger.warn(`${name} failed after ${formatSeconds(Date.now() - taskStartedAt)}.`);
|
||||
abort(err);
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
promise.catch(() => {});
|
||||
return promise;
|
||||
},
|
||||
untilAborted<T>(promise: Promise<T>): Promise<T> {
|
||||
const { signal } = controller;
|
||||
const result = new Promise<T>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason);
|
||||
return;
|
||||
}
|
||||
const onAbort = (): void => reject(signal.reason);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
promise.then(
|
||||
value => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
err => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
result.catch(() => {});
|
||||
return result;
|
||||
},
|
||||
abort,
|
||||
summary(): string {
|
||||
const parts = finished.map(({ name, ms }) => `${name} ${formatSeconds(ms)}`);
|
||||
return `Startup took ${formatSeconds(Date.now() - startedAt)} (${parts.join(', ')}).`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatSeconds(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1)} s`;
|
||||
}
|
||||
@@ -21,6 +21,17 @@ export type AndroidVirtualDeviceName = string & z.BRAND<'AndroidVirtualDeviceNam
|
||||
export type AndroidDeviceName = string & z.BRAND<'AndroidDeviceName'>;
|
||||
export type AndroidDeviceSerialId = string & z.BRAND<'AndroidDeviceSerialId'>;
|
||||
|
||||
/**
|
||||
* Starts the emulator through a wrapper that waits on stdin, so the wrapper's
|
||||
* pid can be placed (for example, into a cgroup) before the emulator execs.
|
||||
*/
|
||||
export type AndroidEmulatorLaunchGate = {
|
||||
emulatorArgs: string[];
|
||||
wrapperCommand: string;
|
||||
wrapperArgs: string[];
|
||||
admitAsync: (pid: number) => Promise<void>;
|
||||
};
|
||||
|
||||
export namespace AndroidEmulatorUtils {
|
||||
const RETRY_INTERVAL_MS = 1_000;
|
||||
|
||||
@@ -312,10 +323,12 @@ export namespace AndroidEmulatorUtils {
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory,
|
||||
launchGate,
|
||||
}: {
|
||||
deviceName: AndroidVirtualDeviceName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logcatDirectory: string;
|
||||
launchGate?: AndroidEmulatorLaunchGate;
|
||||
}): Promise<{
|
||||
emulatorPromise: SpawnPromise<SpawnResult>;
|
||||
serialId: AndroidDeviceSerialId;
|
||||
@@ -341,37 +354,47 @@ export namespace AndroidEmulatorUtils {
|
||||
});
|
||||
}
|
||||
|
||||
const emulatorPromise = spawn(
|
||||
`${process.env.ANDROID_HOME}/emulator/emulator`,
|
||||
[
|
||||
'-no-window',
|
||||
'-no-boot-anim',
|
||||
'-writable-system',
|
||||
'-noaudio',
|
||||
'-no-snapshot-save',
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
logcatOutputPath,
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
'on',
|
||||
...(typeof env.ANDROID_EMULATOR_EXTRA_ARGS === 'string'
|
||||
? env.ANDROID_EMULATOR_EXTRA_ARGS.split(' ')
|
||||
: []),
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
env: {
|
||||
...env,
|
||||
// We don't need to wait for emulator to exit gracefully.
|
||||
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
|
||||
},
|
||||
}
|
||||
);
|
||||
const emulatorCommand = `${process.env.ANDROID_HOME}/emulator/emulator`;
|
||||
const emulatorArgs = [
|
||||
'-no-window',
|
||||
'-no-boot-anim',
|
||||
'-writable-system',
|
||||
'-noaudio',
|
||||
'-no-snapshot-save',
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
logcatOutputPath,
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
'on',
|
||||
...(typeof env.ANDROID_EMULATOR_EXTRA_ARGS === 'string'
|
||||
? env.ANDROID_EMULATOR_EXTRA_ARGS.split(' ')
|
||||
: []),
|
||||
];
|
||||
const emulatorEnv = {
|
||||
...env,
|
||||
// We don't need to wait for emulator to exit gracefully.
|
||||
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1',
|
||||
};
|
||||
const emulatorPromise = launchGate
|
||||
? spawn(
|
||||
launchGate.wrapperCommand,
|
||||
[...launchGate.wrapperArgs, emulatorCommand, ...emulatorArgs, ...launchGate.emulatorArgs],
|
||||
{
|
||||
detached: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
env: emulatorEnv,
|
||||
}
|
||||
)
|
||||
: spawn(emulatorCommand, emulatorArgs, {
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
env: emulatorEnv,
|
||||
});
|
||||
const emulatorOutputStream = fs.createWriteStream(emulatorOutputPath, { flags: 'a' });
|
||||
let reportedEmulatorOutputError = false;
|
||||
emulatorOutputStream.on('error', err => {
|
||||
@@ -413,6 +436,16 @@ export namespace AndroidEmulatorUtils {
|
||||
if (!emulatorPromise.child.pid) {
|
||||
await emulatorPromise;
|
||||
}
|
||||
if (launchGate && emulatorPromise.child.pid) {
|
||||
try {
|
||||
await launchGate.admitAsync(emulatorPromise.child.pid);
|
||||
} catch (err) {
|
||||
emulatorPromise.child.kill('SIGKILL');
|
||||
await asyncResult(emulatorPromise);
|
||||
throw err;
|
||||
}
|
||||
emulatorPromise.child.stdin?.end('go\n');
|
||||
}
|
||||
emulatorPromise.child.unref();
|
||||
// The emulator is detached and managed through adb. Observe its process promise
|
||||
// immediately so expected exits during retry cleanup do not become unhandled rejections.
|
||||
@@ -445,11 +478,14 @@ export namespace AndroidEmulatorUtils {
|
||||
env,
|
||||
timeoutMs = 3 * 60 * 1_000,
|
||||
logger,
|
||||
networkReadyTarget,
|
||||
}: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
logger?: bunyan;
|
||||
/** Host and port the guest must reach; 1.1.1.1:443 unless set. */
|
||||
networkReadyTarget?: { host: string; port: number };
|
||||
}): Promise<void> {
|
||||
const retries = Math.max(0, Math.ceil(timeoutMs / RETRY_INTERVAL_MS) - 1);
|
||||
await retryAsync(
|
||||
@@ -464,7 +500,11 @@ export namespace AndroidEmulatorUtils {
|
||||
throw new Error(`Emulator (${serialId}) boot has not completed.`);
|
||||
}
|
||||
|
||||
const hasNetworkConnection = await hasNetworkConnectionAsync({ serialId, env });
|
||||
const hasNetworkConnection = await hasNetworkConnectionAsync({
|
||||
serialId,
|
||||
env,
|
||||
target: networkReadyTarget,
|
||||
});
|
||||
if (!hasNetworkConnection) {
|
||||
throw new Error(`Emulator (${serialId}) network is not ready.`);
|
||||
}
|
||||
@@ -514,9 +554,11 @@ export namespace AndroidEmulatorUtils {
|
||||
async function hasNetworkConnectionAsync({
|
||||
serialId,
|
||||
env,
|
||||
target = { host: '1.1.1.1', port: 443 },
|
||||
}: {
|
||||
serialId: AndroidDeviceSerialId;
|
||||
env: NodeJS.ProcessEnv;
|
||||
target?: { host: string; port: number };
|
||||
}): Promise<boolean> {
|
||||
const networkReadyCheckCommand = env.ANDROID_EMULATOR_NETWORK_READY_COMMAND?.trim();
|
||||
if (networkReadyCheckCommand) {
|
||||
@@ -529,7 +571,7 @@ export namespace AndroidEmulatorUtils {
|
||||
const netcatResult = await asyncResult(
|
||||
spawn(
|
||||
'adb',
|
||||
['-s', serialId, 'shell', 'nc', '-w', '1', '1.1.1.1', '443'],
|
||||
['-s', serialId, 'shell', 'nc', '-w', '1', target.host, String(target.port)],
|
||||
// Close stdin to make netcat exit cleanly on Android images that don't support `-z`.
|
||||
{ env, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
)
|
||||
|
||||
@@ -261,6 +261,150 @@ describe('AndroidEmulatorUtils', () => {
|
||||
await emulatorOutputStreamClosed;
|
||||
});
|
||||
|
||||
it('spawns the emulator directly with the same arguments when there is no launch gate', async () => {
|
||||
const outputDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'logcat-staging-'));
|
||||
temporaryDirectories.push(outputDir);
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
mockSuccessfulStart(deviceName);
|
||||
const env = { ANDROID_EMULATOR_EXTRA_ARGS: '-gpu swiftshader_indirect' };
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env,
|
||||
logcatDirectory: outputDir,
|
||||
});
|
||||
|
||||
const emulatorCalls = mockedSpawn.mock.calls.filter(([command]) =>
|
||||
command.endsWith('/emulator/emulator')
|
||||
);
|
||||
expect(emulatorCalls).toEqual([
|
||||
[
|
||||
`${process.env.ANDROID_HOME}/emulator/emulator`,
|
||||
[
|
||||
'-no-window',
|
||||
'-no-boot-anim',
|
||||
'-writable-system',
|
||||
'-noaudio',
|
||||
'-no-snapshot-save',
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
result.logcatOutputPath,
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
'on',
|
||||
'-gpu',
|
||||
'swiftshader_indirect',
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
ignoreStdio: true,
|
||||
env: { ...env, ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: '1' },
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(
|
||||
mockedSpawn.mock.calls.filter(([command]) =>
|
||||
['sudo', 'nft', 'systemd-run', 'bash'].includes(command)
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('starts the emulator behind the launch gate and releases it once admitted', 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 stdin = { end: jest.fn() };
|
||||
Object.assign(child, { stdin });
|
||||
const gatedSpawn = mockedSpawn.getMockImplementation()!;
|
||||
mockedSpawn.mockImplementation(((command: string, args: string[], options: object) =>
|
||||
command === 'bash'
|
||||
? gatedSpawn('/android/emulator/emulator', args, options as any)
|
||||
: gatedSpawn(command, args, options as any)) as any);
|
||||
const admitAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const result = await AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
launchGate: {
|
||||
emulatorArgs: ['-http-proxy', '127.0.0.1:8898', '-no-metrics'],
|
||||
wrapperCommand: 'bash',
|
||||
wrapperArgs: ['-c', 'gate', 'eas-egress-gate'],
|
||||
admitAsync,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'bash',
|
||||
[
|
||||
'-c',
|
||||
'gate',
|
||||
'eas-egress-gate',
|
||||
`${process.env.ANDROID_HOME}/emulator/emulator`,
|
||||
'-no-window',
|
||||
'-no-boot-anim',
|
||||
'-writable-system',
|
||||
'-noaudio',
|
||||
'-no-snapshot-save',
|
||||
'-logcat',
|
||||
'*:v',
|
||||
'-logcat-output',
|
||||
result.logcatOutputPath,
|
||||
'-avd',
|
||||
deviceName,
|
||||
'-accel',
|
||||
'on',
|
||||
'-http-proxy',
|
||||
'127.0.0.1:8898',
|
||||
'-no-metrics',
|
||||
],
|
||||
expect.objectContaining({ detached: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
);
|
||||
expect(admitAsync).toHaveBeenCalledWith(1234);
|
||||
expect(stdin.end).toHaveBeenCalledWith('go\n');
|
||||
expect(admitAsync.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
stdin.end.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('kills the gated wrapper and throws when admission fails', 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 stdin = { end: jest.fn() };
|
||||
const kill = jest.fn();
|
||||
Object.assign(child, { stdin, kill });
|
||||
const gatedSpawn = mockedSpawn.getMockImplementation()!;
|
||||
mockedSpawn.mockImplementation(((command: string, args: string[], options: object) =>
|
||||
gatedSpawn(
|
||||
command === 'bash' ? '/android/emulator/emulator' : command,
|
||||
args,
|
||||
options as any
|
||||
)) as any);
|
||||
const admissionError = new Error('not fenced');
|
||||
|
||||
await expect(
|
||||
AndroidEmulatorUtils.startAsync({
|
||||
deviceName,
|
||||
env: {},
|
||||
logcatDirectory: outputDir,
|
||||
launchGate: {
|
||||
emulatorArgs: [],
|
||||
wrapperCommand: 'bash',
|
||||
wrapperArgs: ['-c', 'gate', 'eas-egress-gate'],
|
||||
admitAsync: jest.fn().mockRejectedValue(admissionError),
|
||||
},
|
||||
})
|
||||
).rejects.toBe(admissionError);
|
||||
expect(kill).toHaveBeenCalledWith('SIGKILL');
|
||||
expect(stdin.end).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws a SystemError when the staging directory cannot be prepared', async () => {
|
||||
const outputDir = '/unwritable/logcat-staging';
|
||||
const deviceName = 'eas-simulator' as AndroidVirtualDeviceName;
|
||||
@@ -339,6 +483,30 @@ describe('AndroidEmulatorUtils', () => {
|
||||
).rejects.toThrow('network is not ready');
|
||||
});
|
||||
|
||||
it('checks the given readiness target instead of 1.1.1.1:443', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[3] === 'getprop') {
|
||||
return { stdout: '1\n', stderr: '' } as any;
|
||||
}
|
||||
if (args[3] === 'nc' && args[6] === '192.0.2.1' && args[7] === '443') {
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}
|
||||
throw new Error(`Unexpected adb command args: ${args.join(' ')}`);
|
||||
}) as any);
|
||||
|
||||
await AndroidEmulatorUtils.waitForReadyAsync({
|
||||
serialId: 'emulator-5554' as any,
|
||||
env: process.env,
|
||||
networkReadyTarget: { host: '192.0.2.1', port: 443 },
|
||||
});
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
expect.arrayContaining(['1.1.1.1']),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('uses overridden network readiness command when provided', async () => {
|
||||
mockedSpawn.mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[3] === 'getprop') {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { graphqlAbortContext } from '../graphqlAbort';
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
it('returns nothing without a signal', () => {
|
||||
expect(graphqlAbortContext(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('aborts the request when either the caller or urql aborts', async () => {
|
||||
const seen: AbortSignal[] = [];
|
||||
globalThis.fetch = jest.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seen.push(init!.signal!);
|
||||
return new Response('{}');
|
||||
}) as typeof fetch;
|
||||
const caller = new AbortController();
|
||||
const urql = new AbortController();
|
||||
const context = graphqlAbortContext(caller.signal)!;
|
||||
await context.fetch!('https://api.test/graphql', { method: 'POST', signal: urql.signal });
|
||||
await context.fetch!('https://api.test/graphql', { method: 'POST' });
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(seen.every(signal => !signal.aborted)).toBe(true);
|
||||
urql.abort(new Error('urql gave up'));
|
||||
expect(seen[0].aborted).toBe(true);
|
||||
expect(seen[1].aborted).toBe(false);
|
||||
caller.abort(new Error('caller gave up'));
|
||||
expect(seen[1].aborted).toBe(true);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
import semver from 'semver';
|
||||
|
||||
import { findArtifacts } from './artifacts';
|
||||
import { runEasCliCommand } from './easCli';
|
||||
@@ -12,6 +13,32 @@ import { resolveArtifactPath } from '../ios/resolve';
|
||||
import { BuildContext } from '../context';
|
||||
import { isEASUpdateConfigured } from './expoUpdates';
|
||||
|
||||
function parseBooleanEnvVar(value: string | undefined): boolean | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return value !== '0' && value !== 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploading the embedded bundle is enabled by default for projects on SDK 58 and later, and can be
|
||||
* disabled by setting EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE to "0".
|
||||
*
|
||||
* On SDK 57 and below the feature is still experimental and off by default. Projects opt in by
|
||||
* setting EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE to "1".
|
||||
*/
|
||||
export function shouldUploadEmbeddedBundle(ctx: BuildContext<BuildJob>): boolean {
|
||||
const explicitFlag =
|
||||
parseBooleanEnvVar(ctx.env.EAS_UPDATE_UPLOAD_EMBEDDED_BUNDLE) ??
|
||||
parseBooleanEnvVar(ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE);
|
||||
if (explicitFlag !== undefined) {
|
||||
return explicitFlag;
|
||||
}
|
||||
|
||||
const sdkVersion = ctx.metadata?.sdkVersion;
|
||||
return !!sdkVersion && semver.satisfies(sdkVersion, '>=58');
|
||||
}
|
||||
|
||||
export async function uploadEmbeddedBundleAsync(ctx: BuildContext<BuildJob>): Promise<void> {
|
||||
if (!(await isEASUpdateConfigured(ctx))) {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { OperationContext } from '@urql/core';
|
||||
|
||||
/**
|
||||
* urql replaces `fetchOptions.signal` with its own, so a caller's signal has to be joined into
|
||||
* fetch after urql has resolved auth and its signal. Returns nothing without a signal, so the
|
||||
* client's default fetch stays in use.
|
||||
*/
|
||||
export function graphqlAbortContext(
|
||||
signal: AbortSignal | undefined
|
||||
): Pick<OperationContext, 'fetch'> | undefined {
|
||||
if (!signal) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "24.8.0",
|
||||
"version": "24.9.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "24.8.0"
|
||||
"@expo/steps": "24.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "24.8.0",
|
||||
"version": "24.9.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -527,6 +527,325 @@ describe('StaticWorkflowInterpolationContextZ', () => {
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('accepts sentry context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
sentry: {
|
||||
action: 'assigned',
|
||||
issue: {
|
||||
id: '149381254',
|
||||
short_id: 'TEST-PROJECT-1',
|
||||
title: 'webhook test 1790338443',
|
||||
culprit: '',
|
||||
permalink: 'https://sswrk.sentry.io/issues/149381254/',
|
||||
level: 'error',
|
||||
status: 'unresolved',
|
||||
substatus: 'regressed',
|
||||
platform: 'native',
|
||||
first_seen: '2026-09-25T12:03:52.754394Z',
|
||||
last_seen: '2026-09-25T12:14:03.693515Z',
|
||||
priority: 'high',
|
||||
issue_category: 'error',
|
||||
issue_type: 'error',
|
||||
count: '4',
|
||||
user_count: 1,
|
||||
assigned_to: {
|
||||
type: 'user',
|
||||
id: '3379782',
|
||||
},
|
||||
project: {
|
||||
id: '4512146882822224',
|
||||
slug: 'test-project',
|
||||
name: 'test-project',
|
||||
},
|
||||
},
|
||||
actor: {
|
||||
type: 'user',
|
||||
id: 3379782,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
|
||||
});
|
||||
|
||||
it('accepts sentry context with unassigned issue and application actor', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
sentry: {
|
||||
action: 'unresolved',
|
||||
issue: {
|
||||
id: '149381254',
|
||||
status: 'unresolved',
|
||||
substatus: 'regressed',
|
||||
assigned_to: null,
|
||||
project: {
|
||||
id: '4512146882822224',
|
||||
},
|
||||
},
|
||||
actor: {
|
||||
type: 'application',
|
||||
id: 'sentry',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = StaticWorkflowInterpolationContextZ.parse(context);
|
||||
expect(parsed).toEqual(context);
|
||||
expect(parsed.sentry?.issue.assigned_to).toBeNull();
|
||||
expect(parsed.sentry?.actor?.id).toBe('sentry');
|
||||
});
|
||||
|
||||
it('accepts sentry actor with numeric id', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
sentry: {
|
||||
action: 'archived',
|
||||
issue: {
|
||||
id: '149381254',
|
||||
status: 'ignored',
|
||||
substatus: 'archived_until_escalating',
|
||||
project: {
|
||||
id: '4512146882822224',
|
||||
},
|
||||
},
|
||||
actor: {
|
||||
type: 'user',
|
||||
id: 3379782,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = StaticWorkflowInterpolationContextZ.parse(context);
|
||||
expect(parsed).toEqual(context);
|
||||
expect(parsed.sentry?.actor?.id).toBe(3379782);
|
||||
});
|
||||
|
||||
it('preserves sentry resolution_type', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
sentry: {
|
||||
action: 'resolved',
|
||||
issue: {
|
||||
id: '149381254',
|
||||
status: 'resolved',
|
||||
project: {
|
||||
id: '4512146882822224',
|
||||
},
|
||||
},
|
||||
resolution_type: 'now',
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = StaticWorkflowInterpolationContextZ.parse(context);
|
||||
expect(parsed).toEqual(context);
|
||||
expect(parsed.sentry?.resolution_type).toBe('now');
|
||||
});
|
||||
|
||||
it('accepts sentry context with null culprit, permalink, substatus, and platform', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
sentry: {
|
||||
action: 'resolved',
|
||||
issue: {
|
||||
id: '123456789',
|
||||
short_id: 'MYAPP-1A',
|
||||
title: 'TypeError: Cannot read properties of undefined',
|
||||
culprit: null,
|
||||
permalink: null,
|
||||
level: 'error',
|
||||
status: 'unresolved',
|
||||
substatus: null,
|
||||
platform: null,
|
||||
first_seen: '2026-09-20T10:00:00.000Z',
|
||||
last_seen: '2026-09-21T10:00:00.000Z',
|
||||
project: {
|
||||
id: '987654321',
|
||||
slug: 'my-app',
|
||||
name: 'My App',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = StaticWorkflowInterpolationContextZ.parse(context);
|
||||
expect(parsed).toEqual(context);
|
||||
expect(parsed.sentry?.issue.culprit).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid sentry action', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
sentry: {
|
||||
action: 'ignored',
|
||||
issue: {
|
||||
id: '123456789',
|
||||
project: {
|
||||
id: '987654321',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects sentry issue without project id', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
sentry: {
|
||||
action: 'created',
|
||||
issue: {
|
||||
id: '123456789',
|
||||
project: {
|
||||
slug: 'my-app',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => StaticWorkflowInterpolationContextZ.parse(context)).toThrow();
|
||||
});
|
||||
|
||||
it('preserves sentry on the parsed output', () => {
|
||||
const sentry = {
|
||||
action: 'assigned',
|
||||
issue: {
|
||||
id: '123456789',
|
||||
project: {
|
||||
id: '987654321',
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = {
|
||||
after: {},
|
||||
needs: {},
|
||||
workflow: {
|
||||
id: 'workflow-id',
|
||||
name: 'workflow-name',
|
||||
filename: 'workflow.yml',
|
||||
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
|
||||
},
|
||||
app: {
|
||||
id: 'app-id',
|
||||
slug: 'app-slug',
|
||||
},
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'account-name',
|
||||
},
|
||||
sentry,
|
||||
};
|
||||
|
||||
const parsed = StaticWorkflowInterpolationContextZ.parse(context);
|
||||
expect(parsed.sentry).toEqual(sentry);
|
||||
});
|
||||
|
||||
it('rejects invalid app and account context', () => {
|
||||
const context = {
|
||||
after: {},
|
||||
|
||||
@@ -301,6 +301,49 @@ const AppStoreConnectContextZ = z.looseObject({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const SentryIssueContextZ = z.looseObject({
|
||||
id: z.string(),
|
||||
short_id: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
culprit: z.string().nullable().optional(),
|
||||
permalink: z.string().nullable().optional(),
|
||||
level: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
substatus: z.string().nullable().optional(),
|
||||
platform: z.string().nullable().optional(),
|
||||
first_seen: z.string().optional(),
|
||||
last_seen: z.string().optional(),
|
||||
priority: z.string().nullable().optional(),
|
||||
issue_category: z.string().optional(),
|
||||
issue_type: z.string().optional(),
|
||||
count: z.string().optional(),
|
||||
user_count: z.number().optional(),
|
||||
assigned_to: z
|
||||
.looseObject({
|
||||
type: z.string(),
|
||||
id: z.string(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
project: z.looseObject({
|
||||
id: z.string(),
|
||||
slug: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const SentryContextZ = z.looseObject({
|
||||
action: z.enum(['created', 'resolved', 'assigned', 'archived', 'unresolved']),
|
||||
issue: SentryIssueContextZ,
|
||||
actor: z
|
||||
.looseObject({
|
||||
type: z.string(),
|
||||
id: z.union([z.string(), z.number()]),
|
||||
})
|
||||
.optional(),
|
||||
resolution_type: z.string().optional(),
|
||||
});
|
||||
|
||||
export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
after: z.record(
|
||||
z.string(),
|
||||
@@ -336,6 +379,8 @@ export const StaticWorkflowInterpolationContextZ = z.object({
|
||||
}),
|
||||
// We need to .optional() to support jobs that are not triggered by an App Store Connect event.
|
||||
app_store_connect: AppStoreConnectContextZ.optional(),
|
||||
// We need to .optional() to support jobs that are not triggered by a Sentry event.
|
||||
sentry: SentryContextZ.optional(),
|
||||
});
|
||||
|
||||
export type StaticWorkflowInterpolationContext = z.infer<
|
||||
|
||||
+249
-204
File diff suppressed because it is too large.
Load diff
Generated
+732
-43
@@ -3892,23 +3892,31 @@
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "isSecondFactorAuthenticationEnabled",
|
||||
"description": "Filter members by 2FA status. Only regular users are returned when set, since SSO users\nand robots don't have 2FA. Requires admin permission on the account.",
|
||||
"name": "exactPermissions",
|
||||
"description": "Filter by members containing all of the specified permissions.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "Permission",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "role",
|
||||
"description": "Filter members by role. Only OWNER, ADMIN, DEVELOPER, and VIEW_ONLY are supported.",
|
||||
"name": "isSecondFactorAuthenticationEnabled",
|
||||
"description": "Filter members by 2FA status. Only regular users are returned when set, since SSO users\nand robots don't have 2FA. Requires admin permission on the account.",
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "Role",
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
@@ -8283,6 +8291,84 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "AndroidDeviceRunSessionDevice",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "displayName",
|
||||
"description": "Human-readable device name to show to users (e.g. \"Pixel 9\").",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "identifier",
|
||||
"description": "AVD hardware profile id accepted by android.deviceIdentifier (e.g. \"pixel_9\").",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "AndroidDeviceRunSessionProfiles",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "devices",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AndroidDeviceRunSessionDevice",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "AndroidFcm",
|
||||
@@ -27682,6 +27768,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "branchName",
|
||||
"description": "Branch the update was published to.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "downloadCount",
|
||||
"description": null,
|
||||
@@ -27714,6 +27812,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "gitCommitHash",
|
||||
"description": "Git commit the update was published from, when it was recorded.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "medianDownloadTime",
|
||||
"description": null,
|
||||
@@ -27745,6 +27855,54 @@
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "platform",
|
||||
"description": "Platform the update was published for: ios, android, or web.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "publishedAt",
|
||||
"description": "When the update was published, from the EAS Update record.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "DateTime",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "runtimeVersion",
|
||||
"description": "Runtime version the update is compatible with.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "updateGroupId",
|
||||
"description": "ID of the update group this update belongs to.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -33348,7 +33506,7 @@
|
||||
},
|
||||
{
|
||||
"name": "runsOverTime",
|
||||
"description": null,
|
||||
"description": "Buckets start at the requested timespan start and advance by the granularity interval, so\ntheir boundaries follow the requested range. The last bucket is shorter when the timespan is\nnot a whole number of intervals.",
|
||||
"args": [
|
||||
{
|
||||
"name": "filters",
|
||||
@@ -44825,7 +44983,7 @@
|
||||
},
|
||||
{
|
||||
"name": "systemImagePackage",
|
||||
"description": "Android SDK system image package to use for the emulator (e.g.\n\"system-images;android-35-ext15;google_apis_playstore;x86_64\").",
|
||||
"description": "Android SDK system image package to use for the emulator (e.g.\n\"system-images;android-35;google_apis;x86_64\").",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
@@ -45322,6 +45480,30 @@
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "agentId",
|
||||
"description": "The coding agent sending this request, recorded for analytics only and never used for\nauthorization. Surrounding whitespace is trimmed, and a blank value or one over 200 characters\nis treated as omitted.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "agentSessionId",
|
||||
"description": "The coding agent's own session identifier, recorded for analytics only and never used for\nauthorization. Surrounding whitespace is trimmed, and a blank value or one over 200 characters\nis treated as omitted.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "android",
|
||||
"description": "Android emulator options. Only supported when platform is ANDROID.",
|
||||
@@ -45388,7 +45570,7 @@
|
||||
},
|
||||
{
|
||||
"name": "egress",
|
||||
"description": "Where the virtual device's proxied network traffic exits to the internet. If\nomitted, all traffic exits from EAS infrastructure. LOCAL is only supported for\nsessions on IOS.",
|
||||
"description": "Where the virtual device's proxied network traffic exits to the internet. If\nomitted, all traffic exits from EAS infrastructure.",
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "DeviceRunSessionEgress",
|
||||
@@ -45468,7 +45650,7 @@
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"description": "Human-readable label for the session, at most 255 characters. If omitted, the\nsession is unnamed and clients fall back to identifying it by id.",
|
||||
"description": "Human-readable label for the session, at most 255 characters after leading and\ntrailing whitespace is trimmed. If omitted, the session is unnamed and clients\nfall back to identifying it by id.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
@@ -45478,6 +45660,38 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "networkCapture",
|
||||
"description": "Record HTTP(S) traffic from apps on the device for the whole session. iOS only.\nHTTPS is decrypted, so recordings contain credentials and cookies in cleartext.\nIf omitted, no traffic is recorded.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "Boolean",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "networkCaptureFields",
|
||||
"description": "What network capture may keep beyond method, URL, status, timing and size: header, query,\nrequest-body, response-body. Each can carry credentials, so nothing here is recorded unless\nit is asked for. Requires networkCapture.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "openUrl",
|
||||
"description": "Expo or development-client URL to open after launching the installed application. Requires\nbuildId, applicationArchiveUrl, or expoGo.",
|
||||
@@ -45518,6 +45732,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "requestOrigin",
|
||||
"description": "The client sending this request, recorded for analytics only. Omit it when the\nclient is not one of the listed values; the session is then reported as unknown.",
|
||||
"type": {
|
||||
"kind": "ENUM",
|
||||
"name": "DeviceRunSessionRequestOrigin",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "resourceClass",
|
||||
"description": null,
|
||||
@@ -48835,6 +49061,33 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "DeleteDeviceRunSessionResult",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "id",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "DeleteDiscordUserResult",
|
||||
@@ -50435,7 +50688,7 @@
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"description": "Human-readable label chosen by whoever started the session. Null when the\nsession was started without one.",
|
||||
"description": "Human-readable label set when the session is created or changed later with\nupdateDeviceRunSession. Null when the session has no name.",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
@@ -50901,7 +51154,7 @@
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "name",
|
||||
"description": "Case-insensitive prefix match on the session name.",
|
||||
"description": "Case-insensitive match anywhere in the session name.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
@@ -51116,6 +51369,39 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "deleteDeviceRunSession",
|
||||
"description": "Permanently delete a finished session and its artifacts. The backing job,\nif present, must also have finished. Returns the deleted session ID.",
|
||||
"args": [
|
||||
{
|
||||
"name": "deviceRunSessionId",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "DeleteDeviceRunSessionResult",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "ensureDeviceRunSessionStopped",
|
||||
"description": "Ensure a device run session is stopped. Idempotent: if the session has already\nfinished, the existing session is returned unchanged (an ERRORED session stays\nERRORED).",
|
||||
@@ -51197,6 +51483,98 @@
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "updateDeviceRunSession",
|
||||
"description": "Update a device run session name and tags, including on sessions\nthat have already finished.",
|
||||
"args": [
|
||||
{
|
||||
"name": "deviceRunSessionId",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "ID",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "input",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "UpdateDeviceRunSessionInput",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "DeviceRunSession",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "DeviceRunSessionProfiles",
|
||||
"description": "Virtual device profiles available for new device run sessions.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "android",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "AndroidDeviceRunSessionProfiles",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "ios",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "IosDeviceRunSessionProfiles",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -51241,6 +51619,22 @@
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "profiles",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "DeviceRunSessionProfiles",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -51284,6 +51678,35 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "DeviceRunSessionRequestOrigin",
|
||||
"description": "The client that sent a session creation request, as reported by that client. Recorded\nfor analytics only and never used for authorization.",
|
||||
"fields": null,
|
||||
"inputFields": null,
|
||||
"interfaces": null,
|
||||
"enumValues": [
|
||||
{
|
||||
"name": "EAS_CLI",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "MCP",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "WEBSITE",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "DeviceRunSessionResourceClass",
|
||||
@@ -51333,6 +51756,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "QUEUED",
|
||||
"description": "The session's job run waits in the queue for a worker.",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "STARTING",
|
||||
"description": "A worker runs the session's job and prepares the virtual device.",
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "STOPPED",
|
||||
"description": null,
|
||||
@@ -63964,8 +64399,8 @@
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "This mutation is scheduled for removal."
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -66180,6 +66615,84 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "IosDeviceRunSessionDevice",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "displayName",
|
||||
"description": "Human-readable device name to show to users (e.g. \"iPhone 17 Pro\").",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "identifier",
|
||||
"description": "Simulator device name accepted by ios.deviceIdentifier (e.g. \"iPhone 17 Pro\").",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "IosDeviceRunSessionProfiles",
|
||||
"description": null,
|
||||
"fields": [
|
||||
{
|
||||
"name": "devices",
|
||||
"description": null,
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "OBJECT",
|
||||
"name": "IosDeviceRunSessionDevice",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
"interfaces": [],
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "IosDistributionType",
|
||||
@@ -72738,6 +73251,18 @@
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "sourceMapGroup",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "SourceMapGroup",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "turtleJobRunId",
|
||||
"description": null,
|
||||
@@ -74106,14 +74631,14 @@
|
||||
{
|
||||
"name": "HAS_ADMIN",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "No longer supported"
|
||||
},
|
||||
{
|
||||
"name": "NOT_ADMIN",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "No longer supported"
|
||||
},
|
||||
{
|
||||
"name": "OWNER",
|
||||
@@ -74654,7 +75179,7 @@
|
||||
},
|
||||
{
|
||||
"name": "deviceRunSession",
|
||||
"description": "Mutations that create, start, and stop device run sessions",
|
||||
"description": "Mutations that create, start, stop, and update device run sessions",
|
||||
"args": [],
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
@@ -80307,6 +80832,101 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "SourceMapGroup",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "android",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "SourceMapSourceInput",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "ios",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "SourceMapSourceInput",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"interfaces": null,
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "SourceMapSourceInput",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "bucketKey",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "ENUM",
|
||||
"name": "SourceMapSourceType",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"interfaces": null,
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "SourceMapSourceType",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": null,
|
||||
"interfaces": null,
|
||||
"enumValues": [
|
||||
{
|
||||
"name": "GCS",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "ENUM",
|
||||
"name": "StandardOffer",
|
||||
@@ -83831,22 +84451,6 @@
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "host",
|
||||
"description": null,
|
||||
"type": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "reconnecting",
|
||||
"description": null,
|
||||
@@ -86617,6 +87221,49 @@
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "INPUT_OBJECT",
|
||||
"name": "UpdateDeviceRunSessionInput",
|
||||
"description": null,
|
||||
"fields": null,
|
||||
"inputFields": [
|
||||
{
|
||||
"name": "name",
|
||||
"description": "New human-readable label for the session, at most 255 characters after leading\nand trailing whitespace is trimmed. Pass null to clear the name so clients fall\nback to identifying the session by id.",
|
||||
"type": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "tags",
|
||||
"description": "Replace the session tags. Tags are trimmed, lowercased and deduplicated.\nPass an empty list or null to clear them. Omit to leave tags unchanged.",
|
||||
"type": {
|
||||
"kind": "LIST",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "NON_NULL",
|
||||
"name": null,
|
||||
"ofType": {
|
||||
"kind": "SCALAR",
|
||||
"name": "String",
|
||||
"ofType": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultValue": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"interfaces": null,
|
||||
"enumValues": null,
|
||||
"possibleTypes": null
|
||||
},
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "UpdateDiffReceipt",
|
||||
@@ -89331,6 +89978,12 @@
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "EAS_UPDATE_SOURCE_MAPS",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
@@ -92801,8 +93454,8 @@
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "Derive role from permissions instead"
|
||||
}
|
||||
],
|
||||
"inputFields": null,
|
||||
@@ -93519,8 +94172,8 @@
|
||||
"ofType": null
|
||||
}
|
||||
},
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
"isDeprecated": true,
|
||||
"deprecationReason": "Derive role from permissions instead"
|
||||
},
|
||||
{
|
||||
"name": "userActor",
|
||||
@@ -100651,7 +101304,7 @@
|
||||
{
|
||||
"kind": "OBJECT",
|
||||
"name": "WorkflowDeviceTestCaseInsightsBucket",
|
||||
"description": "Mutually exclusive bucket counts (passedClean + flaky + failed = totalRuns for the bucket).\nBuckets are aligned to the UTC start of the requested granularity interval\n(minute / hour / day).",
|
||||
"description": "Mutually exclusive bucket counts (passedClean + flaky + failed = totalRuns for the bucket).\nBuckets start at the requested timespan start and advance by the granularity interval, so\ntheir boundaries follow the requested range. The last bucket is shorter when the timespan is\nnot a whole number of intervals.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "bucketStartAt",
|
||||
@@ -103450,6 +104103,12 @@
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "WORKFLOW_CALL",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
@@ -105427,6 +106086,36 @@
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "SENTRY_ISSUE_ARCHIVED",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "SENTRY_ISSUE_ASSIGNED",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "SENTRY_ISSUE_CREATED",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "SENTRY_ISSUE_RESOLVED",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
},
|
||||
{
|
||||
"name": "SENTRY_ISSUE_UNRESOLVED",
|
||||
"description": null,
|
||||
"isDeprecated": false,
|
||||
"deprecationReason": null
|
||||
}
|
||||
],
|
||||
"possibleTypes": null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "24.8.0",
|
||||
"version": "24.10.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,8 +48,8 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "24.8.0",
|
||||
"@expo/eas-json": "24.8.0",
|
||||
"@expo/eas-build-job": "24.9.0",
|
||||
"@expo/eas-json": "24.9.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "24.8.0",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "24.8.0",
|
||||
"@expo/steps": "24.9.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { MissingEasJsonError } from '@expo/eas-json/build/errors';
|
||||
|
||||
import { mockCommandContext, mockProjectId, mockTestCommand } from './utils';
|
||||
import { validateWorkflowFileAsync } from '../../commandUtils/workflow/validation';
|
||||
import { WorkflowValidate } from '../../commands/workflow/validate';
|
||||
import { WorkflowFile } from '../../utils/workflowFile';
|
||||
|
||||
jest.mock('../../commandUtils/workflow/validation', () => ({
|
||||
...jest.requireActual('../../commandUtils/workflow/validation'),
|
||||
validateWorkflowFileAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/workflowFile');
|
||||
jest.mock('fs');
|
||||
jest.mock('../../log');
|
||||
jest.mock('../../ora', () => ({
|
||||
ora: () => ({ start: () => ({ succeed: jest.fn(), fail: jest.fn() }) }),
|
||||
}));
|
||||
|
||||
describe(WorkflowValidate, () => {
|
||||
const initialExitCode = process.exitCode;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(WorkflowFile.readWorkflowFileContentsAsync).mockResolvedValue({
|
||||
yamlConfig: 'jobs: {}',
|
||||
filePath: '.eas/workflows/test.yml',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = initialExitCode;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('leaves the exit code unset when the workflow is valid', async () => {
|
||||
jest.mocked(validateWorkflowFileAsync).mockResolvedValue();
|
||||
const ctx = mockCommandContext(WorkflowValidate, { projectId: mockProjectId });
|
||||
|
||||
await mockTestCommand(WorkflowValidate, ['.eas/workflows/test.yml'], ctx).run();
|
||||
|
||||
expect(process.exitCode).toBe(initialExitCode);
|
||||
});
|
||||
|
||||
it('sets a non-zero exit code when the workflow is invalid', async () => {
|
||||
jest.mocked(validateWorkflowFileAsync).mockRejectedValue(new Error('invalid job'));
|
||||
const ctx = mockCommandContext(WorkflowValidate, { projectId: mockProjectId });
|
||||
|
||||
await mockTestCommand(WorkflowValidate, ['.eas/workflows/test.yml'], ctx).run();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('sets a non-zero exit code when a validation error is rethrown', async () => {
|
||||
jest.mocked(validateWorkflowFileAsync).mockRejectedValue(new MissingEasJsonError('missing'));
|
||||
const ctx = mockCommandContext(WorkflowValidate, { projectId: mockProjectId });
|
||||
|
||||
await expect(
|
||||
mockTestCommand(WorkflowValidate, ['.eas/workflows/test.yml'], ctx).run()
|
||||
).rejects.toThrow('Workflows require a valid eas.json');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import type { ora as oraType } from '../ora';
|
||||
|
||||
jest.unmock('ora');
|
||||
|
||||
class FakeTTYStream extends EventEmitter {
|
||||
isTTY = true;
|
||||
columns = 200;
|
||||
clearLineCalls = 0;
|
||||
|
||||
write(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
clearLine(): boolean {
|
||||
this.clearLineCalls++;
|
||||
return true;
|
||||
}
|
||||
|
||||
cursorTo(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
moveCursor(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ora', () => {
|
||||
const originalEnv = process.env;
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
let ora: typeof oraType;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.CI;
|
||||
delete process.env.EXPO_DEBUG;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, writable: true });
|
||||
jest.isolateModules(() => {
|
||||
ora = require('../ora').ora;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
process.env = originalEnv;
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, writable: true });
|
||||
});
|
||||
|
||||
it('clears every wrapped line after the terminal gets narrower', () => {
|
||||
const stream = new FakeTTYStream();
|
||||
const spinner = ora({
|
||||
text: 'x'.repeat(150),
|
||||
stream: stream as unknown as NodeJS.WritableStream,
|
||||
discardStdin: false,
|
||||
}).start();
|
||||
|
||||
stream.columns = 80;
|
||||
stream.emit('resize');
|
||||
spinner.render();
|
||||
|
||||
stream.clearLineCalls = 0;
|
||||
spinner.render();
|
||||
expect(stream.clearLineCalls).toBe(2);
|
||||
|
||||
spinner.stop();
|
||||
expect(stream.listenerCount('resize')).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,10 @@
|
||||
import { validateWorkflowStructure } from '../validation';
|
||||
import { ExpoGraphqlClient } from '../../context/contextUtils/createGraphqlClient';
|
||||
import { buildProfileNamesFromProjectAsync } from '../buildProfileUtils';
|
||||
import { validateWorkflowFileAsync, validateWorkflowStructure } from '../validation';
|
||||
|
||||
jest.mock('../buildProfileUtils');
|
||||
jest.mock('../compositeFunctions');
|
||||
jest.mock('../../../graphql/mutations/WorkflowRevisionMutation');
|
||||
|
||||
const workflowSchema = {
|
||||
type: 'object',
|
||||
@@ -60,3 +66,176 @@ describe(validateWorkflowStructure, () => {
|
||||
}).toThrow('must be a valid URI string');
|
||||
});
|
||||
});
|
||||
|
||||
const buildJobVariant = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { const: 'build' },
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: { platform: { type: 'string' }, profile: { type: 'string' } },
|
||||
required: ['platform'],
|
||||
},
|
||||
},
|
||||
required: ['type', 'params'],
|
||||
};
|
||||
|
||||
const repackJobVariant = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { const: 'repack' },
|
||||
needs: { type: 'array', items: { type: 'string' } },
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: { build_id: { type: 'string' }, profile: { type: 'string' } },
|
||||
required: ['build_id'],
|
||||
},
|
||||
},
|
||||
required: ['type', 'params'],
|
||||
};
|
||||
|
||||
// Calls a reusable workflow; it has no `type`, unlike every other job variant.
|
||||
const reusableWorkflowCallVariant = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
if: { type: 'string' },
|
||||
needs: { type: 'array', items: { type: 'string' } },
|
||||
after: { type: 'array', items: { type: 'string' } },
|
||||
uses: { type: 'string' },
|
||||
with: { type: 'object' },
|
||||
},
|
||||
required: ['uses'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const schemaWithJobVariants = (variants: object[]): object => ({
|
||||
data: {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
properties: {
|
||||
jobs: { type: 'object', additionalProperties: { anyOf: variants } },
|
||||
},
|
||||
required: ['jobs'],
|
||||
},
|
||||
});
|
||||
|
||||
describe(validateWorkflowFileAsync, () => {
|
||||
const fetchSpy = jest.spyOn(global, 'fetch');
|
||||
|
||||
const mockSchema = (variants: object[]): void => {
|
||||
fetchSpy.mockImplementation(
|
||||
async () => new Response(JSON.stringify(schemaWithJobVariants(variants)), { status: 200 })
|
||||
);
|
||||
};
|
||||
|
||||
const validateAsync = (yamlConfig: string): Promise<void> =>
|
||||
validateWorkflowFileAsync(
|
||||
{ yamlConfig, filePath: '.eas/workflows/test.yml' },
|
||||
'/project',
|
||||
{} as ExpoGraphqlClient,
|
||||
'project-id'
|
||||
);
|
||||
|
||||
const initialSchemaPath = process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH;
|
||||
jest.mocked(buildProfileNamesFromProjectAsync).mockResolvedValue(new Set(['production']));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.EXPO_TESTING_WORKFLOW_SCHEMA_PATH = initialSchemaPath;
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('validates typed jobs when the schema has a job variant without a type', async () => {
|
||||
mockSchema([buildJobVariant, reusableWorkflowCallVariant]);
|
||||
|
||||
await expect(
|
||||
validateAsync(`
|
||||
jobs:
|
||||
build_ios:
|
||||
type: build
|
||||
params:
|
||||
platform: ios
|
||||
profile: production
|
||||
`)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('validates a job that calls a reusable workflow', async () => {
|
||||
mockSchema([buildJobVariant, reusableWorkflowCallVariant]);
|
||||
|
||||
await expect(
|
||||
validateAsync(`
|
||||
jobs:
|
||||
shared:
|
||||
uses: ./.eas/workflows/shared.yml
|
||||
`)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists only typed job variants when a job type is invalid', async () => {
|
||||
mockSchema([buildJobVariant, reusableWorkflowCallVariant, repackJobVariant]);
|
||||
|
||||
await expect(
|
||||
validateAsync(`
|
||||
jobs:
|
||||
oops:
|
||||
type: not-a-job-type
|
||||
`)
|
||||
).rejects.toThrow(
|
||||
'The following jobs have invalid types: oops. Valid types are: build, repack'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a repack job that sets only build_id', async () => {
|
||||
mockSchema([buildJobVariant, repackJobVariant]);
|
||||
|
||||
await expect(
|
||||
validateAsync(`
|
||||
jobs:
|
||||
build_android:
|
||||
type: build
|
||||
params:
|
||||
platform: android
|
||||
profile: production
|
||||
repack_android:
|
||||
type: repack
|
||||
needs: [build_android]
|
||||
params:
|
||||
build_id: \${{ needs.build_android.outputs.build_id }}
|
||||
`)
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to schema validation when the schema lists no job types', async () => {
|
||||
mockSchema([reusableWorkflowCallVariant]);
|
||||
|
||||
await expect(
|
||||
validateAsync(`
|
||||
jobs:
|
||||
build_ios:
|
||||
type: build
|
||||
`)
|
||||
).rejects.toThrow("The value at /jobs/build_ios is missing the required field 'uses'.");
|
||||
});
|
||||
|
||||
it('still rejects a repack job whose profile is not in eas.json', async () => {
|
||||
mockSchema([repackJobVariant]);
|
||||
|
||||
await expect(
|
||||
validateAsync(`
|
||||
jobs:
|
||||
repack_android:
|
||||
type: repack
|
||||
params:
|
||||
build_id: abc
|
||||
profile: missing
|
||||
`)
|
||||
).rejects.toThrow(
|
||||
'The build jobs in this workflow refer to the following build profiles that are not present in your eas.json file: missing'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,11 @@ async function validateWorkflowOnServerAsync(
|
||||
|
||||
async function validateWorkflowBuildJobsAsync(parsedYaml: any, projectDir: string): Promise<void> {
|
||||
const jobs = jobsFromWorkflow(parsedYaml);
|
||||
const buildJobs = jobs.filter(job => jobTypesWithBuildProfile.has(job.value.type));
|
||||
// `profile` is optional (e.g. on `repack`), so only jobs that set one can be checked against eas.json.
|
||||
const buildJobs = jobs.filter(
|
||||
job =>
|
||||
jobTypesWithBuildProfile.has(job.value.type) && typeof job.value.params?.profile === 'string'
|
||||
);
|
||||
if (buildJobs.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -131,6 +135,11 @@ async function validateWorkflowBuildJobsAsync(parsedYaml: any, projectDir: strin
|
||||
function validateWorkflowJobTypes(parsedYaml: any, workflowJsonSchema: any): void {
|
||||
const jobs = jobsFromWorkflow(parsedYaml);
|
||||
const jobTypes = jobTypesFromWorkflowSchema(workflowJsonSchema);
|
||||
if (jobTypes.length === 0) {
|
||||
// The schema no longer lists job types where we expect them; leave job validation to the schema.
|
||||
Log.debug('No job types found in the workflow schema, skipping job type validation.');
|
||||
return;
|
||||
}
|
||||
const invalidJobs = jobs.filter(job => job.value.type && !jobTypes.includes(job.value.type));
|
||||
if (invalidJobs.length > 0) {
|
||||
throw new Error(
|
||||
@@ -206,7 +215,8 @@ function jobsFromWorkflow(parsedYaml: any): any[] {
|
||||
}
|
||||
|
||||
function jobTypesFromWorkflowSchema(workflowJsonSchema: any): string[] {
|
||||
return workflowJsonSchema?.properties?.jobs?.additionalProperties?.anyOf.map(
|
||||
(props: any) => props.properties.type.const
|
||||
);
|
||||
// Not every job variant has a `type` (e.g. a job that calls a reusable workflow with `uses`).
|
||||
return (workflowJsonSchema?.properties?.jobs?.additionalProperties?.anyOf ?? [])
|
||||
.map((props: any) => props?.properties?.type?.const)
|
||||
.filter((jobType: unknown): jobType is string => typeof jobType === 'string');
|
||||
}
|
||||
@@ -164,6 +164,22 @@ describe(SimulatorEvents, () => {
|
||||
expect(mockLog).toHaveBeenCalledWith('formatted:event-id');
|
||||
});
|
||||
|
||||
it('keeps following a session in a status this version does not know', async () => {
|
||||
mockEventsByIdAsync
|
||||
.mockResolvedValueOnce(createSession('QUEUED' as DeviceRunSessionStatus))
|
||||
.mockResolvedValueOnce(createSession('STARTING' as DeviceRunSessionStatus))
|
||||
.mockResolvedValueOnce(createSession(DeviceRunSessionStatus.InProgress))
|
||||
.mockResolvedValueOnce(createSession(DeviceRunSessionStatus.Stopped))
|
||||
.mockResolvedValueOnce(createSession(DeviceRunSessionStatus.Stopped))
|
||||
.mockResolvedValueOnce(createSession(DeviceRunSessionStatus.Stopped));
|
||||
mockDownloadEventsAsync.mockResolvedValue([createEvent()]);
|
||||
const command = createCommand(['--id', 'session-id', '--follow']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEventsByIdAsync).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it('wakes from polling and flushes incomplete events when follow mode is interrupted', async () => {
|
||||
const existingInterruptListeners = new Set(process.listeners('SIGINT'));
|
||||
mockEventsByIdAsync.mockResolvedValue(createSession(DeviceRunSessionStatus.InProgress));
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '../../../simulator/env';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo';
|
||||
import * as promiseUtils from '../../../utils/promise';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import Simulator from '../index';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
@@ -53,6 +54,11 @@ jest.mock('../../../simulator/env', () => ({
|
||||
resetSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../simulator/expoGo');
|
||||
jest.mock('../../../utils/json');
|
||||
jest.mock('../../../utils/promise', () => ({
|
||||
...jest.requireActual('../../../utils/promise'),
|
||||
sleepAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../../../prompts');
|
||||
jest.mock('../../../simulator/egress', () => ({
|
||||
...jest.requireActual('../../../simulator/egress'),
|
||||
@@ -93,6 +99,8 @@ const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
|
||||
const mockResolveExpoGoSdkVersionAsync = jest.mocked(resolveExpoGoSdkVersionAsync);
|
||||
const mockOra = jest.mocked(ora);
|
||||
const mockPromptAsync = jest.mocked(promptAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeCreatedDeviceRunSession(
|
||||
overrides: Partial<CreatedDeviceRunSession> = {}
|
||||
@@ -164,6 +172,7 @@ describe(Simulator, () => {
|
||||
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.clearAllMocks();
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
mockAvailabilityByAppIdAsync.mockResolvedValue({ accountName: 'testuser', available: true });
|
||||
@@ -454,6 +463,101 @@ describe(Simulator, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('passes --network-capture to the createDeviceRunSession mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--network-capture',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'project-123',
|
||||
name: undefined,
|
||||
networkCapture: true,
|
||||
packageVersion: undefined,
|
||||
platform: AppPlatform.Ios,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the requested capture fields, splitting a comma-separated value', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--network-capture',
|
||||
'--network-capture-field',
|
||||
'header,query',
|
||||
'--network-capture-field',
|
||||
'request-body',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
networkCapture: true,
|
||||
networkCaptureFields: ['header', 'query', 'request-body'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not send a capture field list when none was asked for', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--network-capture',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.not.objectContaining({ networkCaptureFields: expect.anything() })
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a capture field the recorder does not know', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--network-capture',
|
||||
'--network-capture-field',
|
||||
'cookies',
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(/Unknown network capture field 'cookies'/);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects capture fields without --network-capture', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--network-capture-field',
|
||||
'header',
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --network-capture on android', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'android',
|
||||
'--non-interactive',
|
||||
'--network-capture',
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(/only supported on iOS/);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`throws when ${EAS_SIMULATOR_SESSION_ID} is already present with --no-force`, async () => {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
|
||||
|
||||
@@ -476,6 +580,20 @@ describe(Simulator, () => {
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
});
|
||||
|
||||
it('keeps the live spinner text short and prints the stop command once', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped }));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'To stop the session from another shell, run: eas simulator:stop --id session-123'
|
||||
);
|
||||
expect(mockOra).toHaveBeenLastCalledWith('Simulator session active — press Ctrl+C to stop');
|
||||
});
|
||||
|
||||
it('forwards --name to the create mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
@@ -1099,6 +1217,10 @@ describe(Simulator, () => {
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('eas simulator:egress'));
|
||||
expect(runLocalEgressAsync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
expect.stringContaining("EAS_SIMULATOR_EGRESS_PLATFORM='ios'")
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1138,8 +1260,14 @@ describe(Simulator, () => {
|
||||
});
|
||||
|
||||
it.each(localEgressSessions)(
|
||||
'rejects local egress for Android %s before creating a session',
|
||||
async typeFlag => {
|
||||
'creates Android %s sessions with local egress and marks them as Android',
|
||||
async (typeFlag, type, remoteConfig) => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
type,
|
||||
remoteConfig: { ...remoteConfig, ...localEgressFields },
|
||||
})
|
||||
);
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'android',
|
||||
@@ -1149,10 +1277,21 @@ describe(Simulator, () => {
|
||||
'local',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'--egress local is only supported with --platform ios.'
|
||||
await command.runAsync();
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
platform: AppPlatform.Android,
|
||||
egress: DeviceRunSessionEgress.Local,
|
||||
})
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
expect.stringContaining("EAS_SIMULATOR_EGRESS_PLATFORM='android'")
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('such as UDP, is refused on the device host')
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1289,4 +1428,280 @@ describe(Simulator, () => {
|
||||
expect(mockPromptAsync).not.toHaveBeenCalled();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails the create spinner and rethrows when session creation fails', async () => {
|
||||
mockCreateDeviceRunSessionAsync.mockRejectedValue(new Error('create boom'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('create boom');
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the session errors before it becomes ready', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored, remoteConfig: null })
|
||||
);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(/errored before the .* session was ready/);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the turtle job run finishes before the session is ready', async () => {
|
||||
mockByIdAsync.mockResolvedValue(
|
||||
makeDeviceRunSession({
|
||||
remoteConfig: null,
|
||||
turtleJobRun: { id: 'job-123', status: JobRunStatus.Errored },
|
||||
})
|
||||
);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
/Turtle job run for simulator session .* errored before/
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps polling until the remote config appears', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ remoteConfig: null }))
|
||||
.mockResolvedValueOnce(makeDeviceRunSession());
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops the session and rethrows when polling for readiness fails', async () => {
|
||||
mockByIdAsync.mockRejectedValue(new Error('poll boom'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('poll boom');
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
});
|
||||
|
||||
it('times out when the session never becomes ready', async () => {
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession({ remoteConfig: null }));
|
||||
const realNow = Date.now();
|
||||
jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockReturnValueOnce(realNow)
|
||||
.mockReturnValue(realNow + 60 * 60 * 1000);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(/Timed out after \d+s waiting for/);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints JSON only and skips the human-readable output with --json', async () => {
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'session-123', deviceRunSessionUrl })
|
||||
);
|
||||
});
|
||||
|
||||
it('warns but continues when the dotenv file cannot be written', async () => {
|
||||
jest.mocked(fs.writeFile).mockRejectedValue(new Error('disk full') as never);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Failed to write simulator environment variables to ${SIMULATOR_DOTENV_FILE_NAME}`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('retries after a transient poll failure while the session is running', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockRejectedValueOnce(new Error('transient'))
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped }));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to poll simulator session')
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
});
|
||||
|
||||
it('throws when the session errors while it is running', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Errored }));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('Simulator session session-123 errored.');
|
||||
});
|
||||
|
||||
it('keeps waiting while the session is still in progress', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockResolvedValueOnce(
|
||||
makeDeviceRunSession({ turtleJobRun: { id: 'job-123', status: JobRunStatus.Finished } })
|
||||
);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockByIdAsync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rethrows when clearing the dotenv file fails after the session ends', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped }));
|
||||
mockResetSimulatorEnvAsync.mockRejectedValue(new Error('unlink failed'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('unlink failed');
|
||||
expect(Log.error).toHaveBeenCalledWith(`Failed to clean up ${SIMULATOR_DOTENV_FILE_NAME}`);
|
||||
});
|
||||
|
||||
it('warns and reports failure when the session cannot be stopped', async () => {
|
||||
mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession()).mockImplementationOnce(async () => {
|
||||
process.emit('SIGINT');
|
||||
return makeDeviceRunSession();
|
||||
});
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue(new Error('stop boom'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
'Failed to stop simulator session session-123: stop boom'
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the session when interrupted while it is running', async () => {
|
||||
mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession()).mockImplementationOnce(async () => {
|
||||
process.emit('SIGINT');
|
||||
return makeDeviceRunSession();
|
||||
});
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
});
|
||||
|
||||
it('stops the session and reports when the stop cannot be confirmed after an interrupt before ready', async () => {
|
||||
jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
mockByIdAsync.mockImplementation(async () => {
|
||||
process.emit('SIGINT');
|
||||
return makeDeviceRunSession({ remoteConfig: null });
|
||||
});
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue(new Error('stop boom'));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('process.exit(130)');
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the session when interrupted after it is ready', async () => {
|
||||
jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
jest
|
||||
.mocked(fs.writeFile)
|
||||
.mockImplementationOnce((async () => undefined) as never)
|
||||
.mockImplementationOnce((async () => {
|
||||
process.emit('SIGINT');
|
||||
}) as never);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('process.exit(130)');
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir, 'session-123');
|
||||
});
|
||||
|
||||
it('force exits when a second interrupt arrives', async () => {
|
||||
const exitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
mockByIdAsync.mockResolvedValueOnce(makeDeviceRunSession()).mockImplementationOnce(async () => {
|
||||
process.emit('SIGINT');
|
||||
process.emit('SIGINT');
|
||||
return makeDeviceRunSession();
|
||||
});
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.error).toHaveBeenCalledWith(
|
||||
'Aborted before the simulator session could be stopped. Run `eas simulator:stop --id session-123` to terminate it and avoid unexpected charges.'
|
||||
);
|
||||
expect(exitSpy).toHaveBeenCalledWith(130);
|
||||
});
|
||||
|
||||
it('stringifies a non-Error dotenv write failure', async () => {
|
||||
jest.mocked(fs.writeFile).mockRejectedValue('disk full' as never);
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('disk full'));
|
||||
});
|
||||
|
||||
it('stringifies a non-Error poll failure while the session is running', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockRejectedValueOnce('network down')
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped }));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.debug).toHaveBeenCalledWith(expect.stringContaining('network down'));
|
||||
});
|
||||
|
||||
it('stringifies a non-Error stop failure', async () => {
|
||||
jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
});
|
||||
mockByIdAsync.mockImplementation(async () => {
|
||||
process.emit('SIGINT');
|
||||
return makeDeviceRunSession({ remoteConfig: null });
|
||||
});
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockRejectedValue('gateway timeout');
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('process.exit(130)');
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
'Failed to stop simulator session session-123: gateway timeout'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -162,6 +162,10 @@ describe(SimulatorList, () => {
|
||||
'in-progress',
|
||||
'--status',
|
||||
'new',
|
||||
'--status',
|
||||
'queued',
|
||||
'--status',
|
||||
'starting',
|
||||
'--type',
|
||||
'appium',
|
||||
'--type',
|
||||
@@ -186,8 +190,17 @@ describe(SimulatorList, () => {
|
||||
first: 25,
|
||||
after: 'page-cursor',
|
||||
filter: {
|
||||
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
|
||||
types: [DeviceRunSessionType.Appium, DeviceRunSessionType.WebPreviewOnly],
|
||||
statuses: [
|
||||
DeviceRunSessionStatus.InProgress,
|
||||
DeviceRunSessionStatus.New,
|
||||
DeviceRunSessionStatus.Queued,
|
||||
DeviceRunSessionStatus.Starting,
|
||||
],
|
||||
types: [
|
||||
DeviceRunSessionType.Appium,
|
||||
DeviceRunSessionType.ServeSim,
|
||||
DeviceRunSessionType.WebPreviewOnly,
|
||||
],
|
||||
platforms: [AppPlatform.Ios],
|
||||
name: 'checkout',
|
||||
tags: ['variant:pro', 'nightly'],
|
||||
|
||||
@@ -127,9 +127,11 @@ export default class SimulatorEvents extends EasCommand {
|
||||
return;
|
||||
}
|
||||
|
||||
// Any status that isn't final counts as running, so statuses added later (such as queued
|
||||
// and starting) keep follow mode going.
|
||||
const isRunning =
|
||||
session.status === DeviceRunSessionStatus.New ||
|
||||
session.status === DeviceRunSessionStatus.InProgress;
|
||||
session.status !== DeviceRunSessionStatus.Stopped &&
|
||||
session.status !== DeviceRunSessionStatus.Errored;
|
||||
if (isRunning) {
|
||||
observedRunningSession = true;
|
||||
remainingPostStopRefreshes = 0;
|
||||
|
||||
@@ -113,6 +113,7 @@ export default class SimulatorGet extends EasCommand {
|
||||
Log.log(
|
||||
formatRemoteSessionInstructions(session.remoteConfig, 'env', {
|
||||
sessionUrl: deviceRunSessionUrl,
|
||||
platform: session.platform,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -23,6 +23,7 @@ import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { parseEgressAllowList, runLocalEgressAsync } from '../../simulator/egress';
|
||||
import { parseNetworkCaptureFields } from '../../simulator/networkCapture';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_RESOURCE_CLASS_FLAG_VALUES,
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
DeviceRunSessionRemoteConfig,
|
||||
formatRemoteSessionInstructions,
|
||||
@@ -120,16 +122,26 @@ export default class Simulator extends EasCommand {
|
||||
type: Flags.option({
|
||||
description:
|
||||
'Type of simulator session to create. All session types include a web preview. agent-device, appium, and argent also include an automation interface; web-preview-only includes no automation interface.',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
options: DEVICE_RUN_SESSION_TYPE_FLAG_OPTIONS,
|
||||
default: DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice],
|
||||
})(),
|
||||
'package-version': Flags.string({
|
||||
description:
|
||||
'Version of the package backing the simulator session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
|
||||
}),
|
||||
'network-capture': Flags.boolean({
|
||||
description:
|
||||
'Record HTTP(S) traffic from apps on the device (iOS only). HTTPS is decrypted, so recordings contain credentials in cleartext and certificate-pinned apps fail to connect.',
|
||||
}),
|
||||
'network-capture-field': Flags.string({
|
||||
description:
|
||||
'What a recording may keep beyond method, URL, status, timing and size: header, query, request-body, response-body. Repeatable or comma-separated. Defaults to none of them, because each can carry credentials.',
|
||||
multiple: true,
|
||||
dependsOn: ['network-capture'],
|
||||
}),
|
||||
'max-duration-minutes': Flags.integer({
|
||||
description:
|
||||
'Maximum duration of the simulator session in minutes before it is automatically stopped. Only customizable on paid plans. Defaults to a value derived from the job run priority when omitted.',
|
||||
"Maximum duration of the simulator session in minutes before it is automatically stopped. Defaults to, and cannot exceed, the maximum session duration of the account's plan.",
|
||||
min: 0,
|
||||
}),
|
||||
'max-idle-time-minutes': Flags.integer({
|
||||
@@ -144,12 +156,12 @@ export default class Simulator extends EasCommand {
|
||||
})(),
|
||||
egress: Flags.option({
|
||||
description:
|
||||
'With "local", the simulator system proxy points at this machine: HTTP(S) and WebSocket requests that honor it (WebKit, URLSession) and clients that read proxy environment variables (gRPC, libcurl) exit from this machine and fail while the egress client is disconnected. Connections that ignore both are refused inside the simulator and listed, with the library that tried, in the Logs section of the session page on expo.dev. The egress client must keep running for the life of the session. Only supported with --platform ios.',
|
||||
'With "local", the simulator\'s network traffic exits from this machine and fails while the egress client is disconnected. On iOS, the simulator system proxy points at this machine: HTTP(S) and WebSocket requests that honor it (WebKit, URLSession) and clients that read proxy environment variables (gRPC, libcurl) use it, and connections that ignore both are refused inside the simulator and listed, with the library that tried, in the Logs section of the session page on expo.dev. On Android, all TCP traffic from the emulator goes through its proxy to this machine; traffic that cannot (UDP, such as QUIC) is refused and listed in the same Logs section, and DNS for apps that ignore the system proxy resolves on the device host. The egress client must keep running for the life of the session.',
|
||||
options: EGRESS_FLAG_VALUES,
|
||||
})(),
|
||||
'egress-allow': Flags.string({
|
||||
description:
|
||||
'Destination on this machine or its network that the simulator may reach through local egress, as an exact host:port (for example localhost:3000). Repeat for multiple destinations. A localhost or 127.0.0.1 entry also forwards that port from the simulator host to this machine (like adb reverse), so dev server URLs that use 127.0.0.1 work. Requires --egress local.',
|
||||
'Destination on this machine or its network that the simulator may reach through local egress, as an exact host:port (for example localhost:3000). Repeat for multiple destinations. A localhost or 127.0.0.1 entry also forwards that port from the simulator host to this machine (like adb reverse), so dev server URLs that use 127.0.0.1 work. On Android, such an entry also covers 10.0.2.2 on the same port. Requires --egress local.',
|
||||
multiple: true,
|
||||
dependsOn: ['egress'],
|
||||
}),
|
||||
@@ -240,17 +252,28 @@ export default class Simulator extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
// Before the platform prompt: a typo here does not depend on the answer.
|
||||
let networkCaptureFields: string[] = [];
|
||||
try {
|
||||
networkCaptureFields = parseNetworkCaptureFields(flags['network-capture-field'] ?? []);
|
||||
} catch (err) {
|
||||
throw new EasCommandError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
const platform = await resolvePlatformAsync(flags.platform, nonInteractive);
|
||||
const egress = flags.egress === 'local' ? DeviceRunSessionEgress.Local : undefined;
|
||||
if (egress && platform !== AppPlatform.Ios) {
|
||||
throw new EasCommandError('--egress local is only supported with --platform ios.');
|
||||
}
|
||||
let egressAllow: string[] = [];
|
||||
try {
|
||||
egressAllow = parseEgressAllowList(flags['egress-allow'] ?? []);
|
||||
} catch (err) {
|
||||
throw new EasCommandError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
if (flags['network-capture'] && platform !== AppPlatform.Ios) {
|
||||
throw new EasCommandError(
|
||||
'Network capture is only supported on iOS simulator sessions. Re-run without --network-capture, or pass --platform ios.'
|
||||
);
|
||||
}
|
||||
|
||||
if (platform === AppPlatform.Android) {
|
||||
Log.warn(
|
||||
'Android emulator support in EAS Simulator is still in development. Some features available on iOS may not work on Android yet. Full parity with iOS is coming soon.'
|
||||
@@ -282,6 +305,8 @@ export default class Simulator extends EasCommand {
|
||||
platform,
|
||||
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
|
||||
packageVersion: flags['package-version'],
|
||||
networkCapture: flags['network-capture'],
|
||||
...(networkCaptureFields.length ? { networkCaptureFields } : {}),
|
||||
...(deviceIdentifier
|
||||
? platform === AppPlatform.Ios
|
||||
? { ios: { deviceIdentifier } }
|
||||
@@ -319,6 +344,11 @@ export default class Simulator extends EasCommand {
|
||||
simulatorEnvWritten ? `, saved to ${SIMULATOR_DOTENV_FILE_NAME}` : ''
|
||||
}) ${link(deviceRunSessionUrl)}`
|
||||
);
|
||||
if (flags['network-capture']) {
|
||||
Log.warn(
|
||||
'Network capture was requested. HTTPS is decrypted, so recordings contain credentials in cleartext. Relaunch an installed app to record its traffic.'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
createSpinner.fail('Failed to create simulator session');
|
||||
sessionInterrupt?.dispose();
|
||||
@@ -408,7 +438,7 @@ export default class Simulator extends EasCommand {
|
||||
|
||||
if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) {
|
||||
await writeSimulatorEnvSafelyAsync(projectDir, {
|
||||
...getRemoteSessionEnvironmentVariables(remoteConfig, { egressAllow }),
|
||||
...getRemoteSessionEnvironmentVariables(remoteConfig, { egressAllow, platform }),
|
||||
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
|
||||
});
|
||||
}
|
||||
@@ -442,11 +472,12 @@ export default class Simulator extends EasCommand {
|
||||
egressAllow,
|
||||
egressClientRunsInline: !nonInteractive,
|
||||
sessionUrl: deviceRunSessionUrl,
|
||||
platform,
|
||||
})
|
||||
);
|
||||
Log.newLine();
|
||||
|
||||
const localEgress = getLocalEgressConfig(remoteConfig, egressAllow);
|
||||
const localEgress = getLocalEgressConfig(remoteConfig, egressAllow, platform);
|
||||
|
||||
if (nonInteractive) {
|
||||
sessionInterrupt.dispose();
|
||||
@@ -562,9 +593,12 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
projectDir: string;
|
||||
sessionInterrupt: SessionInterrupt;
|
||||
}): Promise<void> {
|
||||
const spinner = ora(
|
||||
`Simulator session active — press Ctrl+C to stop, or run \`eas simulator:stop --id ${deviceRunSessionId}\` from another shell`
|
||||
).start();
|
||||
Log.log(
|
||||
`To stop the session from another shell, run: eas simulator:stop --id ${deviceRunSessionId}`
|
||||
);
|
||||
// Keep the spinner text short so it fits on one line. When the text wraps, resizing the
|
||||
// terminal can leave copies of the spinner line behind.
|
||||
const spinner = ora('Simulator session active — press Ctrl+C to stop').start();
|
||||
|
||||
const { signal } = sessionInterrupt;
|
||||
try {
|
||||
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user