mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f95ca50d9 |
No files matched your search
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@expo/eas-build-job': minor
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Explain Google Play failures caused by missing app declarations, a disabled API, or an upload certificate that is not yet active.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add experimentation client.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add a Free plan build limit guidance experiment for coding agents to `eas build`.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
---
|
||||
|
||||
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
'@expo/steps': patch
|
||||
---
|
||||
|
||||
Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'eas-cli': none
|
||||
'@expo/build-tools': none
|
||||
---
|
||||
|
||||
Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Stop sandboxes after an optional maximum duration measured from readiness.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Share one GraphQL client between the project ID check and the command, so commands send one fewer app query.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': minor
|
||||
---
|
||||
|
||||
Add `eas simulator:simctl` to set the appearance of a remote iOS simulator session and restart its SpringBoard.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
'@expo/eas-build-job': major
|
||||
'@expo/build-tools': major
|
||||
---
|
||||
|
||||
Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports.
|
||||
@@ -2,36 +2,6 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
|
||||
- [build-tools] Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
|
||||
- [build-tools] Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [25.2.0](https://github.com/expo/eas-cli/releases/tag/v25.2.0) - 2026-10-17
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [eas-cli] Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- [build-tools] Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Prepare authenticated simulator action transport and file transfers. ([#4642](https://github.com/expo/eas-cli/pull/4642) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
@@ -215,7 +215,7 @@ if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'pro
|
||||
Artifacts tracked as `ArtifactToUpload`:
|
||||
|
||||
- Managed artifacts (APK, IPA, AAB) with specific handling
|
||||
- Workflow artifacts for any file
|
||||
- Generic artifacts for any file
|
||||
- Upload via `ctx.uploadArtifact()` or `upload-artifact` step
|
||||
|
||||
## Package Interdependencies
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "25.3.0",
|
||||
"PRODUCTION": "25.3.0"
|
||||
"STAGING": "25.1.1",
|
||||
"PRODUCTION": "25.1.1"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "25.3.0",
|
||||
"version": "25.1.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
|
||||
actionlint = "1.7.12"
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "22.23.4"
|
||||
node = "22.23.3"
|
||||
@@ -1,26 +1,5 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 25.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Let serve-sim boot, install and launch Argent iOS sessions. ([#4602](https://github.com/expo/eas-cli/pull/4602) by [@gwdp](https://github.com/gwdp))
|
||||
- Let serve-sim boot, install and launch Appium iOS sessions. ([#4603](https://github.com/expo/eas-cli/pull/4603) by [@gwdp](https://github.com/gwdp))
|
||||
- Upload the screen recording session manifest as a JSON artifact alongside each video. ([#4689](https://github.com/expo/eas-cli/pull/4689) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Quote Android launch arguments and URLs for the device shell in simulator sessions, so values with spaces or shell characters reach `am start` as one argument; pass raw values and remove any shell quoting added as a workaround. ([#4695](https://github.com/expo/eas-cli/pull/4695) by [@brentvatne](https://github.com/brentvatne))
|
||||
- Wait up to five minutes for Google Play to finish processing an uploaded bundle before giving up on the edit commit. ([#4700](https://github.com/expo/eas-cli/pull/4700) by [@hSATAC](https://github.com/hSATAC))
|
||||
- Reset the device run session idle timer on iOS Simulator preview input. ([#4691](https://github.com/expo/eas-cli/pull/4691) by [@gwdp](https://github.com/gwdp))
|
||||
- Forward captured Simulator traffic through the local egress proxy. ([#4598](https://github.com/expo/eas-cli/pull/4598) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 25.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Let serve-sim boot, install and launch agent-device iOS sessions with the local-egress guard. ([#4544](https://github.com/expo/eas-cli/pull/4544) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 25.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "25.3.0",
|
||||
"version": "25.1.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -26,7 +26,7 @@
|
||||
"build:egress-guard": "resources/egress-guard/build.sh",
|
||||
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
|
||||
"typecheck": "tsc",
|
||||
"generate-appium-commands": "mise exec [email protected]3.4 -- node scripts/generate-appium-commands.js",
|
||||
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
|
||||
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
|
||||
"jest-unit": "jest --config jest/unit-config.ts",
|
||||
"jest-integration": "jest --config jest/integration-config.ts",
|
||||
@@ -42,17 +42,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "25.3.0",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/downloader": "25.1.1",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "25.3.0",
|
||||
"@expo/template-file": "25.3.0",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"@expo/steps": "25.1.1",
|
||||
"@expo/template-file": "25.1.1",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
@@ -16,13 +16,11 @@ function createCtx(
|
||||
{ workingdir = '/tmp/wd' }: { workingdir?: string } = {}
|
||||
): {
|
||||
ctx: BuildContext<Ios.Job>;
|
||||
info: jest.Mock;
|
||||
warn: jest.Mock;
|
||||
} {
|
||||
const info = jest.fn();
|
||||
const warn = jest.fn();
|
||||
const logger = {
|
||||
info,
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn,
|
||||
@@ -38,7 +36,7 @@ function createCtx(
|
||||
workingdir,
|
||||
}
|
||||
);
|
||||
return { ctx, info, warn };
|
||||
return { ctx, warn };
|
||||
}
|
||||
|
||||
// Project checkout lives at <workingdir>/build (job projectRootDirectory is '.').
|
||||
@@ -81,31 +79,22 @@ describe(parseJobHooksAsync, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('writes nothing to the build log while parsing', async () => {
|
||||
const { ctx, info, warn } = createCtx({
|
||||
before_install_node_modules: [{ run: 'echo hi' }],
|
||||
before_submit: [{ run: 'echo hi' }],
|
||||
before_bogus: [{ run: 'echo hi' }],
|
||||
});
|
||||
await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(info).not.toHaveBeenCalled();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats an unknown hook key as inert', async () => {
|
||||
const { ctx } = createCtx({
|
||||
it('treats an unknown hook key as inert and warns', async () => {
|
||||
const { ctx, warn } = createCtx({
|
||||
before_bogus: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey).toEqual({});
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
|
||||
});
|
||||
|
||||
it('shape-checks a registered-but-unwrapped key, then skips it', async () => {
|
||||
const { ctx } = createCtx({
|
||||
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
|
||||
const { ctx, warn } = createCtx({
|
||||
before_submit: [{ run: 'echo hi' }],
|
||||
});
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
|
||||
});
|
||||
|
||||
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
|
||||
@@ -203,9 +192,10 @@ describe(parseJobHooksAsync, () => {
|
||||
});
|
||||
|
||||
it('never loads a composite function referenced under a registered-but-unwrapped key', async () => {
|
||||
const { ctx } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
|
||||
const { ctx, warn } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
|
||||
const parsed = await parseJobHooksAsync(ctx, INSTALL);
|
||||
expect(parsed!.hookEntriesByKey).toEqual({});
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
|
||||
});
|
||||
|
||||
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
|
||||
|
||||
@@ -44,8 +44,9 @@ export async function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({
|
||||
if (hooks) {
|
||||
try {
|
||||
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
|
||||
} catch {
|
||||
// The phase error wins.
|
||||
} catch (hookError) {
|
||||
// The phase error wins; still surface the after-hook failure in the log.
|
||||
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
|
||||
}
|
||||
}
|
||||
throw phaseError;
|
||||
|
||||
@@ -53,17 +53,19 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
|
||||
const globalContext = new BuildStepGlobalContext(customBuildContext, false);
|
||||
// Hook steps run in the checked-out project directory, not the pre-checkout
|
||||
// target directory the global context defaults to.
|
||||
globalContext.markAsCheckedOut();
|
||||
globalContext.markAsCheckedOut(ctx.logger);
|
||||
|
||||
const externalFunctions = getEasFunctions(customBuildContext);
|
||||
const externalFunctionGroups = getEasFunctionGroups(customBuildContext);
|
||||
|
||||
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
|
||||
// a malformed `before_submit` fails a steps build, so it fails here too). An
|
||||
// unknown key is inert — a key newer than this worker must be ignored, never
|
||||
// fail the job.
|
||||
// a malformed `before_submit` fails a steps build, so it fails here too), and
|
||||
// warn on keys this build won't run. An unknown key is inert — a key newer
|
||||
// than this worker must be ignored, never fail the job.
|
||||
for (const [key, steps] of Object.entries(hooks)) {
|
||||
if (parseHookKey(key) === null) {
|
||||
const parsed = parseHookKey(key);
|
||||
if (parsed === null) {
|
||||
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(steps) && steps.length === 0) {
|
||||
@@ -79,6 +81,15 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
// Registered but not wrapped by this build: shape-checked above, then
|
||||
// skipped. Function existence is deliberately NOT checked (that happens only
|
||||
// during construction) — an unknown `uses:` under an unwrapped key warns
|
||||
// rather than failing the build.
|
||||
if (!wrappedAnchors.includes(parsed.anchorId)) {
|
||||
ctx.logger.warn(
|
||||
`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
BuildPhaseResult,
|
||||
Env,
|
||||
EnvironmentSecretType,
|
||||
GenericArtifactType,
|
||||
Job,
|
||||
LogMarker,
|
||||
ManagedArtifactType,
|
||||
Metadata,
|
||||
errors,
|
||||
isGenericArtifact,
|
||||
} from '@expo/eas-build-job';
|
||||
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
|
||||
import { bunyan } from '@expo/logger';
|
||||
@@ -40,7 +42,7 @@ export type ArtifactToUpload =
|
||||
paths: string[];
|
||||
}
|
||||
| {
|
||||
type: null;
|
||||
type: GenericArtifactType;
|
||||
name: string;
|
||||
paths: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -239,7 +241,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
logger: bunyan;
|
||||
}): Promise<{ artifactId: string | null }> {
|
||||
const result = await this._uploadArtifact({ artifact, logger });
|
||||
if (result.filename && artifact.type !== null) {
|
||||
if (result.filename && !isGenericArtifact(artifact)) {
|
||||
this.artifacts[artifact.type] = result.filename;
|
||||
}
|
||||
return { artifactId: result.artifactId ?? null };
|
||||
|
||||
@@ -48,10 +48,7 @@ describe(createCheckoutBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('moves project sources to the target directory when no inputs are given', async () => {
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const { globalCtx, executeAsync } = createCheckoutStep({
|
||||
logger,
|
||||
projectArchive: GIT_PROJECT_ARCHIVE,
|
||||
});
|
||||
|
||||
@@ -59,9 +56,6 @@ describe(createCheckoutBuildFunction, () => {
|
||||
|
||||
expect(await vol.promises.readFile(`${projectTargetDirectory}/app.json`, 'utf8')).toBe('{}');
|
||||
expect(globalCtx.wasCheckedOut()).toBe(true);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Changing default working directory to')
|
||||
);
|
||||
expect(fetchAndCheckoutRefAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -102,11 +96,14 @@ describe(createCheckoutBuildFunction, () => {
|
||||
});
|
||||
|
||||
it('rejects the ref input when the project is already checked out', async () => {
|
||||
const logger = createMockLogger();
|
||||
jest.mocked(logger.child).mockReturnValue(logger);
|
||||
const { globalCtx, executeAsync } = createCheckoutStep({
|
||||
logger,
|
||||
projectArchive: GIT_PROJECT_ARCHIVE,
|
||||
callInputs: { ref: 'feature/add-icon' },
|
||||
});
|
||||
globalCtx.markAsCheckedOut();
|
||||
globalCtx.markAsCheckedOut(logger);
|
||||
|
||||
await expect(executeAsync()).rejects.toMatchObject({
|
||||
errorCode: 'EAS_CHECKOUT_REF_AFTER_CHECKOUT',
|
||||
|
||||
+3
-5
@@ -79,11 +79,9 @@ beforeEach(() => {
|
||||
apiUrl: 'https://preview.example.test',
|
||||
closeAsync: closePreview,
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: finishHost,
|
||||
});
|
||||
jest
|
||||
.mocked(startDeviceSessionHostAsync)
|
||||
.mockResolvedValue({ openPreviewAsync: openPreview, finishAsync: finishHost });
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: undefined,
|
||||
getOutput: () => '',
|
||||
|
||||
@@ -11,7 +11,6 @@ import * as tar from 'tar';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { Datadog } from '../../../datadog';
|
||||
import * as files from '../../../utils/files';
|
||||
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
|
||||
|
||||
@@ -31,17 +30,11 @@ const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.
|
||||
function createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
error,
|
||||
transientError,
|
||||
}: {
|
||||
applicationArchiveUrl?: string | null;
|
||||
error?: Error;
|
||||
transientError?: Error;
|
||||
}): Client {
|
||||
const toPromise = jest.fn();
|
||||
if (transientError) {
|
||||
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
|
||||
}
|
||||
toPromise.mockResolvedValue(
|
||||
const toPromise = jest.fn().mockResolvedValue(
|
||||
error
|
||||
? { error, data: undefined }
|
||||
: {
|
||||
@@ -69,7 +62,7 @@ function createSuccessfulResponse({
|
||||
url,
|
||||
headers,
|
||||
}: {
|
||||
body: Buffer | Readable;
|
||||
body: Buffer;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Response {
|
||||
@@ -79,7 +72,7 @@ function createSuccessfulResponse({
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: body instanceof Readable ? body : Readable.from(body),
|
||||
body: Readable.from(body),
|
||||
url,
|
||||
headers: {
|
||||
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
|
||||
@@ -87,47 +80,6 @@ function createSuccessfulResponse({
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function createUndiciNetworkError(code: string): CombinedError {
|
||||
return new CombinedError({
|
||||
networkError: Object.assign(new TypeError('fetch failed'), {
|
||||
cause: Object.assign(new Error(`read ${code}`), { code }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const { FetchError: ActualFetchError } = jest.requireActual(
|
||||
'node-fetch'
|
||||
) as typeof import('node-fetch');
|
||||
|
||||
function createSocketFetchError(url: string, code: string): Error {
|
||||
return new ActualFetchError(`request to ${url} failed, reason: connect ${code}`, 'system', {
|
||||
code,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function useFakeBackoffTimers(): void {
|
||||
// Streams and fs callbacks need the real nextTick and setImmediate; only the backoff timers are faked.
|
||||
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
|
||||
}
|
||||
|
||||
const MAX_IO_TURNS_BEFORE_BACKOFF = 1000;
|
||||
|
||||
async function waitForBackoffTimerAsync(): Promise<void> {
|
||||
for (let turns = 0; jest.getTimerCount() === 0; turns++) {
|
||||
if (turns === MAX_IO_TURNS_BEFORE_BACKOFF) {
|
||||
throw new Error('No backoff timer was scheduled.');
|
||||
}
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
async function fireBackoffTimersAsync(count: number): Promise<void> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await waitForBackoffTimerAsync();
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
}
|
||||
}
|
||||
|
||||
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
|
||||
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
|
||||
@@ -156,11 +108,6 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
@@ -518,13 +465,13 @@ describe('downloadBuild', () => {
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError with the cause code after retrying a failing build lookup', async () => {
|
||||
useFakeBackoffTimers();
|
||||
it('throws SystemError when GraphQL request fails with a network error', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: createUndiciNetworkError('ECONNRESET'),
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Network request failed'),
|
||||
}),
|
||||
});
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
@@ -533,81 +480,19 @@ describe('downloadBuild', () => {
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: [Network] fetch failed (ECONNRESET)`,
|
||||
});
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup failed on attempt 6', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.build_lookup',
|
||||
outcome: 'failed',
|
||||
attempts: '6',
|
||||
code: 'ECONNRESET',
|
||||
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a network error', createUndiciNetworkError('ECONNRESET'), 'ECONNRESET'],
|
||||
[
|
||||
'a transient GraphQL error',
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
{
|
||||
message: 'Too many requests',
|
||||
extensions: { isTransient: true, errorCode: 'TOO_MANY_REQUESTS' },
|
||||
},
|
||||
],
|
||||
response: { status: 200 },
|
||||
}),
|
||||
'TOO_MANY_REQUESTS',
|
||||
],
|
||||
])('downloads after a build lookup that failed once with %s', async (_, transientError, code) => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
transientError,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValue(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(path.basename(artifactPath)).toBe('app.apk');
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(2);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup recovered on attempt 2', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.build_lookup',
|
||||
outcome: 'recovered',
|
||||
attempts: '2',
|
||||
code,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request returns 5xx', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: [{ message: 'Internal Server Error' }],
|
||||
error: Object.assign(new Error('Internal Server Error'), {
|
||||
response: { status: 500 },
|
||||
}),
|
||||
});
|
||||
@@ -619,14 +504,12 @@ describe('downloadBuild', () => {
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Internal Server Error`,
|
||||
message: `Could not fetch build ${buildId}: Internal Server Error`,
|
||||
});
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -651,193 +534,10 @@ describe('downloadBuild', () => {
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
|
||||
});
|
||||
expect(graphqlClient.query).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads after an archive request that failed once with a socket error', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'))
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(path.basename(artifactPath)).toBe('app.apk');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws SystemError with the cause code after retrying a failing build archive request', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ETIMEDOUT'));
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not download the application archive: request to ${APPLICATION_ARCHIVE_URL} failed, reason: connect ETIMEDOUT`,
|
||||
});
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith('download_build.archive_download failed on attempt 6', {
|
||||
event: 'network_retry',
|
||||
operation: 'download_build.archive_download',
|
||||
outcome: 'failed',
|
||||
attempts: '6',
|
||||
code: 'ETIMEDOUT',
|
||||
});
|
||||
});
|
||||
|
||||
function createBrokenBody(code: string): Readable {
|
||||
return Readable.from(
|
||||
(async function* () {
|
||||
yield Buffer.from('he');
|
||||
throw Object.assign(new Error(`body failed with ${code}`), { code });
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
it.each(['ERR_STREAM_PREMATURE_CLOSE', 'ECONNRESET'])(
|
||||
'downloads again when the archive body breaks off with %s after the headers arrived',
|
||||
async code => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
const brokenBody = createBrokenBody(code);
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: brokenBody, url: 'https://cdn.test/app.apk' })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
const { artifactPath } = await promise;
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
|
||||
expect(datadogLog).toHaveBeenCalledWith(
|
||||
'download_build.archive_download recovered on attempt 2',
|
||||
expect.objectContaining({ outcome: 'recovered', code })
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('throws SystemError when the build archive body keeps breaking off', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
jest.mocked(fetch).mockImplementation(async () =>
|
||||
createSuccessfulResponse({
|
||||
body: createBrokenBody('ECONNRESET'),
|
||||
url: 'https://cdn.test/app.apk',
|
||||
})
|
||||
);
|
||||
const datadogLog = jest.spyOn(Datadog, 'log');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: 'Could not download the application archive: body failed with ECONNRESET',
|
||||
});
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
expect(datadogLog).toHaveBeenCalledWith(
|
||||
'download_build.archive_download failed on attempt 6',
|
||||
expect.objectContaining({ outcome: 'failed', code: 'ECONNRESET' })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not start another archive attempt after an abort during the backoff', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'));
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('step timed out');
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
signal: controller.signal,
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBe(reason);
|
||||
await waitForBackoffTimerAsync();
|
||||
controller.abort(reason);
|
||||
await jest.advanceTimersToNextTimerAsync();
|
||||
|
||||
await rejection;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rethrows the raw error of a failing user-supplied archive URL after the retries', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ENOTFOUND'));
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
});
|
||||
const rejection = expect(promise).rejects.toBeInstanceOf(ActualFetchError);
|
||||
await fireBackoffTimersAsync(5);
|
||||
|
||||
await rejection;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it('should throw an error if no matching files are found', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
@@ -905,21 +605,6 @@ describe('createDownloadBuildFunction', () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createApplicationResponse(buildId: string): Response {
|
||||
return new ActualResponse(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
build: {
|
||||
id: buildId,
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
function createStep(ctx: any, callInputs: Record<string, unknown>) {
|
||||
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
@@ -935,7 +620,18 @@ describe('createDownloadBuildFunction', () => {
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest.mocked(fetch).mockImplementation(async url => {
|
||||
if (url === workerApiUrl) {
|
||||
return createApplicationResponse(buildId);
|
||||
return new ActualResponse(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
build: {
|
||||
id: buildId,
|
||||
gitCommitHash: null,
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
@@ -965,33 +661,6 @@ describe('createDownloadBuildFunction', () => {
|
||||
expect(graphqlClient.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries the worker API lookup after a 5xx response', async () => {
|
||||
useFakeBackoffTimers();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(new ActualResponse('Service Unavailable', { status: 503 }))
|
||||
.mockResolvedValueOnce(createApplicationResponse(buildId))
|
||||
.mockResolvedValueOnce(
|
||||
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
|
||||
);
|
||||
|
||||
const execution = createStep(createSessionCtx(graphqlClient), {
|
||||
build_id: buildId,
|
||||
extensions: ['apk'],
|
||||
}).executeAsync();
|
||||
await fireBackoffTimersAsync(1);
|
||||
|
||||
await execution;
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(3);
|
||||
expect(jest.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
|
||||
workerApiUrl,
|
||||
workerApiUrl,
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not call the worker API for an application_archive_url input', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({});
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
@@ -202,263 +201,6 @@ describe(launchApplicationAsync, () => {
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('quotes Android launch arguments and URLs for the device shell', async () => {
|
||||
const logger = createMockLogger();
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
activityName: 'dev.example.app.MainActivity',
|
||||
launchArgs: ['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"],
|
||||
openUrl: 'exp://example.test/path?a=1&b=2',
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-d',
|
||||
'euxy://tempo',
|
||||
'--es',
|
||||
'probe',
|
||||
"'hello world'",
|
||||
'--es',
|
||||
'quote',
|
||||
"'it'\\''s'",
|
||||
'-n',
|
||||
'dev.example.app/dev.example.app.MainActivity',
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'-a',
|
||||
'android.intent.action.VIEW',
|
||||
'-d',
|
||||
"'exp://example.test/path?a=1&b=2'",
|
||||
],
|
||||
{ env: {}, logger }
|
||||
);
|
||||
expect(logger.info).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`Launching dev.example.app with arguments ${JSON.stringify(['-d', 'euxy://tempo', '--es', 'probe', 'hello world', '--es', 'quote', "it's"])}.`
|
||||
);
|
||||
});
|
||||
|
||||
it('passes iOS launch arguments with spaces unquoted', async () => {
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
launchArgs: ['-name', 'hello world'],
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'launch', 'booted', 'com.example.app', '-name', 'hello world'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('round-trips quoted Android arguments through a POSIX shell', async () => {
|
||||
const component = 'dev.example.app/dev.example.app.MainActivity$Inner';
|
||||
const launchArgs = [
|
||||
'--es',
|
||||
'text',
|
||||
'hello world',
|
||||
'--es',
|
||||
'json',
|
||||
'{"a":1,"b":2}',
|
||||
'--es',
|
||||
'shell',
|
||||
'$(printf injected) `printf injected` $HOME ; & | < >',
|
||||
'--es',
|
||||
'glob',
|
||||
'*',
|
||||
'--es',
|
||||
'home',
|
||||
'~',
|
||||
'--esa',
|
||||
'list',
|
||||
'a\\,b,c',
|
||||
'--es',
|
||||
'whitespace',
|
||||
'one\ntwo\tthree',
|
||||
'--es',
|
||||
'unicode',
|
||||
'héllo 🌍',
|
||||
'--es',
|
||||
'empty',
|
||||
'',
|
||||
];
|
||||
const openUrl = 'exp://example.test/path?a=1&b=2#fragment';
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'dev.example.app',
|
||||
activityName: 'dev.example.app.MainActivity$Inner',
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
// adb joins the words after `shell` with spaces and the device shell parses the result.
|
||||
const parsedCommands = mockedSpawn.mock.calls.map(([, args]) => {
|
||||
const { stdout } = spawnSync(
|
||||
'/bin/sh',
|
||||
['-c', `am() { printf '%s\\0' "$@"; }; ${(args as string[]).slice(1).join(' ')}`],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
return stdout.split('\0').slice(0, -1);
|
||||
});
|
||||
expect(parsedCommands).toEqual([
|
||||
['start', ...launchArgs, '-n', component],
|
||||
['start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([[['--es', 'key', '']], [['-e', 'key', '']], [['-d', '']]])(
|
||||
'passes empty Android option values as empty arguments (%j)',
|
||||
async launchArgs => {
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
...launchArgs.map(argument => (argument === '' ? "''" : argument)),
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('explains an Android launch failure when a launch argument is empty', async () => {
|
||||
const launchError = new Error('adb shell am start exited with non-zero code: 1');
|
||||
mockedSpawn.mockRejectedValueOnce(launchError);
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true', ''],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_LAUNCH_APPLICATION_FAILED',
|
||||
message: expect.stringContaining('empty argument at position 4'),
|
||||
cause: launchError,
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows an Android launch failure unchanged when no launch argument is empty', async () => {
|
||||
const launchError = new Error('adb shell am start exited with non-zero code: 1');
|
||||
mockedSpawn.mockRejectedValueOnce(launchError);
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--ez', 'isTest', 'true'],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toBe(launchError);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ launchArgs: ['--es', 'key', 'a\0b'] },
|
||||
{ launchArgs: [], openUrl: 'exp://example.test/a\0b' },
|
||||
])('rejects NUL characters on Android (%j)', async ({ launchArgs, openUrl }) => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs,
|
||||
openUrl,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'adb',
|
||||
expect.arrayContaining([expect.stringContaining('\0')]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an Android launch command of exactly 65,000 bytes', async () => {
|
||||
const fixedLength = 'am start --es key -n com.example.app/com.example.app.MainActivity'.length;
|
||||
|
||||
await launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--es', 'key', 'a'.repeat(65_000 - fixedLength)],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an Android URL command of 65,001 bytes', async () => {
|
||||
const fixedLength = 'am start -a android.intent.action.VIEW -d exp://example.test/'.length;
|
||||
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
openUrl: `exp://example.test/${'a'.repeat(65_001 - fixedLength)}`,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an Android launch command that is too long after quoting', async () => {
|
||||
await expect(
|
||||
launchApplicationAsync({
|
||||
applicationIdentifier: 'com.example.app',
|
||||
activityName: 'com.example.app.MainActivity',
|
||||
launchArgs: ['--es', 'quotes', "'".repeat(20_000)],
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger: createMockLogger(),
|
||||
})
|
||||
).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('launches using application metadata passed to the build step', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
@@ -495,36 +237,6 @@ describe(launchApplicationAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('quotes Android launch arguments passed to the build step', async () => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({ runtimePlatform: BuildRuntimePlatform.LINUX }),
|
||||
{
|
||||
callInputs: {
|
||||
application_identifier: 'com.example.app',
|
||||
activity_name: 'com.example.app.MainActivity',
|
||||
launch_args: ['--es', 'probe', 'hello world'],
|
||||
},
|
||||
}
|
||||
);
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'adb',
|
||||
[
|
||||
'shell',
|
||||
'am',
|
||||
'start',
|
||||
'--es',
|
||||
'probe',
|
||||
"'hello world'",
|
||||
'-n',
|
||||
'com.example.app/com.example.app.MainActivity',
|
||||
],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it.each([{}, ['valid', 42]])('rejects invalid launch arguments (%j)', async launchArgs => {
|
||||
const launchApplication = createLaunchApplicationFunction();
|
||||
const buildStep = launchApplication.createBuildStepFromFunctionCall(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
@@ -305,6 +305,7 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name:
|
||||
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
|
||||
paths: [
|
||||
@@ -1090,6 +1091,7 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: 'Maestro HTML Report',
|
||||
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
|
||||
}),
|
||||
@@ -1418,6 +1420,7 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: shot.displayName,
|
||||
metadata: shot.metadata,
|
||||
}),
|
||||
|
||||
@@ -162,7 +162,6 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
});
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
|
||||
openPreviewAsync: openPreview,
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopPreview,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
|
||||
+2
-19
@@ -76,8 +76,7 @@ async function runAsync(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
capture: CaptureInputs = NO_CAPTURE,
|
||||
disableShare = false,
|
||||
privatePreviewUrl?: string,
|
||||
maxIdleTimeMinutes?: number
|
||||
privatePreviewUrl?: string
|
||||
): Promise<void> {
|
||||
await runAgentDeviceRemoteSessionAsync(ctx, {
|
||||
env: {},
|
||||
@@ -89,7 +88,7 @@ async function runAsync(
|
||||
ngrokAuthtoken: 'ngrok-token',
|
||||
},
|
||||
packageVersion: undefined,
|
||||
maxIdleTimeMinutes,
|
||||
maxIdleTimeMinutes: undefined,
|
||||
maxDurationSeconds: undefined,
|
||||
capture,
|
||||
disableShare,
|
||||
@@ -134,7 +133,6 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
};
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementation(async (_ctx, options) => {
|
||||
@@ -204,21 +202,6 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
|
||||
expect(JSON.stringify(logger.info.mock.calls)).not.toContain(encodeURIComponent(token));
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(ctx, {
|
||||
application: Promise.resolve(),
|
||||
} as never);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX, NO_CAPTURE, false, undefined, 5);
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
it('hands network capture to serve-sim', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
|
||||
|
||||
|
||||
@@ -687,18 +687,6 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['NUL argument', { launch_args: ['a\u0000b'] }],
|
||||
['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }],
|
||||
['NUL URL', { open_url: 'example:\u0000screen' }],
|
||||
])('rejects Android launch %s before startup work', async (_name, inputs) => {
|
||||
const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs });
|
||||
await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' });
|
||||
expect(startAndroidEmulatorAsync).not.toHaveBeenCalled();
|
||||
expect(downloadBuildAsync).not.toHaveBeenCalled();
|
||||
expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps Android launch inputs on its existing direct path', async () => {
|
||||
const launchArgs = ['a'.repeat(9000)];
|
||||
await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs });
|
||||
|
||||
+7
-397
@@ -6,18 +6,14 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../../utils/appiumEvents';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { preparePreinstalledWdaAsync } from '../../utils/preinstalledWda';
|
||||
import {
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import {
|
||||
@@ -31,16 +27,11 @@ jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: {
|
||||
getAvailableDevicesAsync: jest.fn(),
|
||||
getDeviceAsync: jest.fn(),
|
||||
resolveUdidAsync: jest.fn().mockResolvedValue('chosen-ios-udid'),
|
||||
},
|
||||
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
@@ -57,15 +48,6 @@ jest.mock('../../../utils/turtleFetch', () => ({
|
||||
}));
|
||||
jest.mock('../../utils/appiumEvents', () => ({ startAppiumEventCollectionAsync: jest.fn() }));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/preinstalledWda', () => ({ preparePreinstalledWdaAsync: jest.fn() }));
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/localEgressSession', () => ({
|
||||
...jest.requireActual('../../utils/localEgressSession'),
|
||||
uploadRemoteSessionConfigWithLocalEgressAsync: jest.fn(),
|
||||
@@ -106,6 +88,7 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'iOS',
|
||||
@@ -114,6 +97,7 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
udid: 'ios-simulator-id',
|
||||
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledWith({ env: {}, logger });
|
||||
expect(IosSimulatorUtils.getAvailableDevicesAsync).toHaveBeenCalledWith({
|
||||
env: {},
|
||||
filter: 'booted',
|
||||
@@ -129,6 +113,7 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
resolveAppiumDeviceAsync({
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
env: {},
|
||||
logger,
|
||||
})
|
||||
).resolves.toEqual({
|
||||
platformName: 'Android',
|
||||
@@ -227,13 +212,6 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
const stopEventCollection = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({});
|
||||
jest.mocked(IosSimulatorUtils.getDeviceAsync).mockResolvedValue({
|
||||
udid: 'CHOSEN-IOS-UDID',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
state: 'Shutdown',
|
||||
} as never);
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => ({
|
||||
stdout: args.includes('--json') ? '{}' : '',
|
||||
})) as never);
|
||||
@@ -264,7 +242,6 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: jest.fn(),
|
||||
});
|
||||
});
|
||||
@@ -273,22 +250,16 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function runSessionAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock },
|
||||
maxIdleTimeMinutes?: number,
|
||||
runtimePlatform = BuildRuntimePlatform.LINUX,
|
||||
startupInputs: Record<string, { value: unknown }> = {}
|
||||
): Promise<void> {
|
||||
async function runSessionAsync(logger: { info: jest.Mock; warn: jest.Mock }): Promise<void> {
|
||||
await createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: maxIdleTimeMinutes },
|
||||
...startupInputs,
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
@@ -309,345 +280,6 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
);
|
||||
}
|
||||
|
||||
it.each([false, true])(
|
||||
'preserves preview settings with Simulator startup %s',
|
||||
async bootSimulator => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest
|
||||
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
|
||||
.mockResolvedValue([{ udid: 'chosen-ios-udid' } as never]);
|
||||
await runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
...(bootSimulator
|
||||
? { boot_simulator: { value: true }, device_identifier: { value: 'chosen-device' } }
|
||||
: {}),
|
||||
public_preview: { value: false },
|
||||
private_preview_url: { value: 'https://expo.dev/private-preview' },
|
||||
frame_ancestors: { value: ['https://preview.example.com'] },
|
||||
cors_origins: { value: ['https://api.example.com'] },
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
disableShare: true,
|
||||
privatePreviewUrl: 'https://expo.dev/private-preview',
|
||||
frameAncestors: ['https://preview.example.com'],
|
||||
corsOrigins: ['https://api.example.com'],
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('selects Xcode only once for an already-prepared iOS session', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockResolvedValue([
|
||||
{
|
||||
udid: 'chosen-ios-udid',
|
||||
runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4',
|
||||
} as never,
|
||||
]);
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
|
||||
});
|
||||
await runSessionAsync(
|
||||
{ info: jest.fn(), warn: jest.fn() },
|
||||
undefined,
|
||||
BuildRuntimePlatform.DARWIN
|
||||
);
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-4' })
|
||||
);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('starts serve-sim on the selected iOS device while Appium installation is pending', async () => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockClear();
|
||||
jest.mocked(IosSimulatorUtils.getAvailableDevicesAsync).mockClear();
|
||||
jest.mocked(preparePreinstalledWdaAsync).mockResolvedValue({
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
|
||||
});
|
||||
let releaseInstall!: (value: unknown) => void;
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return new Promise(resolve => {
|
||||
releaseInstall = resolve;
|
||||
}) as never;
|
||||
});
|
||||
const running = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext).fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
install_app_path: { value: '/tmp/App.app' },
|
||||
launch_args: { value: ['--literal'] },
|
||||
open_url: { value: 'example://home' },
|
||||
},
|
||||
outputs: {},
|
||||
env: {
|
||||
DEVICE_RUN_SESSION_ID: 'device-run-session-id',
|
||||
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'tunnel.example.com',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
},
|
||||
} as never
|
||||
);
|
||||
await installEntered;
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
iosSimulatorUdid: 'CHOSEN-IOS-UDID',
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--literal'],
|
||||
openUrl: 'example://home',
|
||||
})
|
||||
);
|
||||
expect(IosSimulatorUtils.getAvailableDevicesAsync).not.toHaveBeenCalled();
|
||||
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
|
||||
const readyHost = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
|
||||
expect(readyHost.finishAsync).not.toHaveBeenCalled();
|
||||
expect(jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].signal?.aborted).toBe(false);
|
||||
releaseInstall({ stdout: '' });
|
||||
await running;
|
||||
expect(readyHost.finishAsync).toHaveBeenCalledTimes(1);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).toHaveBeenCalledWith({
|
||||
udid: 'CHOSEN-IOS-UDID',
|
||||
env: expect.objectContaining({ DEVICE_RUN_SESSION_ID: 'device-run-session-id' }),
|
||||
});
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeIdentifier: 'com.apple.CoreSimulator.SimRuntime.iOS-26-5',
|
||||
appiumHome: jest.mocked(spawnDetached).mock.calls[0][0].env.APPIUM_HOME,
|
||||
})
|
||||
);
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
'appium:usePreinstalledWDA': true,
|
||||
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
|
||||
});
|
||||
expect(selectXcodeDeveloperDirectoryAsync).toHaveBeenCalledTimes(1);
|
||||
expect(stopAppium).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('drains a failed Simulator startup installer before removing its Appium home', async () => {
|
||||
const hostError = new Error('Simulator boot failed');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
let closeInstaller!: (error: Error) => void;
|
||||
const child = { pid: 12345, kill: jest.fn() };
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeInstaller = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never;
|
||||
});
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
const settled = jest.fn();
|
||||
const observed = running.then(settled, settled);
|
||||
try {
|
||||
await installEntered;
|
||||
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
const hostOptions = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
hostOptions.onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
await Promise.resolve();
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(home)).toBe(true);
|
||||
closeInstaller(new Error('installer interrupted'));
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(fs.existsSync(home)).toBe(false);
|
||||
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
closeInstaller?.(new Error('test cleanup'));
|
||||
await observed;
|
||||
}
|
||||
});
|
||||
|
||||
it('removes a completed Appium install when Simulator failure interrupts FFmpeg setup', async () => {
|
||||
const hostError = new Error('Simulator startup failed');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
jest
|
||||
.mocked(ensureFfmpegInstalledOnceAsync)
|
||||
.mockImplementationOnce(
|
||||
({ signal }) =>
|
||||
new Promise(resolve => signal?.addEventListener('abort', () => resolve(), { once: true }))
|
||||
);
|
||||
let installed!: () => void;
|
||||
const installationFinished = new Promise<void>(resolve => {
|
||||
installed = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args[0] === 'driver' && args[1] === 'install') {
|
||||
installed();
|
||||
}
|
||||
return { stdout: args.includes('--json') ? '{}' : '' };
|
||||
}) as never);
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
await installationFinished;
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
expect(fs.existsSync(home)).toBe(false);
|
||||
expect(preparePreinstalledWdaAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves an installer failure before a later host failure during home cleanup', async () => {
|
||||
const installError = new Error('npm install failed');
|
||||
const hostError = new Error('later Simulator failure');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
jest.mocked(spawn).mockRejectedValueOnce(installError);
|
||||
const remove = fs.promises.rm.bind(fs.promises);
|
||||
let enteredCleanup!: () => void;
|
||||
const cleanupEntered = new Promise<void>(resolve => {
|
||||
enteredCleanup = resolve;
|
||||
});
|
||||
let releaseCleanup!: () => void;
|
||||
const cleanupGate = new Promise<void>(resolve => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
jest.spyOn(fs.promises, 'rm').mockImplementationOnce(async (directory, options) => {
|
||||
enteredCleanup();
|
||||
await cleanupGate;
|
||||
await remove(directory, options);
|
||||
});
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
const observed = running.catch(() => {});
|
||||
try {
|
||||
await cleanupEntered;
|
||||
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(options.signal?.reason).toBe(installError);
|
||||
options.onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
releaseCleanup();
|
||||
await expect(running).rejects.toBe(installError);
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
failHost(hostError);
|
||||
releaseCleanup();
|
||||
await observed;
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['WDA download', 'Appium readiness'])(
|
||||
'keeps the Simulator error when it interrupts %s',
|
||||
async stage => {
|
||||
stopAppium.mockResolvedValue(undefined);
|
||||
const hostError = new Error('Simulator launch failed');
|
||||
const laterError = new Error(`${stage} interrupted`);
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let enteredStage!: () => void;
|
||||
const stageEntered = new Promise<void>(resolve => {
|
||||
enteredStage = resolve;
|
||||
});
|
||||
let rejectStage!: (error: Error) => void;
|
||||
let setupSignal: AbortSignal | undefined;
|
||||
const waitForStage = (signal?: AbortSignal): Promise<never> => {
|
||||
setupSignal = signal;
|
||||
enteredStage();
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectStage = reject;
|
||||
signal?.addEventListener('abort', () => reject(laterError), { once: true });
|
||||
});
|
||||
};
|
||||
if (stage === 'WDA download') {
|
||||
jest
|
||||
.mocked(preparePreinstalledWdaAsync)
|
||||
.mockImplementationOnce(({ signal }) => waitForStage(signal));
|
||||
} else {
|
||||
jest
|
||||
.mocked(turtleFetch)
|
||||
.mockImplementationOnce((_url, _method, options) =>
|
||||
waitForStage(options?.signal ?? undefined)
|
||||
);
|
||||
}
|
||||
const running = runSessionAsync(logger, undefined, BuildRuntimePlatform.DARWIN, {
|
||||
boot_simulator: { value: true },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
});
|
||||
const observed = running.catch(() => {});
|
||||
try {
|
||||
await stageEntered;
|
||||
jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1].onStartupError?.(hostError);
|
||||
failHost(hostError);
|
||||
await Promise.resolve();
|
||||
expect(setupSignal?.aborted).toBe(true);
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
const home = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
expect(fs.existsSync(home)).toBe(false);
|
||||
expect(startAppiumEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rejectStage?.(laterError);
|
||||
await observed;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the readiness error when Appium cannot be stopped', async () => {
|
||||
let now = Date.now();
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => now);
|
||||
@@ -675,32 +307,10 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as never,
|
||||
{} as never
|
||||
);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
|
||||
await expect(runSessionAsync({ info: jest.fn(), warn: jest.fn() }, 5)).rejects.toBe(stopError);
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
it('fails a finished session when Appium cannot be stopped', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await expect(runSessionAsync(logger)).rejects.toBe(stopError);
|
||||
expect(preparePreinstalledWdaAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runtimeIdentifier: undefined })
|
||||
);
|
||||
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
|
||||
'appium:eventTimings': true,
|
||||
});
|
||||
expectAppiumStoppedAndHomeRemoved(logger);
|
||||
expect(stopEventCollection.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
stopAppium.mock.invocationCallOrder[0]
|
||||
|
||||
+2
-328
@@ -7,9 +7,8 @@ import path from 'node:path';
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
|
||||
import { isProcessDescendantOfAsync, killProcessGroup } from '../../../utils/processes';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
import { startArgentEventCollectionAsync } from '../../utils/argentEvents';
|
||||
import { createProcessOutput } from '../../utils/processOutput';
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from '../startArgentRemoteSession';
|
||||
import { readIosApplicationIdentifierAsync } from '../../utils/iosAppArtifact';
|
||||
|
||||
// Redirect ~/.argent (where the tool-server writes its state file and event log) to a temp
|
||||
// home so waitForArgentToolServerStateAsync — which lives in the module under test and cannot
|
||||
@@ -40,32 +38,13 @@ jest.mock('node:os', () => {
|
||||
});
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../../utils/processes', () => ({
|
||||
isProcessDescendantOfAsync: jest.fn(),
|
||||
killProcessGroup: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../utils/processes', () => ({ isProcessDescendantOfAsync: jest.fn() }));
|
||||
jest.mock('../../utils/argentArtifacts', () => ({ pollArgentArtifactsForUploadAsync: jest.fn() }));
|
||||
jest.mock('../../utils/argentEvents', () => ({
|
||||
...jest.requireActual('../../utils/argentEvents'),
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/deviceSessionHost');
|
||||
jest.mock('../../utils/iosAppArtifact', () => ({
|
||||
...jest.requireActual('../../utils/iosAppArtifact'),
|
||||
readIosApplicationIdentifierAsync: jest.fn().mockResolvedValue('dev.example.app'),
|
||||
}));
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: {
|
||||
resolveUdidAsync: jest.fn().mockResolvedValue('selected-ios-udid'),
|
||||
getAvailableDevicesAsync: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ name: 'iPhone 17', udid: 'selected-ios-udid' }]),
|
||||
},
|
||||
}));
|
||||
jest.mock('../../utils/localEgressGuard', () => ({
|
||||
resolveLocalEgressServeSimBootEnvironmentAsync: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
@@ -125,7 +104,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
@@ -239,7 +217,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: mockPreviewStopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
@@ -530,35 +507,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resets the idle timer on preview input from the session host', async () => {
|
||||
const previewInputAt = new Date('2026-10-19T12:00:00Z');
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as never,
|
||||
{} as never
|
||||
);
|
||||
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(previewInputAt);
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
|
||||
await 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: 5 },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
|
||||
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
|
||||
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
|
||||
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(previewInputAt);
|
||||
});
|
||||
|
||||
it('redacts startup credentials and registers the state token for later output', async () => {
|
||||
const token = 'tool/server "secret"';
|
||||
await fs.promises.writeFile(
|
||||
@@ -644,280 +592,6 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('boots and prepares the downloaded app while Argent installation is pending, and closes the host if installation fails', async () => {
|
||||
let rejectInstall!: (error: Error) => void;
|
||||
let enteredInstall!: () => void;
|
||||
const installEntered = new Promise<void>(resolve => {
|
||||
enteredInstall = resolve;
|
||||
});
|
||||
jest.mocked(spawn).mockImplementationOnce(() => {
|
||||
enteredInstall();
|
||||
return new Promise((_resolve, reject) => {
|
||||
rejectInstall = reject;
|
||||
}) as never;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
device_identifier: { value: 'chosen-device' },
|
||||
install_app_path: { value: '/tmp/App.app' },
|
||||
launch_args: { value: ['--literal'] },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
await installEntered;
|
||||
expect(
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mock.invocationCallOrder[0]
|
||||
).toBeLessThan(jest.mocked(IosSimulatorUtils.resolveUdidAsync).mock.invocationCallOrder[0]);
|
||||
expect(readIosApplicationIdentifierAsync).toHaveBeenCalledWith({
|
||||
artifactPath: '/tmp/App.app',
|
||||
env: {},
|
||||
});
|
||||
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
iosSimulatorUdid: 'SELECTED-IOS-UDID',
|
||||
installAppPath: '/tmp/App.app',
|
||||
launchAppIdentifier: 'dev.example.app',
|
||||
launchArgs: ['--literal'],
|
||||
})
|
||||
);
|
||||
const error = new Error('Argent install failed');
|
||||
rejectInstall(error);
|
||||
await expect(running).rejects.toBe(error);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['artifacts-list-endpoint', 'tool-server-event-log', 'video-watermark'])(
|
||||
'stops and drains the pending %s command when the concurrent host fails',
|
||||
async flag => {
|
||||
const hostError = new Error('serve-sim startup failed');
|
||||
const commandError = new Error('Argent command exited after cancellation');
|
||||
let failHost!: (error: Error) => void;
|
||||
let finishHostCleanup!: () => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
|
||||
async (_ctx, options) =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
failHost = error => {
|
||||
options.onStartupError?.(error);
|
||||
finishHostCleanup = () => reject(error);
|
||||
};
|
||||
})
|
||||
);
|
||||
let failCommand!: (error: Error) => void;
|
||||
let commandStarted!: () => void;
|
||||
const commandEntered = new Promise<void>(resolve => {
|
||||
commandStarted = resolve;
|
||||
});
|
||||
const child = { pid: 4242, kill: jest.fn() };
|
||||
jest.mocked(spawn).mockImplementation((_command, args) => {
|
||||
if (!args.includes(flag)) {
|
||||
return Promise.resolve(undefined) as never;
|
||||
}
|
||||
const pending = new Promise((_resolve, reject) => {
|
||||
failCommand = reject;
|
||||
});
|
||||
commandStarted();
|
||||
return Object.assign(pending, { child }) as never;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
let settled = false;
|
||||
void running
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
await commandEntered;
|
||||
failHost(hostError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(killProcessGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).toBe(false);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
failCommand(commandError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
finishHostCleanup();
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
failCommand?.(commandError);
|
||||
finishHostCleanup?.();
|
||||
await running.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('interrupts tool-server state polling and retains the original host error', async () => {
|
||||
await fs.promises.rm(path.join(ARGENT_STATE_DIR, 'tool-server-orchestration.json'));
|
||||
const hostError = new Error('serve-sim startup failed');
|
||||
let failHost!: (error: Error) => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
failHost = reject;
|
||||
})
|
||||
);
|
||||
let pollingStarted!: () => void;
|
||||
const pollingEntered = new Promise<void>(resolve => {
|
||||
pollingStarted = resolve;
|
||||
});
|
||||
const getExitError = jest.fn<Error | undefined, []>(() => {
|
||||
pollingStarted();
|
||||
return undefined;
|
||||
});
|
||||
const stopServer = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValueOnce({
|
||||
pid: 4242,
|
||||
getOutput: () => 'later controller diagnostic',
|
||||
getExitError,
|
||||
stopAsync: stopServer,
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
void running.catch(() => {});
|
||||
try {
|
||||
await pollingEntered;
|
||||
failHost(hostError);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const options = jest.mocked(startDeviceSessionHostAsync).mock.calls[0][1];
|
||||
expect(options.signal?.aborted).toBe(true);
|
||||
getExitError.mockReturnValue(new Error('later Argent exit'));
|
||||
await expect(running).rejects.toBe(hostError);
|
||||
expect(stopServer).toHaveBeenCalledTimes(1);
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
getExitError.mockReturnValue(new Error('stop pending state poll'));
|
||||
await running.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Argent setup concurrent but waits for the iOS host before opening tools', async () => {
|
||||
const host = await jest.mocked(startDeviceSessionHostAsync).getMockImplementation()!(
|
||||
{} as CustomBuildContext,
|
||||
{} as never
|
||||
);
|
||||
let releaseHost!: () => void;
|
||||
jest.mocked(startDeviceSessionHostAsync).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
releaseHost = () => resolve(host);
|
||||
})
|
||||
);
|
||||
let stateReady!: () => void;
|
||||
const stateRead = new Promise<void>(resolve => {
|
||||
stateReady = resolve;
|
||||
});
|
||||
jest.mocked(isProcessDescendantOfAsync).mockImplementationOnce(async () => {
|
||||
stateReady();
|
||||
return true;
|
||||
});
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
const running = Promise.resolve(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
boot_simulator: { value: true },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
);
|
||||
try {
|
||||
await stateRead;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(spawnDetached).toHaveBeenCalledTimes(1);
|
||||
expect(startArgentEventCollectionAsync).not.toHaveBeenCalled();
|
||||
expect(startNgrokTunnelAsync).not.toHaveBeenCalled();
|
||||
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
releaseHost();
|
||||
await running;
|
||||
}
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
|
||||
'keeps %s prepared setup commands outside the new cancellation path',
|
||||
async runtimePlatform => {
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger: { info: jest.fn(), warn: jest.fn() },
|
||||
global: { runtimePlatform },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledTimes(3);
|
||||
for (const [, , options] of jest.mocked(spawn).mock.calls) {
|
||||
expect(options).not.toHaveProperty('detached');
|
||||
expect(options).not.toHaveProperty('signal');
|
||||
}
|
||||
expect(killProcessGroup).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { Client, fetchExchange } from '@urql/core';
|
||||
|
||||
import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
@@ -6,90 +6,6 @@ import * as sandboxDaemon from '../../utils/sandboxDaemon';
|
||||
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
|
||||
|
||||
describe('sandbox build functions', () => {
|
||||
it.each([
|
||||
{ completion: 'duration', duration: 0.02 },
|
||||
{ completion: 'cancellation', duration: 60 },
|
||||
{ completion: 'duration', duration: 0.0001 },
|
||||
])(
|
||||
'stops after $completion with $duration seconds only after readiness',
|
||||
async ({ completion, duration }) => {
|
||||
const controller = new AbortController();
|
||||
let ready!: () => void;
|
||||
const stopAsync = jest.fn(async () => {});
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
|
||||
ready: new Promise<void>(resolve => {
|
||||
ready = resolve;
|
||||
}),
|
||||
stopAsync,
|
||||
});
|
||||
const mutation = jest.fn(() => ({ toPromise: async () => ({ data: {} }) }));
|
||||
const logger = { info: jest.fn(), child: jest.fn() };
|
||||
logger.child.mockReturnValue(logger);
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
graphqlClient: { mutation },
|
||||
} as any);
|
||||
try {
|
||||
const pending = fn.fn!({ logger } as any, {
|
||||
inputs: {
|
||||
sandbox_id: { value: 'sandbox-id' },
|
||||
max_duration_seconds: { value: duration },
|
||||
},
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 40));
|
||||
expect(mutation).not.toHaveBeenCalled();
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
ready();
|
||||
if (completion === 'cancellation') {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(stopAsync).not.toHaveBeenCalled();
|
||||
controller.abort();
|
||||
}
|
||||
await pending;
|
||||
expect(mutation).toHaveBeenCalledTimes(1);
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
|
||||
if (completion === 'duration') {
|
||||
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
|
||||
} else {
|
||||
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
|
||||
}
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each([0, -1, Infinity, NaN])(
|
||||
'rejects invalid maximum duration %s before starting the daemon',
|
||||
async duration => {
|
||||
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
|
||||
const fn = createStartSandboxBuildFunction({
|
||||
mcpServerUrl: 'ws://localhost:8787',
|
||||
} as any);
|
||||
try {
|
||||
const result = fn.fn!({} as any, {
|
||||
inputs: {
|
||||
sandbox_id: { value: 'sandbox-id' },
|
||||
max_duration_seconds: { value: duration },
|
||||
},
|
||||
outputs: {},
|
||||
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
|
||||
});
|
||||
await expect(result).rejects.toBeInstanceOf(UserError);
|
||||
await expect(result).rejects.toThrow(
|
||||
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
start.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('aborts an active ready request and keeps its authentication headers', async () => {
|
||||
const controller = new AbortController();
|
||||
let requestStarted!: () => void;
|
||||
|
||||
-5
@@ -81,7 +81,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
@@ -98,7 +97,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
previewToken: 'tok-1',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
@@ -126,7 +124,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
|
||||
@@ -154,7 +151,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
closeAsync: jest.fn(),
|
||||
}),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, { public_preview: { value: false } });
|
||||
@@ -172,7 +168,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
if (phase === 'preview') {
|
||||
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
|
||||
openPreviewAsync: jest.fn().mockRejectedValue(error),
|
||||
getLastPreviewActivityAt: jest.fn(),
|
||||
finishAsync: stopAsync,
|
||||
});
|
||||
} else if (phase === 'config') {
|
||||
|
||||
@@ -121,32 +121,26 @@ describe(createUploadArtifactBuildFunction, () => {
|
||||
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['other', 'android-apk', 'android-aab', 'ios-simulator-app', 'ios-ipa'])(
|
||||
'uploads %s as a workflow artifact with metadata',
|
||||
async type => {
|
||||
contextUploadArtifact.mockClear();
|
||||
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
|
||||
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({}),
|
||||
{
|
||||
callInputs: {
|
||||
type,
|
||||
key: 'Screen Recording',
|
||||
path: '/',
|
||||
metadata,
|
||||
},
|
||||
}
|
||||
);
|
||||
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
|
||||
contextUploadArtifact.mockClear();
|
||||
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
|
||||
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: {
|
||||
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
|
||||
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
|
||||
type: 'other',
|
||||
key: 'Screen Recording',
|
||||
path: '/',
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(contextUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({ type: null, metadata }),
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
expect(contextUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
|
||||
contextUploadArtifact.mockClear();
|
||||
|
||||
+2
-11
@@ -140,12 +140,9 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const uploads = jest
|
||||
const names = jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, options]) => options);
|
||||
const names = uploads
|
||||
.filter(options => options.kind === 'screen-recording')
|
||||
.map(options => options.name);
|
||||
.mock.calls.map(([, options]) => options.name);
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining([
|
||||
'iPhone 16 screen recording (01234567-…, started at Jul 10, 2026, 10:00:00.000 UTC)',
|
||||
@@ -154,12 +151,6 @@ describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
|
||||
])
|
||||
);
|
||||
expect(new Set(names).size).toBe(3);
|
||||
const metadata = uploads.filter(options => options.kind === 'screen-recording-metadata');
|
||||
expect(metadata.map(options => options.name)).toEqual(
|
||||
expect.arrayContaining(names.map(name => `${name} metadata`))
|
||||
);
|
||||
expect(metadata).toHaveLength(3);
|
||||
expect(uploads).toHaveLength(6);
|
||||
} finally {
|
||||
await Promise.all(
|
||||
recordingDirectories.map(directory => fs.rm(directory, { recursive: true, force: true }))
|
||||
|
||||
@@ -58,11 +58,7 @@ export function createCheckoutBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
const previousWorkingDirectory = stepsCtx.global.defaultWorkingDirectory;
|
||||
stepsCtx.global.markAsCheckedOut();
|
||||
stepsCtx.logger.info(
|
||||
`Changing default working directory to ${stepsCtx.global.defaultWorkingDirectory} (was ${previousWorkingDirectory})`
|
||||
);
|
||||
stepsCtx.global.markAsCheckedOut(stepsCtx.logger);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { Client, CombinedError } from '@urql/core';
|
||||
import { Client } from '@urql/core';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import { glob } from 'fast-glob';
|
||||
import { graphql } from 'gql.tada';
|
||||
@@ -26,16 +26,10 @@ import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { graphqlAbortContext } from '../../utils/graphqlAbort';
|
||||
import {
|
||||
describeError,
|
||||
isTransferFailure,
|
||||
isTransientGraphqlError,
|
||||
} from '../../utils/networkErrors';
|
||||
import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
import { pluralize } from '../../utils/strings';
|
||||
import {
|
||||
type DeviceRunSessionWorkerApi,
|
||||
DeviceRunSessionWorkerApiError,
|
||||
getDeviceRunSessionWorkerApi,
|
||||
} from '../utils/deviceRunSessionWorkerApi';
|
||||
|
||||
@@ -158,34 +152,21 @@ async function fetchApplicationArchiveUrlAsync({
|
||||
signal,
|
||||
});
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await retryOnNetworkFailureAsync(
|
||||
async () => {
|
||||
const queryResult = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
if (queryResult.error) {
|
||||
throw queryResult.error;
|
||||
}
|
||||
return queryResult.data;
|
||||
},
|
||||
{
|
||||
operation: 'download_build.build_lookup',
|
||||
signal,
|
||||
isRetryable: error => error instanceof CombinedError && isTransientGraphqlError(error),
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
const message = `Could not fetch build ${buildId}: ${describeError(error)}`;
|
||||
throw error instanceof CombinedError && !isTransientGraphqlError(error)
|
||||
? new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error })
|
||||
: new SystemError(message, { cause: error });
|
||||
}
|
||||
const result = await graphqlClient
|
||||
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
|
||||
.toPromise();
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const applicationArchiveUrl = data?.builds.byId?.artifacts?.applicationArchiveUrl;
|
||||
if (result.error) {
|
||||
const { error } = result;
|
||||
const message = `Could not fetch build ${buildId}: ${error.message}`;
|
||||
|
||||
throw error.networkError || result.error.response?.status >= 500
|
||||
? new SystemError(message, { cause: error })
|
||||
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
|
||||
}
|
||||
|
||||
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
|
||||
if (!applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
|
||||
@@ -214,18 +195,13 @@ async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
|
||||
}): Promise<string> {
|
||||
let application;
|
||||
try {
|
||||
application = await retryOnNetworkFailureAsync(
|
||||
() => deviceRunSessionWorkerApi.getApplicationAsync({ signal }),
|
||||
{
|
||||
operation: 'download_build.session_build_lookup',
|
||||
signal,
|
||||
isRetryable: error => error instanceof DeviceRunSessionWorkerApiError && error.isTransient,
|
||||
}
|
||||
);
|
||||
application = await deviceRunSessionWorkerApi.getApplicationAsync({ signal });
|
||||
} catch (err) {
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Could not fetch build ${buildId} for this device run session: ${describeError(err)}`,
|
||||
`Could not fetch build ${buildId} for this device run session: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
@@ -305,34 +281,18 @@ export async function downloadBuildAsync(
|
||||
);
|
||||
|
||||
signal?.throwIfAborted();
|
||||
let archivePath: string;
|
||||
try {
|
||||
archivePath = await retryOnNetworkFailureAsync(
|
||||
async () => {
|
||||
const response = await fetch(downloadUrl, { headers, signal });
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
throw new Error(
|
||||
`Unexpected response from server (${response.status}): ${textResult.value}`
|
||||
);
|
||||
}
|
||||
const archiveFilename = resolveArchiveFilename({ response, extensions });
|
||||
const attemptArchivePath = 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(attemptArchivePath));
|
||||
return attemptArchivePath;
|
||||
},
|
||||
{ operation: 'download_build.archive_download', signal, isRetryable: isTransferFailure }
|
||||
);
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted();
|
||||
if (params.buildId && isTransferFailure(error)) {
|
||||
throw new SystemError(`Could not download the application archive: ${describeError(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError, quoteAndroidShellCommand } from '@expo/eas-build-job';
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
@@ -106,40 +106,24 @@ export async function launchApplicationAsync({
|
||||
logApplicationLaunch(logger, applicationIdentifier, launchArgs);
|
||||
// Android does not support process arguments like iOS. Pass raw `am start` Intent
|
||||
// arguments instead, such as `--es key value` or `--ez key true`.
|
||||
try {
|
||||
await spawnAndroidShellCommandAsync(
|
||||
['am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
|
||||
{ env, logger }
|
||||
);
|
||||
} catch (err) {
|
||||
// An empty argument that is not an option value reaches `am start` as an empty package, and
|
||||
// `am start` then fails to resolve the Intent without saying why.
|
||||
const emptyArgumentIndex = launchArgs.indexOf('');
|
||||
if (emptyArgumentIndex === -1 || err instanceof UserError) {
|
||||
throw err;
|
||||
await spawn(
|
||||
'adb',
|
||||
['shell', 'am', 'start', ...launchArgs, '-n', `${applicationIdentifier}/${activityName}`],
|
||||
{
|
||||
env,
|
||||
logger,
|
||||
}
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_FAILED',
|
||||
`Launching ${applicationIdentifier} failed. Input "launch_args" has an empty argument at position ${emptyArgumentIndex + 1}, which Android passes to "am start" as an empty argument. Remove it unless it is an option value, such as "--es", "key", "".`,
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
);
|
||||
if (openUrl) {
|
||||
logger.info(`Opening ${openUrl} in ${applicationIdentifier}.`);
|
||||
await spawnAndroidShellCommandAsync(
|
||||
['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
await spawn(
|
||||
'adb',
|
||||
['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl],
|
||||
{ env, logger }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function spawnAndroidShellCommandAsync(
|
||||
command: string[],
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
): Promise<void> {
|
||||
await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger });
|
||||
}
|
||||
|
||||
async function preapproveIosUrlSchemeAsync({
|
||||
applicationIdentifier,
|
||||
openUrl,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
@@ -631,7 +631,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
try {
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
artifact: {
|
||||
type: null,
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: selectedReport.name,
|
||||
paths: [selectedReport.artifactPath],
|
||||
},
|
||||
@@ -738,7 +738,7 @@ async function uploadFailureScreenshotsAsync({
|
||||
await fs.copyFile(shot.fileAbsPath, safePath);
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
artifact: {
|
||||
type: null,
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: shot.displayName,
|
||||
paths: [safePath],
|
||||
metadata: shot.metadata,
|
||||
|
||||
@@ -265,7 +265,6 @@ export async function runAgentDeviceRemoteSessionAsync(
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job';
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -164,8 +164,6 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
|
||||
}
|
||||
if (isIos) {
|
||||
validateServeSimLaunchOptions({ launchArgs, openUrl });
|
||||
} else {
|
||||
validateAndroidLaunchOptions({ launchArgs, openUrl });
|
||||
}
|
||||
const deviceIdentifier = inputs.device_identifier.value as string | undefined;
|
||||
const capture = parseNetworkCaptureInputs(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Result, asyncResult } from '@expo/results';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -8,11 +8,10 @@ import {
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn, { type SpawnOptions, type SpawnResult } from '@expo/turtle-spawn';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -22,19 +21,17 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
} from '../utils/networkCaptureFields';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageAdd,
|
||||
} from '../../utils/packageManager';
|
||||
import { killProcessGroup } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
@@ -42,7 +39,6 @@ import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
@@ -55,6 +51,7 @@ import {
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -81,7 +78,6 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
@@ -106,21 +102,14 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
@@ -133,125 +122,54 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
logger.info(
|
||||
`Starting Appium remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
const device: AppiumDevice = iosStartup
|
||||
? {
|
||||
platformName: 'iOS',
|
||||
automationName: 'XCUITest',
|
||||
driverName: 'xcuitest',
|
||||
udid: iosStartup.iosSimulatorUdid,
|
||||
}
|
||||
: await resolveAppiumDeviceAsync({ runtimePlatform, env });
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = signal
|
||||
? AbortSignal.any([signal, startupAbortController.signal])
|
||||
: startupAbortController.signal;
|
||||
const setupSignal = iosStartup ? startupSignal : undefined;
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let appiumProcess: ReturnType<typeof spawnDetached> | undefined;
|
||||
let appiumHome: string | undefined;
|
||||
const device = await resolveAppiumDeviceAsync({ runtimePlatform, env, logger });
|
||||
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
|
||||
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
|
||||
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
|
||||
// Both installs settle before the step continues or fails, so no install outlives it.
|
||||
const [appiumInstall] = await Promise.all([
|
||||
asyncResult(
|
||||
installAppiumAsync({ versionSpec, driverName: device.driverName, env, logger })
|
||||
),
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger }),
|
||||
]);
|
||||
const { appiumHome, appiumBinPath, appiumEnv } = appiumInstall.enforceValue();
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
const appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
args: [
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
let eventCollection: Awaited<ReturnType<typeof startAppiumEventCollectionAsync>> | undefined;
|
||||
let appiumTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
// Appium's startRecordingScreen runs ffmpeg on this host: XCUITest encodes the
|
||||
// simulator stream with it, and UiAutomator2 merges long recordings with it. The
|
||||
// macOS session image does not ship ffmpeg, so install it concurrently with Appium.
|
||||
// Both owned installs settle before proceeding; a shared FFmpeg install keeps its owner.
|
||||
const [appiumInstall] = await Promise.all([
|
||||
asyncResult(
|
||||
installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName: device.driverName,
|
||||
env,
|
||||
logger,
|
||||
signal: setupSignal,
|
||||
onError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
})
|
||||
),
|
||||
asyncResult(
|
||||
ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal: setupSignal })
|
||||
),
|
||||
]);
|
||||
const installation = appiumInstall.enforceValue();
|
||||
appiumHome = installation.appiumHome;
|
||||
setupSignal?.throwIfAborted();
|
||||
const { appiumBinPath, appiumEnv } = installation;
|
||||
const wdaCapabilities = await preparePreinstalledWdaAsync({
|
||||
runtimeIdentifier: iosStartup
|
||||
? (
|
||||
await IosSimulatorUtils.getDeviceAsync({
|
||||
udid: iosStartup.iosSimulatorUdid as IosSimulatorUuid,
|
||||
env,
|
||||
})
|
||||
)?.runtime
|
||||
: device.runtimeIdentifier,
|
||||
appiumHome,
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
signal: setupSignal,
|
||||
});
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
appiumProcess = spawnDetached({
|
||||
command: appiumBinPath,
|
||||
args: [
|
||||
'--address',
|
||||
APPIUM_HOST,
|
||||
'--port',
|
||||
String(APPIUM_PORT),
|
||||
'--base-path',
|
||||
'/',
|
||||
'--log-level',
|
||||
'error',
|
||||
// Appium 3 gates session listing (GET /appium/sessions) behind the
|
||||
// session_discovery insecure feature. We rely on it to poll for
|
||||
// Appium Event Timings, so enable it for all drivers.
|
||||
'--allow-insecure',
|
||||
'*:session_discovery',
|
||||
'--default-capabilities',
|
||||
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
|
||||
],
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger, signal: setupSignal });
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
setupSignal?.throwIfAborted();
|
||||
await waitForAppiumReadyAsync({ appiumProcess, logger });
|
||||
eventCollection = await startAppiumEventCollectionAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -272,7 +190,22 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
@@ -307,15 +240,13 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw setupSignal?.aborted ? setupSignal.reason : error;
|
||||
throw error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Appium session ended.'));
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
@@ -328,16 +259,14 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
await eventCollection?.stopAsync();
|
||||
} finally {
|
||||
try {
|
||||
await appiumProcess?.stopAsync();
|
||||
await appiumProcess.stopAsync();
|
||||
} finally {
|
||||
if (appiumHome) {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', hostStartup?.then(result => result.value?.finishAsync())],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -367,12 +296,15 @@ export type AppiumDevice = {
|
||||
export async function resolveAppiumDeviceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<AppiumDevice> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN: {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
const [bootedDevice] = await IosSimulatorUtils.getAvailableDevicesAsync({
|
||||
env,
|
||||
filter: 'booted',
|
||||
@@ -409,135 +341,65 @@ export async function installAppiumAsync({
|
||||
driverName,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
onError,
|
||||
}: {
|
||||
versionSpec: string;
|
||||
driverName: AppiumDevice['driverName'];
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
onError?: (error: unknown) => void;
|
||||
}): Promise<{ appiumHome: string; appiumBinPath: string; appiumEnv: BuildStepEnv }> {
|
||||
signal?.throwIfAborted();
|
||||
const appiumHome = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-appium-home-'));
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
await fs.promises.writeFile(
|
||||
path.join(appiumHome, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
const add = resolvePackageAdd(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
`appium@${versionSpec}`
|
||||
);
|
||||
await fs.promises.writeFile(
|
||||
path.join(appiumHome, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-appium-home', private: true })}\n`
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
const add = resolvePackageAdd(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
`appium@${versionSpec}`
|
||||
);
|
||||
|
||||
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
add.command,
|
||||
add.args,
|
||||
{
|
||||
cwd: appiumHome,
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
},
|
||||
signal
|
||||
);
|
||||
const { stdout } = await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'list', '--installed', '--json'],
|
||||
{
|
||||
env: appiumEnv,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
signal
|
||||
);
|
||||
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
|
||||
if (installedDrivers[driverName]?.installed) {
|
||||
logger.info(`Updating the installed Appium ${driverName} driver.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'update', driverName],
|
||||
{ env: appiumEnv, logger },
|
||||
signal
|
||||
);
|
||||
} else {
|
||||
logger.info(`Installing the Appium ${driverName} driver.`);
|
||||
await runAppiumSetupCommandAsync(
|
||||
appiumBinPath,
|
||||
['driver', 'install', driverName],
|
||||
{ env: appiumEnv, logger },
|
||||
signal
|
||||
);
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
if (signal) {
|
||||
await fs.promises.rm(appiumHome, { recursive: true, force: true });
|
||||
signal.throwIfAborted();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAppiumSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<SpawnResult> {
|
||||
if (!signal) {
|
||||
return await spawn(command, args, options);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const task = spawn(command, args, { ...options, detached: true });
|
||||
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
const result = await task;
|
||||
signal.throwIfAborted();
|
||||
return result;
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, {
|
||||
cwd: appiumHome,
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
const { stdout } = await spawn(appiumBinPath, ['driver', 'list', '--installed', '--json'], {
|
||||
env: appiumEnv,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const installedDrivers = AppiumInstalledDriversSchema.parse(JSON.parse(stdout));
|
||||
if (installedDrivers[driverName]?.installed) {
|
||||
logger.info(`Updating the installed Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'update', driverName], { env: appiumEnv, logger });
|
||||
} else {
|
||||
logger.info(`Installing the Appium ${driverName} driver.`);
|
||||
await spawn(appiumBinPath, ['driver', 'install', driverName], { env: appiumEnv, logger });
|
||||
}
|
||||
return { appiumHome, appiumBinPath, appiumEnv };
|
||||
}
|
||||
|
||||
async function waitForAppiumReadyAsync({
|
||||
appiumProcess,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
appiumProcess: { getOutput: () => string };
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + APPIUM_STARTUP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
const response = await turtleFetch(`http://${APPIUM_HOST}:${APPIUM_PORT}/status`, 'GET', {
|
||||
timeout: 2_000,
|
||||
retries: 0,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
signal?.throwIfAborted();
|
||||
if (signal) {
|
||||
await setTimeoutAsync(1_000, undefined, { signal });
|
||||
} else {
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
const output = appiumProcess.getOutput();
|
||||
throw new SystemError(
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { type Result, asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -21,7 +19,6 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
|
||||
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
|
||||
import {
|
||||
createNetworkCaptureInputProviders,
|
||||
parseNetworkCaptureInputs,
|
||||
@@ -32,14 +29,13 @@ import {
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { isProcessDescendantOfAsync, killProcessGroup } from '../../utils/processes';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
createServeSimLaunchInputProviders,
|
||||
@@ -52,6 +48,7 @@ import {
|
||||
omitNgrokAuthtoken,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -93,7 +90,6 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
...createIosSessionStartupInputProviders(),
|
||||
...createNetworkCaptureInputProviders(),
|
||||
createPublicPreviewInputProvider(),
|
||||
createPrivatePreviewUrlInputProvider(),
|
||||
@@ -132,21 +128,14 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
|
||||
runtimePlatform,
|
||||
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
|
||||
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
|
||||
installAppPath: inputs.install_app_path?.value as string | undefined,
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
});
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
|
||||
{
|
||||
networkCapture: inputs.network_capture?.value,
|
||||
@@ -158,6 +147,10 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
// Start the potentially slow installation while Argent is being prepared.
|
||||
// On Linux expo-device-hub calls this again and awaits the same in-flight
|
||||
// setup before launching. On macOS this remains non-blocking, so only a
|
||||
@@ -169,124 +162,74 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const argentExec = (args: string[]): { command: string; args: string[] } =>
|
||||
resolvePackageExec(packageManager, args);
|
||||
|
||||
const startupAbortController = new AbortController();
|
||||
const startupSignal = signal
|
||||
? AbortSignal.any([signal, startupAbortController.signal])
|
||||
: startupAbortController.signal;
|
||||
const setupSignal = iosStartup ? startupSignal : undefined;
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
|
||||
|
||||
logger.info('Disabling the Argent video watermark flag.');
|
||||
const disableVideoWatermark = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'disable',
|
||||
ARGENT_VIDEO_WATERMARK_FLAG,
|
||||
]);
|
||||
// The watermark is cosmetic, so a failure here must not fail the session.
|
||||
try {
|
||||
await spawn(disableVideoWatermark.command, disableVideoWatermark.args, { env, logger });
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const secrets: string[] = [];
|
||||
const argentServer = spawnDetached({
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
'Failed to start Argent: could not determine the PID of the launched process.'
|
||||
);
|
||||
}
|
||||
|
||||
const artifactPollAbortController = new AbortController();
|
||||
let artifactPollingPromise: Promise<void> | undefined;
|
||||
let eventCollection: Awaited<ReturnType<typeof startArgentEventCollectionAsync>> | undefined;
|
||||
let toolsTunnel: Awaited<ReturnType<typeof startNgrokTunnelAsync>> | undefined;
|
||||
let argentServer: ReturnType<typeof spawnDetached> | undefined;
|
||||
let hostStartup: Promise<Result<DeviceSessionHost>> | undefined;
|
||||
let sessionHost: DeviceSessionHost | undefined;
|
||||
let sessionFailed = false;
|
||||
const startHost = () =>
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
...iosStartup,
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
signal: startupSignal,
|
||||
onStartupError: iosStartup ? error => startupAbortController.abort(error) : undefined,
|
||||
...launch,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
}).catch(error => {
|
||||
if (iosStartup) {
|
||||
startupAbortController.abort(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
if (iosStartup) {
|
||||
hostStartup = asyncResult(startHost());
|
||||
}
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableArtifacts.command,
|
||||
enableArtifacts.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await runArgentSetupCommandAsync(
|
||||
enableEventLog.command,
|
||||
enableEventLog.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
|
||||
logger.info('Disabling the Argent video watermark flag.');
|
||||
const disableVideoWatermark = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'disable',
|
||||
ARGENT_VIDEO_WATERMARK_FLAG,
|
||||
]);
|
||||
// The watermark is cosmetic, so a failure here must not fail the session.
|
||||
try {
|
||||
await runArgentSetupCommandAsync(
|
||||
disableVideoWatermark.command,
|
||||
disableVideoWatermark.args,
|
||||
{ env, logger },
|
||||
setupSignal
|
||||
);
|
||||
} catch (err) {
|
||||
setupSignal?.throwIfAborted();
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not disable the Argent video watermark, so recorded videos may include it. Continuing the session.'
|
||||
);
|
||||
}
|
||||
|
||||
setupSignal?.throwIfAborted();
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const secrets: string[] = [];
|
||||
argentServer = spawnDetached({
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
|
||||
logger,
|
||||
secrets,
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
'Failed to start Argent: could not determine the PID of the launched process.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(`Waiting for argent tool-server state in ${ARGENT_STATE_DIR}.`);
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
@@ -296,7 +239,6 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
ancestorPid: argentServer.pid,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
getExitError: argentServer.getExitError,
|
||||
signal: setupSignal,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
@@ -311,10 +253,6 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
if (hostStartup) {
|
||||
sessionHost = (await hostStartup).enforceValue();
|
||||
}
|
||||
setupSignal?.throwIfAborted();
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
const artifactPollSignal = signal
|
||||
? AbortSignal.any([signal, artifactPollAbortController.signal])
|
||||
@@ -349,7 +287,22 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
sessionHost ??= (await (hostStartup ??= asyncResult(startHost()))).enforceValue();
|
||||
sessionHost = await startDeviceSessionHostAsync(ctx, {
|
||||
separateLogPhase: true,
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
disableShare: inputs.public_preview?.value === false,
|
||||
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
|
||||
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
|
||||
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
});
|
||||
const webPreview = await sessionHost.openPreviewAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
@@ -386,15 +339,13 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
? {
|
||||
maxIdleTimeMinutes,
|
||||
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
||||
getLastPreviewActivityAt: () => sessionHost?.getLastPreviewActivityAt(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
sessionFailed = true;
|
||||
throw setupSignal?.aborted ? setupSignal.reason : error;
|
||||
throw error;
|
||||
} finally {
|
||||
startupAbortController.abort(new Error('Argent session ended.'));
|
||||
await finishRemoteSessionAsync({
|
||||
logger,
|
||||
sessionFailed,
|
||||
@@ -426,11 +377,11 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await argentServer?.stopAsync();
|
||||
await argentServer.stopAsync();
|
||||
}
|
||||
})(),
|
||||
],
|
||||
['session host', hostStartup?.then(result => result.value?.finishAsync())],
|
||||
['session host', sessionHost?.finishAsync()],
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -490,31 +441,6 @@ export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
}
|
||||
}
|
||||
|
||||
async function runArgentSetupCommandAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
if (!signal) {
|
||||
await spawn(command, args, options);
|
||||
return;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const task = spawn(command, args, { ...options, detached: true });
|
||||
const abort = (): void => killProcessGroup(task.child, 'SIGKILL');
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
await task;
|
||||
signal.throwIfAborted();
|
||||
} finally {
|
||||
signal.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgentToolServerState(raw: string): ArgentToolServerState {
|
||||
let json: unknown;
|
||||
try {
|
||||
@@ -537,16 +463,13 @@ export async function waitForArgentToolServerStateAsync({
|
||||
timeoutMs,
|
||||
pollIntervalMs = 1_000,
|
||||
getExitError,
|
||||
signal,
|
||||
}: {
|
||||
stateDir: string;
|
||||
ancestorPid: number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs?: number;
|
||||
getExitError?: () => Error | undefined;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<ArgentToolServerState> {
|
||||
signal?.throwIfAborted();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
const throwIfExited = (): void => {
|
||||
@@ -557,7 +480,6 @@ export async function waitForArgentToolServerStateAsync({
|
||||
};
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
signal?.throwIfAborted();
|
||||
throwIfExited();
|
||||
let matchingState: ArgentToolServerState | undefined;
|
||||
try {
|
||||
@@ -583,19 +505,13 @@ export async function waitForArgentToolServerStateAsync({
|
||||
lastError = err;
|
||||
}
|
||||
// Keep process failures outside the retryable state-file read errors.
|
||||
signal?.throwIfAborted();
|
||||
throwIfExited();
|
||||
if (matchingState) {
|
||||
return matchingState;
|
||||
}
|
||||
if (signal) {
|
||||
await setTimeoutAsync(pollIntervalMs, undefined, { signal });
|
||||
} else {
|
||||
await sleepAsync(pollIntervalMs);
|
||||
}
|
||||
await sleepAsync(pollIntervalMs);
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
throw new SystemError(
|
||||
`Timed out waiting for an argent tool-server state file belonging to process ${ancestorPid} in ${stateDir}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import { graphql } from 'gql.tada';
|
||||
|
||||
@@ -32,11 +32,6 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
required: true,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'max_duration_seconds',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, signal, env }) => {
|
||||
// The daemon needs this credential, but shell commands do not.
|
||||
@@ -49,18 +44,6 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
|
||||
}
|
||||
const sandboxId = String(inputs.sandbox_id.value);
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value;
|
||||
if (
|
||||
maxDurationSeconds !== undefined &&
|
||||
(typeof maxDurationSeconds !== 'number' ||
|
||||
!Number.isFinite(maxDurationSeconds) ||
|
||||
maxDurationSeconds <= 0)
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_SANDBOX_INVALID_DURATION',
|
||||
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
|
||||
);
|
||||
}
|
||||
const daemon = await startSandboxDaemonAsync({
|
||||
credential: sandboxToken,
|
||||
serverUrl: mcpServerUrl,
|
||||
@@ -80,21 +63,7 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
stepCtx.logger.info('Sandbox daemon started.');
|
||||
|
||||
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
|
||||
const timeoutSignal =
|
||||
maxDurationSeconds === undefined
|
||||
? undefined
|
||||
: AbortSignal.timeout(Math.ceil(maxDurationSeconds * 1000));
|
||||
await waitForCompletionAsync(
|
||||
AbortSignal.any([
|
||||
...(signal ? [signal] : []),
|
||||
...(timeoutSignal ? [timeoutSignal] : []),
|
||||
])
|
||||
);
|
||||
if (timeoutSignal?.aborted) {
|
||||
stepCtx.logger.info(
|
||||
`Sandbox reached its maximum runtime of ${maxDurationSeconds} seconds. Stopping.`
|
||||
);
|
||||
}
|
||||
await waitForCancellationAsync(signal);
|
||||
});
|
||||
} finally {
|
||||
await daemon.stopAsync();
|
||||
@@ -120,11 +89,11 @@ export async function markSandboxReadyAsync(
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForCompletionAsync(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
signal.addEventListener('abort', () => resolve(), { once: true });
|
||||
signal?.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Job, ManagedArtifactType } from '@expo/eas-build-job';
|
||||
import {
|
||||
GenericArtifactType,
|
||||
Job,
|
||||
ManagedArtifactType,
|
||||
isGenericArtifact,
|
||||
} from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
@@ -28,12 +33,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
ManagedArtifactType.APPLICATION_ARCHIVE,
|
||||
ManagedArtifactType.BUILD_ARTIFACTS,
|
||||
...Object.keys(artifactTypeInputToManagedArtifactType),
|
||||
// Keep accepting these legacy artifact types for compatibility.
|
||||
'android-apk',
|
||||
'android-aab',
|
||||
'ios-simulator-app',
|
||||
'ios-ipa',
|
||||
'other',
|
||||
...Object.values(GenericArtifactType),
|
||||
],
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
@@ -126,13 +126,12 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
paths: artifactPaths,
|
||||
name: (inputs.name.value || inputs.key.value) as string,
|
||||
};
|
||||
const artifact =
|
||||
artifactSpec.type === null
|
||||
? {
|
||||
...artifactSpec,
|
||||
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
|
||||
}
|
||||
: artifactSpec;
|
||||
const artifact = isGenericArtifact(artifactSpec)
|
||||
? {
|
||||
...artifactSpec,
|
||||
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
|
||||
}
|
||||
: artifactSpec;
|
||||
|
||||
try {
|
||||
const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
|
||||
@@ -142,7 +141,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
}
|
||||
} catch (error) {
|
||||
if (inputs.ignore_error.value) {
|
||||
logger.error({ err: error }, 'Failed to upload artifact. Ignoring error.');
|
||||
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
|
||||
// Ignoring error.
|
||||
return;
|
||||
}
|
||||
@@ -159,7 +158,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
* values. This makes sure we support all:
|
||||
* - kebab-case managed artifact types (the original)
|
||||
* - snake-caps-case managed artifact types (the mistake)
|
||||
* - workflow artifact types.
|
||||
* - generic artifact types.
|
||||
*/
|
||||
function parseArtifactTypeInput({
|
||||
inputValue,
|
||||
@@ -167,15 +166,19 @@ function parseArtifactTypeInput({
|
||||
}: {
|
||||
inputValue: string;
|
||||
platform: Job['platform'];
|
||||
}): ManagedArtifactType | null {
|
||||
}): GenericArtifactType | ManagedArtifactType {
|
||||
if (!inputValue) {
|
||||
// In build jobs the default artifact type is application-archive.
|
||||
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : null;
|
||||
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : GenericArtifactType.OTHER;
|
||||
}
|
||||
|
||||
return (
|
||||
artifactTypeInputToManagedArtifactType[inputValue] ??
|
||||
Object.values(ManagedArtifactType).find(type => type === inputValue) ??
|
||||
null
|
||||
);
|
||||
// Step's allowedValues ensures input is either
|
||||
// a key of artifactTypeInputToManagedArtifactType
|
||||
// or a value of an artifact type.
|
||||
const translatedManagedArtifactType = artifactTypeInputToManagedArtifactType[inputValue];
|
||||
if (translatedManagedArtifactType) {
|
||||
return translatedManagedArtifactType;
|
||||
}
|
||||
|
||||
return inputValue as GenericArtifactType | ManagedArtifactType;
|
||||
}
|
||||
+6
-35
@@ -1,7 +1,7 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import type { BuildStepEnv } from '@expo/steps';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { copyFile, mkdir, 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';
|
||||
@@ -40,14 +40,7 @@ beforeEach(async () => {
|
||||
creationTokens.push(request.headers['expo-device-run-session-token'] as string | undefined);
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
uploadSession: {
|
||||
url: `${url}/artifact/${encodeURIComponent(JSON.parse(body).filename)}`,
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
JSON.stringify({ data: { uploadSession: { url: `${url}/artifact`, headers: {} } } })
|
||||
);
|
||||
});
|
||||
} else {
|
||||
@@ -163,14 +156,10 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
]);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('empty/recording.mp4.partial'));
|
||||
|
||||
const uploaded = new Map<string, Buffer>();
|
||||
let putBytes = 0;
|
||||
handle = (request, response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
request.on('data', chunk => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
request.on('data', chunk => (putBytes += chunk.length));
|
||||
request.on('end', () => {
|
||||
uploaded.set(request.url!, Buffer.concat(chunks));
|
||||
response.statusCode = 200;
|
||||
response.end();
|
||||
});
|
||||
@@ -182,18 +171,10 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
recordings,
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
for (const recording of recordings) {
|
||||
const manifest = await readFile(path.join(recording.directory, 'session.json'));
|
||||
const recordingId = path.basename(recording.directory);
|
||||
expect(uploaded.get(`/artifact/${recordingId}.json`)).toEqual(manifest);
|
||||
expect(uploaded.get(`/artifact/${recordingId}.mp4`)).toEqual(
|
||||
await readFile(path.join(recording.directory, JSON.parse(manifest.toString()).recording))
|
||||
);
|
||||
}
|
||||
expect(creationTokens).toEqual(Array(6).fill('session-token'));
|
||||
expect(putBytes).toBe(3 * (await stat(path.join(cut, 'recording.mp4.partial'))).size);
|
||||
expect(creationTokens).toEqual(['session-token', 'session-token', 'session-token']);
|
||||
const inputs = creationBodies
|
||||
.map(body => JSON.parse(body))
|
||||
.filter(input => input.kind === 'screen-recording')
|
||||
.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]);
|
||||
@@ -204,16 +185,6 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
|
||||
width: 128,
|
||||
height: 96,
|
||||
});
|
||||
const manifests = creationBodies
|
||||
.map(body => JSON.parse(body))
|
||||
.filter(input => input.kind === 'screen-recording-metadata')
|
||||
.sort((a, b) => a.filename.localeCompare(b.filename));
|
||||
expect(manifests.map(input => input.filename)).toEqual(['cut.json', 'done.json', 'failed.json']);
|
||||
expect(manifests.map(input => input.metadata.recordingFilename)).toEqual([
|
||||
'cut.mp4',
|
||||
'done.mp4',
|
||||
'failed.mp4',
|
||||
]);
|
||||
expect(inputs[2].metadata).toMatchObject({
|
||||
partial: true,
|
||||
partialReason: 'Recording finalization exceeded 30000 ms.',
|
||||
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
import type { bunyan } from '@expo/logger';
|
||||
import { vol } from 'memfs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
import type { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const recording = {
|
||||
udid: 'simulator-id',
|
||||
deviceName: 'iPhone Duo',
|
||||
runtimeDisplayName: 'iOS 27.1',
|
||||
directory: '/recordings/recording-id',
|
||||
};
|
||||
const manifest = {
|
||||
firstFrameWallClock: { unixMs: 1_791_072_000_000, iso8601: '2026-10-04T00:00:00.000Z' },
|
||||
width: 1440,
|
||||
height: 1200,
|
||||
recording: 'recording.mp4',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{},
|
||||
{
|
||||
deviceStates: [
|
||||
{
|
||||
timeMs: 12.5,
|
||||
state: {
|
||||
width: 1200,
|
||||
height: 1440,
|
||||
orientation: 'landscape-left',
|
||||
screen: 'inner',
|
||||
hingeAngle: 90,
|
||||
physicalOrientation: 'portrait',
|
||||
tableMode: false,
|
||||
contentRect: { x: 220, y: 0, width: 1000, height: 1200 },
|
||||
},
|
||||
},
|
||||
],
|
||||
interruptions: [{ reason: 'simulatorReboot', pausedAtSeconds: 1, resumedAtSeconds: 2 }],
|
||||
futureField: { label: '서울 🌲' },
|
||||
},
|
||||
])('uploads the complete manifest unchanged beside its video (%j)', async extra => {
|
||||
const contents = Buffer.from(JSON.stringify({ ...manifest, ...extra }, null, 2) + '\n');
|
||||
vol.fromJSON({
|
||||
[recording.directory + '/session.json']: contents.toString(),
|
||||
[recording.directory + '/recording.mp4']: 'video',
|
||||
});
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
|
||||
if (input.kind === 'screen-recording') {
|
||||
await writeFile(recording.directory + '/session.json', '{}');
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'session-id',
|
||||
recordings: [recording],
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
const inputs = jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mock.calls.map(([, input]) => input);
|
||||
expect(inputs.map(input => input.filename)).toEqual(['recording-id.mp4', 'recording-id.json']);
|
||||
expect(inputs[1]).toMatchObject({
|
||||
deviceRunSessionId: 'session-id',
|
||||
artifactId: 'recording-id-metadata',
|
||||
kind: 'screen-recording-metadata',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording-metadata',
|
||||
recordingFilename: 'recording-id.mp4',
|
||||
udid: recording.udid,
|
||||
},
|
||||
size: contents.length,
|
||||
});
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of inputs[1].openStream()) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
expect(Buffer.concat(chunks)).toEqual(contents);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports a metadata upload failure while keeping the video and uploading other recordings', async () => {
|
||||
const other = { ...recording, directory: '/recordings/other-recording', udid: 'other-device' };
|
||||
for (const item of [recording, other]) {
|
||||
vol.fromJSON({
|
||||
[item.directory + '/session.json']: JSON.stringify(manifest),
|
||||
[item.directory + '/recording.mp4']: 'video',
|
||||
});
|
||||
}
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockImplementation(async (_, input) => {
|
||||
if (input.filename === 'recording-id.json') {
|
||||
throw new Error('Storage unavailable');
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadDeviceRunSessionScreenRecordingsAsync(ctx, {
|
||||
logger,
|
||||
deviceRunSessionId: 'session-id',
|
||||
recordings: [recording, other],
|
||||
})
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mock.calls.map(([, input]) => input.filename)
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'recording-id.mp4',
|
||||
'recording-id.json',
|
||||
'other-recording.mp4',
|
||||
'other-recording.json',
|
||||
])
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Could not upload device screen recording metadata',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(Sentry.capture).not.toHaveBeenCalledWith(
|
||||
'Could not upload device screen recording',
|
||||
expect.any(Error)
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
`Could not upload screen recording metadata for ${recording.deviceName}.`
|
||||
);
|
||||
});
|
||||
@@ -330,8 +330,7 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
});
|
||||
|
||||
it('reports a request that failed before www responded', async () => {
|
||||
const reason = new Error('socket hang up');
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(new Error('socket hang up'));
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync()
|
||||
@@ -339,7 +338,6 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
expect(error.status).toBeUndefined();
|
||||
expect(error.cause).toBe(reason);
|
||||
expect(error.message).toContain('failed before www responded: socket hang up');
|
||||
});
|
||||
|
||||
|
||||
@@ -12,9 +12,7 @@ import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { verifyLocalEgressGuardAsync } from '../localEgressGuard';
|
||||
import { type ServeSimApplicationOptions } from '../remoteDeviceRunSession';
|
||||
|
||||
import { readLocalEgressHandoffAsync } from '../localEgress';
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from '../serveSimActions';
|
||||
import { startServeSimActivityPolling } from '../serveSimActivity';
|
||||
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
@@ -46,11 +44,6 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
}));
|
||||
jest.mock('../localEgressGuard', () => ({ verifyLocalEgressGuardAsync: jest.fn() }));
|
||||
jest.mock('../serveSimActions');
|
||||
jest.mock('../serveSimActivity');
|
||||
jest.mock('../localEgress', () => ({
|
||||
...jest.requireActual('../localEgress'),
|
||||
readLocalEgressHandoffAsync: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../serveSimMetricsRecorder', () => ({
|
||||
@@ -136,7 +129,6 @@ beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(verifyLocalEgressGuardAsync).mockReset().mockResolvedValue(undefined);
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockReset().mockResolvedValue(null);
|
||||
stopSessionPreview.mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceRunSessionPreview).mockReturnValue({ stopAsync: stopSessionPreview });
|
||||
jest.mocked(ensureFfmpegInstalledOnceAsync).mockResolvedValue(undefined);
|
||||
@@ -460,62 +452,6 @@ it('starts the readiness budget after refusals that exceed the normal startup de
|
||||
await rejected;
|
||||
});
|
||||
|
||||
it('reports preview activity from serve-sim on iOS and stops polling when finished', async () => {
|
||||
const observedAt = new Date('2026-10-19T12:00:00Z');
|
||||
const stopActivity = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(startServeSimActivityPolling).mockReturnValue({
|
||||
getLastActivityObservedAt: () => observedAt,
|
||||
stopAsync: stopActivity,
|
||||
});
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const port = Number(
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].args[
|
||||
jest.mocked(spawnDetached).mock.calls[0][0].args.indexOf('--port') + 1
|
||||
]
|
||||
);
|
||||
|
||||
expect(startServeSimActivityPolling).toHaveBeenCalledWith({
|
||||
port,
|
||||
token: 'preview-token',
|
||||
logger: expect.anything(),
|
||||
});
|
||||
expect(host.getLastPreviewActivityAt()).toBe(observedAt);
|
||||
|
||||
await host.finishAsync();
|
||||
expect(stopActivity).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not start polling preview activity when startup is cancelled', async () => {
|
||||
const controller = new AbortController();
|
||||
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockImplementation(async () => {
|
||||
controller.abort(new Error('cancelled'));
|
||||
});
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
iosSimulatorUdid: 'emulator-5554',
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
signal: controller.signal,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports no preview activity on Android, where expo-device-hub keeps no event log', async () => {
|
||||
const host = await startHostAsync();
|
||||
|
||||
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
|
||||
expect(host.getLastPreviewActivityAt()).toBeUndefined();
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('stops a host that became ready on a different Simulator', async () => {
|
||||
await expect(
|
||||
startDeviceSessionHostAsync(ctx, {
|
||||
@@ -1383,35 +1319,6 @@ it('uses the existing step logger for startup and shutdown output', async () =>
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('passes the active local-egress address to serve-sim without tunnel credentials', async () => {
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue({
|
||||
port: 8899,
|
||||
url: 'https://tunnel.example',
|
||||
token: 'tunnel-secret',
|
||||
fingerprint: 'fingerprint',
|
||||
});
|
||||
const host = await startDeviceSessionHostAsync(ctx, {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: 10_000,
|
||||
networkCapture: true,
|
||||
});
|
||||
const spawned = jest.mocked(spawnDetached).mock.calls[0][0];
|
||||
expect(spawned.args).toEqual(
|
||||
expect.arrayContaining(['--network-capture-proxy', 'http://127.0.0.1:8899'])
|
||||
);
|
||||
expect(spawned.args).toContain('--network-capture');
|
||||
expect(JSON.stringify(spawned)).not.toContain('tunnel-secret');
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it('does not read the iOS egress handoff for an Android host', async () => {
|
||||
const host = await startHostAsync();
|
||||
expect(readLocalEgressHandoffAsync).not.toHaveBeenCalled();
|
||||
await host.finishAsync();
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
'waits for setup before launch or boot-only readiness (app: %s)',
|
||||
async hasApp => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import * as processes from '../../../utils/processes';
|
||||
import { preparePreinstalledWdaAsync } from '../preinstalledWda';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
@@ -79,37 +78,3 @@ it('does not enable preinstalled startup when the download produces no runner',
|
||||
jest.mocked(spawn).mockResolvedValue({ stdout: '' } as never);
|
||||
await expect(preparePreinstalledWdaAsync(options)).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('drains an aborted WDA downloader before removing its partial artifact', async () => {
|
||||
await fs.promises.mkdir(outdir, { recursive: true });
|
||||
const controller = new AbortController();
|
||||
const error = new Error('Simulator startup failed');
|
||||
const child = { pid: 12345, kill: jest.fn() };
|
||||
let closeDownloader!: (error: Error) => void;
|
||||
jest.mocked(spawn).mockReturnValueOnce(
|
||||
Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeDownloader = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const running = preparePreinstalledWdaAsync({ ...options, signal: controller.signal });
|
||||
const settled = jest.fn();
|
||||
const observed = running.then(settled, settled);
|
||||
try {
|
||||
controller.abort(error);
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(outdir)).toBe(true);
|
||||
closeDownloader(new Error('download interrupted'));
|
||||
await expect(running).rejects.toBe(error);
|
||||
expect(fs.existsSync(outdir)).toBe(false);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
closeDownloader(error);
|
||||
await observed;
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -18,7 +18,6 @@ import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import * as processes from '../../../utils/processes';
|
||||
import {
|
||||
IosSimulatorRecordingUtils,
|
||||
SERVE_SIM_STOP_GRACE_PERIOD_MS,
|
||||
@@ -661,14 +660,6 @@ describe(createServeSimArgs, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes a capture proxy as one argument, including when capture will be enabled later', () => {
|
||||
const proxy = 'http://user:p%40ss%[email protected]:8899';
|
||||
const args = createServeSimArgs({ port: 4321, networkCaptureProxy: proxy });
|
||||
expect(args.slice(-2)).toEqual(['--network-capture-proxy', proxy]);
|
||||
expect(args).not.toContain('--network-capture');
|
||||
expect(createServeSimArgs({ port: 4321 })).not.toContain('--network-capture-proxy');
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -1939,47 +1930,6 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
|
||||
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
|
||||
});
|
||||
|
||||
it('keeps the session alive while preview input keeps arriving', async () => {
|
||||
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'stopped' }]);
|
||||
const logger = createLoggerMock();
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () => new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(workerApiCallCount('session')).toBe(21);
|
||||
expect(workerApiCallCount('stop')).toBe(0);
|
||||
});
|
||||
|
||||
it('counts idle time from the later of automation and preview activity', async () => {
|
||||
const ctx = createStatusCtxMock(manyInProgressStatuses());
|
||||
const logger = createLoggerMock();
|
||||
const previewInputAt = new Date(Date.now() + 30_000);
|
||||
|
||||
await waitForDeviceRunSessionStoppedAsync({
|
||||
ctx,
|
||||
deviceRunSessionId: 'drs-id',
|
||||
logger,
|
||||
idleTimeout: {
|
||||
maxIdleTimeMinutes: 1,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
getLastPreviewActivityAt: () =>
|
||||
Date.now() >= previewInputAt.getTime() ? previewInputAt : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Idle from 30 s, so the stop comes at 90 s: 18 polls.
|
||||
expect(workerApiCallCount('session')).toBe(18);
|
||||
expect(workerApiCallCount('stop')).toBe(1);
|
||||
});
|
||||
|
||||
it('still returns when the session cannot be marked stopped', async () => {
|
||||
const ctx = createStatusCtxMock(manyInProgressStatuses(), {
|
||||
stopError: new Error('forbidden'),
|
||||
@@ -2185,105 +2135,6 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('retries one aborted FFmpeg install for two surviving shared callers after it drains', async () => {
|
||||
const controller = new AbortController();
|
||||
const error = new Error('Simulator startup failed');
|
||||
const child = { pid: 12345, kill: jest.fn() };
|
||||
let closeInstaller!: (error: Error) => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
Object.assign(
|
||||
new Promise((_resolve, reject) => {
|
||||
closeInstaller = reject;
|
||||
}),
|
||||
{ child }
|
||||
) as never
|
||||
);
|
||||
let finishReplacement!: () => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
new Promise<void>(resolve => {
|
||||
finishReplacement = resolve;
|
||||
}) as never
|
||||
);
|
||||
spawnMock.mockReturnValue(spawnResolved());
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
const owner = ensureFfmpegInstalledOnceAsync({ ...options, signal: controller.signal });
|
||||
const surviving = [
|
||||
ensureFfmpegInstalledOnceAsync(options),
|
||||
ensureFfmpegInstalledOnceAsync({ ...options, signal: new AbortController().signal }),
|
||||
];
|
||||
const settled = jest.fn();
|
||||
const observed = owner.then(settled, settled);
|
||||
const borrowersSettled = jest.fn();
|
||||
const observedBorrowers = Promise.all(surviving).then(borrowersSettled, borrowersSettled);
|
||||
try {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
controller.abort(error);
|
||||
expect(killGroup).toHaveBeenCalledWith(child, 'SIGKILL');
|
||||
expect(settled).not.toHaveBeenCalled();
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
closeInstaller(new Error('brew interrupted'));
|
||||
await expect(owner).rejects.toBe(error);
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
expect(borrowersSettled).not.toHaveBeenCalled();
|
||||
finishReplacement();
|
||||
await expect(Promise.all(surviving)).resolves.toEqual([undefined, undefined]);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(3);
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
|
||||
expect(options.logger.warn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
closeInstaller?.(error);
|
||||
finishReplacement();
|
||||
await Promise.all([observed, observedBorrowers]);
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops an aborted shared waiter without cancelling the installer owner', async () => {
|
||||
let finishInstall!: () => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
new Promise<void>(resolve => {
|
||||
finishInstall = resolve;
|
||||
}) as never
|
||||
);
|
||||
spawnMock.mockReturnValue(spawnResolved());
|
||||
const killGroup = jest.spyOn(processes, 'killProcessGroup').mockImplementation(() => {});
|
||||
const controller = new AbortController();
|
||||
const error = new Error('Appium startup failed');
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
const owner = ensureFfmpegInstalledOnceAsync(options);
|
||||
let ownerFinished = false;
|
||||
const observedOwner = owner.then(() => {
|
||||
ownerFinished = true;
|
||||
});
|
||||
const waiter = ensureFfmpegInstalledOnceAsync({ ...options, signal: controller.signal });
|
||||
const observedWaiter = waiter.catch(() => {});
|
||||
try {
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
controller.abort(error);
|
||||
await expect(waiter).rejects.toBe(error);
|
||||
expect(ownerFinished).toBe(false);
|
||||
expect(killGroup).not.toHaveBeenCalled();
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
finishInstall();
|
||||
await expect(owner).resolves.toBeUndefined();
|
||||
expect(spawnMock).toHaveBeenLastCalledWith(...warmUpCall);
|
||||
} finally {
|
||||
finishInstall();
|
||||
await Promise.all([observedOwner, observedWaiter]);
|
||||
killGroup.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('still installs on linux when the apt index refresh fails', async () => {
|
||||
spawnMock
|
||||
.mockReturnValueOnce(spawnRejected()) // apt-get update
|
||||
@@ -2321,30 +2172,6 @@ describe(ensureFfmpegInstalledOnceAsync, () => {
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry an ordinary shared FFmpeg install failure', async () => {
|
||||
let failInstall!: (error: Error) => void;
|
||||
spawnMock.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
failInstall = reject;
|
||||
}) as never
|
||||
);
|
||||
const options = {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
env: createPathEnv(),
|
||||
logger: createLoggerMock(),
|
||||
};
|
||||
const callers = [
|
||||
ensureFfmpegInstalledOnceAsync(options),
|
||||
ensureFfmpegInstalledOnceAsync(options),
|
||||
ensureFfmpegInstalledOnceAsync({ ...options, signal: new AbortController().signal }),
|
||||
];
|
||||
await new Promise<void>(resolve => setImmediate(resolve));
|
||||
failInstall(new Error('Homebrew unavailable'));
|
||||
await expect(Promise.all(callers)).resolves.toEqual([undefined, undefined, undefined]);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(options.logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// The caller runs this with `void` and the worker installs no unhandledRejection
|
||||
// handler, so a rejection here would crash the process. `spawn` is not async and
|
||||
// can throw synchronously, which `asyncResult` cannot catch.
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { once } from 'node:events';
|
||||
import http from 'node:http';
|
||||
|
||||
import { fetchLatestServeSimEventIdAsync, startServeSimActivityPolling } from '../serveSimActivity';
|
||||
|
||||
const token = 'test-session-token';
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let events: { id: number }[];
|
||||
let status: number;
|
||||
let requests: { url?: string; authorization?: string }[];
|
||||
let rawBody: string | undefined;
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
events = [];
|
||||
status = 200;
|
||||
requests = [];
|
||||
rawBody = undefined;
|
||||
server = http.createServer((req, res) => {
|
||||
requests.push({ url: req.url, authorization: req.headers.authorization });
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(rawBody ?? JSON.stringify({ events: events.slice(-1) }));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
port = (server.address() as { port: number }).port;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
describe(fetchLatestServeSimEventIdAsync, () => {
|
||||
it('reads the newest event ID with the session token', async () => {
|
||||
events = [{ id: 3 }, { id: 7 }];
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBe(7);
|
||||
expect(requests).toEqual([{ url: '/api/event-log?limit=1', authorization: `Bearer ${token}` }]);
|
||||
});
|
||||
|
||||
it('returns undefined when serve-sim has logged no event', async () => {
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['unparseable JSON', 'not json'],
|
||||
['a body without an events array', '{}'],
|
||||
['an entry without a numeric ID', '{"events":[{"id":"7"}]}'],
|
||||
])('rejects %s', async (_, body) => {
|
||||
rawBody = body;
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow(
|
||||
'serve-sim event log'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a refused request', async () => {
|
||||
status = 401;
|
||||
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow('401');
|
||||
});
|
||||
});
|
||||
|
||||
describe(startServeSimActivityPolling, () => {
|
||||
async function waitForRequestsAsync(count: number): Promise<void> {
|
||||
while (requests.length < count) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
}
|
||||
|
||||
it('reports activity only when a new event appears', async () => {
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
|
||||
events = [{ id: 1 }];
|
||||
const before = Date.now();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const firstSeen = polling.getLastActivityObservedAt();
|
||||
expect(firstSeen?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
|
||||
|
||||
events = [{ id: 1 }, { id: 2 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while the event log fails, and warns once', async () => {
|
||||
status = 500;
|
||||
const logger = createLoggerMock();
|
||||
const polling = startServeSimActivityPolling({ port, token, logger, pollIntervalMs: 10 });
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
events = [{ id: 4 }];
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
const observedAt = polling.getLastActivityObservedAt();
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the last observation while serve-sim refuses connections', async () => {
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
server = http.createServer();
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity when serve-sim drops the connection mid-response', async () => {
|
||||
server.removeAllListeners('request');
|
||||
server.on('request', (req, res) => {
|
||||
requests.push({ url: req.url });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '100' });
|
||||
res.write('{"events":');
|
||||
setImmediate(() => res.socket?.destroy());
|
||||
});
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(3);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports current activity while serve-sim answers without an event log', async () => {
|
||||
status = 404;
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
try {
|
||||
await waitForRequestsAsync(2);
|
||||
const before = Date.now();
|
||||
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
|
||||
|
||||
status = 200;
|
||||
await waitForRequestsAsync(requests.length + 2);
|
||||
expect(polling.getLastActivityObservedAt()).toBeUndefined();
|
||||
} finally {
|
||||
await polling.stopAsync();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops polling after stopAsync', async () => {
|
||||
const polling = startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger: createLoggerMock(),
|
||||
pollIntervalMs: 10,
|
||||
});
|
||||
await waitForRequestsAsync(1);
|
||||
await polling.stopAsync();
|
||||
const count = requests.length;
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
expect(requests.length).toBe(count);
|
||||
});
|
||||
});
|
||||
@@ -196,7 +196,7 @@ export namespace GooglePlayUtils {
|
||||
signal?.throwIfAborted();
|
||||
return isUploadStillProcessing(error);
|
||||
},
|
||||
{ retries: 13, factor: 2, minTimeout: 2000, maxTimeout: 32_000 },
|
||||
{ retries: 5, factor: 2, minTimeout: 2000, maxTimeout: 32_000 },
|
||||
({ attemptNumber, maxAttemptsCount }) => {
|
||||
logger.warn(
|
||||
`Google Play is still processing the uploaded bundle. Retrying the same edit commit (attempt ${attemptNumber}/${maxAttemptsCount}).`
|
||||
@@ -206,7 +206,7 @@ export namespace GooglePlayUtils {
|
||||
} catch (error) {
|
||||
if (isUploadStillProcessing(error)) {
|
||||
throw new SystemError(
|
||||
`Google Play did not finish processing the uploaded bundle for ${packageName} within five minutes. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
|
||||
`Google Play did not finish processing the uploaded bundle for ${packageName} after six commit attempts. Wait a few minutes, then check the release at https://play.google.com/console/ before submitting again.`,
|
||||
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
|
||||
);
|
||||
}
|
||||
@@ -302,28 +302,6 @@ export namespace GooglePlayUtils {
|
||||
code = 'EAS_GOOGLE_PLAY_DRAFT_APP';
|
||||
detail =
|
||||
'This app is still a draft in Play Console. Submit a draft release without a rollout, or complete the app setup in Play Console before submitting a release to testers. Use releaseStatus: "draft" in eas.json, or release_status: "draft" in a workflow.';
|
||||
} else if (/must declare the use of advertising ID/i.test(message)) {
|
||||
code = 'EAS_GOOGLE_PLAY_ADVERTISING_ID_DECLARATION_REQUIRED';
|
||||
detail =
|
||||
'Google Play requires an advertising ID declaration for this app. Complete the declaration in Play Console so it matches the app and its SDKs, then submit again.';
|
||||
} else if (
|
||||
/must let us know whether your app uses any Foreground Service permissions/i.test(message)
|
||||
) {
|
||||
code = 'EAS_GOOGLE_PLAY_FOREGROUND_SERVICE_DECLARATION_REQUIRED';
|
||||
detail =
|
||||
'Google Play requires a foreground service declaration for this app. Complete the declaration in Play Console for the permissions used by the app, then submit again.';
|
||||
} else if (
|
||||
/Google Play Android Developer API has not been used in project .* before or it is disabled/i.test(
|
||||
message
|
||||
)
|
||||
) {
|
||||
code = 'EAS_GOOGLE_PLAY_API_DISABLED';
|
||||
detail =
|
||||
'The Google Play Android Developer API is not enabled for the Google Cloud project used by this service account. Enable the API in that project, wait for the change to take effect, then submit again.';
|
||||
} else if (/upload certificate.*not yet valid.*recently reset/i.test(message)) {
|
||||
code = 'EAS_GOOGLE_PLAY_UPLOAD_CERTIFICATE_NOT_YET_VALID';
|
||||
detail =
|
||||
'Google Play has not activated the upload certificate after its reset. Check the activation time in Play Console or the reset confirmation, wait until the certificate is active, then submit again. Do not reset the key again to resolve this wait.';
|
||||
} else if (error.status === 409 || /edit.*(?:expired|invalid|not found)/i.test(message)) {
|
||||
code = 'EAS_GOOGLE_PLAY_EDIT_INVALID';
|
||||
detail =
|
||||
|
||||
+2
-44
@@ -110,12 +110,12 @@ it.each([false, true])(
|
||||
}
|
||||
);
|
||||
|
||||
it('stops waiting for bundle processing after 14 attempts', async () => {
|
||||
it('stops waiting for bundle processing after six attempts', async () => {
|
||||
mockToken();
|
||||
api()
|
||||
.post(`${editPath}:commit`)
|
||||
.query({})
|
||||
.times(14)
|
||||
.times(6)
|
||||
.reply(400, { error: { message: pendingUploadMessage } });
|
||||
const error = await commit().catch(error => error);
|
||||
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toMatchObject({
|
||||
@@ -194,48 +194,6 @@ it.each([
|
||||
expect((mapped as Error).message).toContain('releaseStatus: "draft" in eas.json');
|
||||
}
|
||||
});
|
||||
it.each([
|
||||
[
|
||||
400,
|
||||
'Your app targets Android 13 (API 33) or above. You must declare the use of advertising ID in Play Console.',
|
||||
'EAS_GOOGLE_PLAY_ADVERTISING_ID_DECLARATION_REQUIRED',
|
||||
'advertising ID declaration',
|
||||
],
|
||||
[
|
||||
403,
|
||||
'You must let us know whether your app uses any Foreground Service permissions.',
|
||||
'EAS_GOOGLE_PLAY_FOREGROUND_SERVICE_DECLARATION_REQUIRED',
|
||||
'foreground service declaration',
|
||||
],
|
||||
[
|
||||
403,
|
||||
'Google Play Android Developer API has not been used in project 123 before or it is disabled. Enable it by visiting https://console.developers.google.com/',
|
||||
'EAS_GOOGLE_PLAY_API_DISABLED',
|
||||
'Google Cloud project',
|
||||
],
|
||||
[
|
||||
403,
|
||||
'The upload certificate this APK is signed with is not yet valid because it has been recently reset.',
|
||||
'EAS_GOOGLE_PLAY_UPLOAD_CERTIFICATE_NOT_YET_VALID',
|
||||
'activation time',
|
||||
],
|
||||
] as const)('gives a specific app-setup remedy for %s %s', (status, message, code, remedy) => {
|
||||
const original = new GooglePlayApiError(status, message, [], {
|
||||
requestPath: '/example',
|
||||
responseBody: 'diagnostic',
|
||||
});
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
|
||||
expect(mapped).toMatchObject({
|
||||
errorCode: code,
|
||||
cause: original,
|
||||
metadata: { googlePlayRequestPath: '/example', googlePlayErrorResponse: 'diagnostic' },
|
||||
});
|
||||
expect((mapped as Error).message).toContain(remedy);
|
||||
expect((mapped as Error).message).toContain(`App: ${packageName}`);
|
||||
expect((mapped as Error).message).toContain('https://play.google.com/console/');
|
||||
expect((mapped as Error).message).not.toContain('Check the track');
|
||||
});
|
||||
|
||||
it('maps a rejected OAuth key to a credential error', () => {
|
||||
const original = new GooglePlayAuthRequestError(400, 'invalid_grant');
|
||||
expect(GooglePlayUtils.mapGooglePlayError(original, packageName)).toMatchObject({
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 { Readable } from 'node:stream';
|
||||
import limitFactory from 'promise-limit';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -141,8 +140,9 @@ export async function uploadDeviceRunSessionScreenRecordingsAsync(
|
||||
recordings.map(recording =>
|
||||
limit(async () => {
|
||||
try {
|
||||
const manifest = await readFile(path.join(recording.directory, 'session.json'));
|
||||
const metadata = RecordingManifestSchema.parse(JSON.parse(manifest.toString('utf-8')));
|
||||
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.');
|
||||
}
|
||||
@@ -186,31 +186,6 @@ export async function uploadDeviceRunSessionScreenRecordingsAsync(
|
||||
openStream: () => createReadStream(recordingPath),
|
||||
attempts: 3,
|
||||
});
|
||||
try {
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: `${recordingId}-metadata`,
|
||||
name: `${displayName} metadata`,
|
||||
filename: `${recordingId}.json`,
|
||||
kind: 'screen-recording-metadata',
|
||||
metadata: {
|
||||
__eas_type: 'screen-recording-metadata',
|
||||
recordingFilename: `${recordingId}.mp4`,
|
||||
udid: recording.udid,
|
||||
},
|
||||
size: manifest.length,
|
||||
openStream: () => Readable.from(manifest),
|
||||
attempts: 3,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload device screen recording metadata', error);
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
`Could not upload screen recording metadata for ${recording.deviceName}.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
@@ -88,16 +88,11 @@ export class DeviceRunSessionWorkerApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** The HTTP status of the response, or undefined when no response arrived. */
|
||||
readonly status: number | undefined,
|
||||
options?: ErrorOptions
|
||||
readonly status: number | undefined
|
||||
) {
|
||||
super(message, options);
|
||||
super(message);
|
||||
this.name = 'DeviceRunSessionWorkerApiError';
|
||||
}
|
||||
|
||||
get isTransient(): boolean {
|
||||
return this.status === undefined || this.status >= 500;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,8 +218,7 @@ export class DeviceRunSessionWorkerApi {
|
||||
`${routeDescription} failed before www responded: ${
|
||||
reason instanceof Error ? reason.message : String(reason)
|
||||
}. Check the network connection of the worker.`,
|
||||
undefined,
|
||||
{ cause: reason }
|
||||
undefined
|
||||
);
|
||||
}
|
||||
const response = responseResult.value;
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
} from './remoteDeviceRunSession';
|
||||
import { LOCAL_EGRESS_PROXY_HOST, readLocalEgressHandoffAsync } from './localEgress';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import {
|
||||
IosSimulatorRecordingUtils,
|
||||
@@ -49,7 +48,6 @@ import {
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
import { startLogPhase } from '../../utils/logPhase';
|
||||
import { runServeSimActionAsync, stageServeSimAppAsync } from './serveSimActions';
|
||||
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
|
||||
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
|
||||
import { IosSimulatorUtils, type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
|
||||
import { createStartupTasks } from './startupTasks';
|
||||
@@ -138,7 +136,6 @@ export function createServeSimArgs({
|
||||
openUrl,
|
||||
networkCapture = false,
|
||||
networkCaptureFields = [],
|
||||
networkCaptureProxy,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
@@ -152,7 +149,6 @@ export function createServeSimArgs({
|
||||
installAppPath?: string;
|
||||
networkCapture?: boolean;
|
||||
networkCaptureFields?: string[];
|
||||
networkCaptureProxy?: string;
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
@@ -192,7 +188,6 @@ export function createServeSimArgs({
|
||||
...networkCaptureFields.flatMap(field => ['--network-capture-field', field]),
|
||||
]
|
||||
: []),
|
||||
...(networkCaptureProxy !== undefined ? ['--network-capture-proxy', networkCaptureProxy] : []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -355,8 +350,6 @@ export type DeviceWebPreview = {
|
||||
|
||||
export type DeviceSessionHost = {
|
||||
openPreviewAsync(options: { baseDomain: string; authtoken: string }): Promise<DeviceWebPreview>;
|
||||
/** Last observed preview input. Always `undefined` on Android, which keeps no event log. */
|
||||
getLastPreviewActivityAt(): Date | undefined;
|
||||
/**
|
||||
* Terminal and idempotent. Never rejects: a failed finalization, host stop or upload is logged
|
||||
* and reported, so callers need no error handling around it.
|
||||
@@ -500,7 +493,6 @@ async function startDeviceSessionHostInternalAsync(
|
||||
? createExpoDeviceHubPackageSpec(packageVersion)
|
||||
: createServeSimPackageSpec(packageVersion);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
|
||||
const localEgress = !isAndroid ? await readLocalEgressHandoffAsync() : null;
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
isAndroid
|
||||
@@ -530,9 +522,6 @@ async function startDeviceSessionHostInternalAsync(
|
||||
openUrl: iosSimulatorUdid ? undefined : openUrl,
|
||||
networkCapture,
|
||||
networkCaptureFields,
|
||||
networkCaptureProxy: localEgress
|
||||
? `http://${LOCAL_EGRESS_PROXY_HOST}:${localEgress.port}`
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
logger.info(
|
||||
@@ -588,7 +577,6 @@ async function startDeviceSessionHostInternalAsync(
|
||||
const previewSetupController = new AbortController();
|
||||
let previewSetupTask: Promise<void> | undefined;
|
||||
let previewFailed = false;
|
||||
let previewActivity: ServeSimActivityPolling | null = null;
|
||||
|
||||
const host: DeviceSessionHost = {
|
||||
openPreviewAsync({ baseDomain, authtoken }) {
|
||||
@@ -646,9 +634,6 @@ async function startDeviceSessionHostInternalAsync(
|
||||
});
|
||||
return opening;
|
||||
},
|
||||
getLastPreviewActivityAt() {
|
||||
return previewActivity?.getLastActivityObservedAt();
|
||||
},
|
||||
finishAsync() {
|
||||
readinessController.abort(new Error('Simulator preview ended.'));
|
||||
return (finishTask ??= finishDeviceSessionHostAsync(ctx, {
|
||||
@@ -657,7 +642,6 @@ async function startDeviceSessionHostInternalAsync(
|
||||
previewSetupController.abort();
|
||||
await previewSetupTask;
|
||||
await sessionPreview?.stopAsync();
|
||||
await previewActivity?.stopAsync();
|
||||
},
|
||||
previewServer,
|
||||
screenshots,
|
||||
@@ -785,9 +769,6 @@ async function startDeviceSessionHostInternalAsync(
|
||||
secrets.push(previewToken);
|
||||
if (!isAndroid) {
|
||||
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
|
||||
if (!finishTask) {
|
||||
previewActivity = startServeSimActivityPolling({ port, token: previewToken, logger });
|
||||
}
|
||||
}
|
||||
// Android installed FFmpeg before launching the host. The optional thumbnail must not delay
|
||||
// readiness on macOS, so it starts after its encoder is installed there.
|
||||
|
||||
@@ -12,16 +12,13 @@ export async function preparePreinstalledWdaAsync({
|
||||
appiumBinPath,
|
||||
appiumEnv,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
runtimeIdentifier: string | undefined;
|
||||
appiumHome: string;
|
||||
appiumBinPath: string;
|
||||
appiumEnv: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
signal?.throwIfAborted();
|
||||
const iosMajor = /^com\.apple\.CoreSimulator\.SimRuntime\.iOS-(\d+)(?:-|$)/.exec(
|
||||
runtimeIdentifier ?? ''
|
||||
)?.[1];
|
||||
@@ -55,17 +52,10 @@ export async function preparePreinstalledWdaAsync({
|
||||
// Appium's downloader inherits its pipes; killing only Appium leaves spawn's
|
||||
// close event pending. Kill the owned group, then await close before cleanup.
|
||||
const timeout = setTimeout(() => killProcessGroup(download.child, 'SIGKILL'), 60_000);
|
||||
const abort = (): void => killProcessGroup(download.child, 'SIGKILL');
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
await download;
|
||||
signal?.throwIfAborted();
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
if (!(await fs.promises.stat(runner)).isDirectory()) {
|
||||
throw new Error('The downloaded WebDriverAgent runner is not an app directory.');
|
||||
@@ -77,7 +67,6 @@ export async function preparePreinstalledWdaAsync({
|
||||
};
|
||||
} catch (err) {
|
||||
await fs.promises.rm(outdir, { recursive: true, force: true });
|
||||
signal?.throwIfAborted();
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Could not prepare preinstalled WebDriverAgent; using the default xcodebuild startup.'
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
parseOpenUrlInput,
|
||||
} from '../functions/launchApplication';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { isProcessGroupRunning, killProcessGroup } from '../../utils/processes';
|
||||
import { isProcessGroupRunning } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { createProcessOutput } from './processOutput';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
@@ -126,8 +126,6 @@ export type DeviceRunSessionIdleTimeout = {
|
||||
* begins, so a session nobody ever connects to still times out.
|
||||
*/
|
||||
getLastEventObservedAt: () => Date | undefined;
|
||||
/** Last observed preview input, counted like a session event. */
|
||||
getLastPreviewActivityAt?: () => Date | undefined;
|
||||
};
|
||||
|
||||
export async function waitForDeviceRunSessionStoppedAsync({
|
||||
@@ -181,13 +179,9 @@ export async function waitForDeviceRunSessionStoppedAsync({
|
||||
|
||||
while (!signal.aborted) {
|
||||
if (idleTimeout) {
|
||||
for (const observedAt of [
|
||||
idleTimeout.getLastEventObservedAt(),
|
||||
idleTimeout.getLastPreviewActivityAt?.(),
|
||||
]) {
|
||||
if (observedAt && observedAt > lastActivityAt) {
|
||||
lastActivityAt = observedAt;
|
||||
}
|
||||
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
|
||||
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
|
||||
lastActivityAt = lastEventObservedAt;
|
||||
}
|
||||
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
|
||||
logger.info(
|
||||
@@ -317,28 +311,14 @@ function warmUpFfmpeg(env: BuildStepEnv): void {
|
||||
async function installFfmpegWithHomebrewAsync({
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const install = spawn('brew', ['install', 'ffmpeg'], {
|
||||
await spawn('brew', ['install', 'ffmpeg'], {
|
||||
env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
|
||||
logger,
|
||||
...(signal ? { detached: true } : {}),
|
||||
});
|
||||
const abort = (): void => killProcessGroup(install.child, 'SIGKILL');
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
}
|
||||
try {
|
||||
await install;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
|
||||
async function installFfmpegWithAptAsync({
|
||||
@@ -356,7 +336,7 @@ async function installFfmpegWithAptAsync({
|
||||
await spawn('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger });
|
||||
}
|
||||
|
||||
let ffmpegSetupPromise: Promise<'aborted' | void> | undefined;
|
||||
let ffmpegSetupPromise: Promise<void> | undefined;
|
||||
|
||||
/**
|
||||
* Install ffmpeg when the runtime does not already provide it. Device-session
|
||||
@@ -376,16 +356,13 @@ async function ensureFfmpegInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<'aborted' | void> {
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const ffmpegPath = await findFfmpegOnPathAsync(env);
|
||||
signal?.throwIfAborted();
|
||||
if (ffmpegPath) {
|
||||
logger.info(`ffmpeg is already installed at ${ffmpegPath}.`);
|
||||
warmUpFfmpeg(env);
|
||||
@@ -399,17 +376,13 @@ async function ensureFfmpegInstalledAsync({
|
||||
} for the device session.`
|
||||
);
|
||||
if (isDarwin) {
|
||||
await installFfmpegWithHomebrewAsync({ env, logger, signal });
|
||||
await installFfmpegWithHomebrewAsync({ env, logger });
|
||||
} else {
|
||||
await installFfmpegWithAptAsync({ env, logger });
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
logger.info('Installed ffmpeg.');
|
||||
warmUpFfmpeg(env);
|
||||
} catch (err) {
|
||||
if (signal?.aborted) {
|
||||
return 'aborted';
|
||||
}
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not install ffmpeg for the device session', error, {
|
||||
level: 'warning',
|
||||
@@ -425,46 +398,20 @@ export async function ensureFfmpegInstalledOnceAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
signal,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
if (ffmpegSetupPromise) {
|
||||
const sharedSetup = ffmpegSetupPromise;
|
||||
let outcome: 'aborted' | void;
|
||||
if (signal) {
|
||||
outcome = await new Promise<'aborted' | void>((resolve, reject) => {
|
||||
const abort = (): void => reject(signal.reason);
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
void sharedSetup.then(result => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
resolve(result);
|
||||
});
|
||||
if (signal.aborted) {
|
||||
abort();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
outcome = await sharedSetup;
|
||||
}
|
||||
if (outcome === 'aborted') {
|
||||
if (ffmpegSetupPromise === sharedSetup) {
|
||||
ffmpegSetupPromise = undefined;
|
||||
}
|
||||
await ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger, signal });
|
||||
}
|
||||
await ffmpegSetupPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger, signal });
|
||||
const setupPromise = ensureFfmpegInstalledAsync({ runtimePlatform, env, logger });
|
||||
ffmpegSetupPromise = setupPromise;
|
||||
try {
|
||||
await setupPromise;
|
||||
signal?.throwIfAborted();
|
||||
} finally {
|
||||
if (ffmpegSetupPromise === setupPromise) {
|
||||
ffmpegSetupPromise = undefined;
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
export type ServeSimActivityPolling = {
|
||||
/**
|
||||
* When a new event log entry (input or host action, not stream traffic) was first seen. Reports
|
||||
* now while the log is unreadable but serve-sim is up, so an unobservable session stays active.
|
||||
*/
|
||||
getLastActivityObservedAt: () => Date | undefined;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** Nothing listens on the port. A reset or dropped response can come from a running serve-sim. */
|
||||
function isServeSimDownError(err: unknown): boolean {
|
||||
// Not `instanceof`: undici errors can come from another realm.
|
||||
let error: unknown = err;
|
||||
for (let depth = 0; depth < 5 && typeof error === 'object' && error !== null; depth++) {
|
||||
const { code, cause } = error as { code?: unknown; cause?: unknown };
|
||||
if (code === 'ECONNREFUSED') {
|
||||
return true;
|
||||
}
|
||||
error = cause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A changed newest event ID counts as activity, timed by the local clock. */
|
||||
export function startServeSimActivityPolling({
|
||||
port,
|
||||
token,
|
||||
logger,
|
||||
pollIntervalMs = POLL_INTERVAL_MS,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
logger: bunyan;
|
||||
pollIntervalMs?: number;
|
||||
}): ServeSimActivityPolling {
|
||||
const controller = new AbortController();
|
||||
let lastEventId: number | undefined;
|
||||
let lastActivityObservedAt: Date | undefined;
|
||||
let eventLogUnavailable = false;
|
||||
let didWarn = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
|
||||
const pollAsync = async (): Promise<void> => {
|
||||
try {
|
||||
const eventId = await fetchLatestServeSimEventIdAsync({
|
||||
port,
|
||||
token,
|
||||
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
||||
});
|
||||
eventLogUnavailable = false;
|
||||
if (eventId !== undefined && eventId !== lastEventId) {
|
||||
lastEventId = eventId;
|
||||
lastActivityObservedAt = new Date();
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
// Only a down serve-sim, which receives no input either, keeps the last observation.
|
||||
eventLogUnavailable = !isServeSimDownError(err);
|
||||
if (!didWarn) {
|
||||
didWarn = true;
|
||||
logger.warn(
|
||||
{ err },
|
||||
eventLogUnavailable
|
||||
? 'Could not read the serve-sim event log. Preview input cannot be observed, so the ' +
|
||||
'idle timeout does not stop this session.'
|
||||
: 'Could not read serve-sim preview activity.'
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const tick = (): void => {
|
||||
pending = pollAsync().finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
timer = setTimeout(tick, pollIntervalMs);
|
||||
timer.unref();
|
||||
}
|
||||
});
|
||||
};
|
||||
tick();
|
||||
return {
|
||||
getLastActivityObservedAt: () => (eventLogUnavailable ? new Date() : lastActivityObservedAt),
|
||||
stopAsync: async () => {
|
||||
controller.abort();
|
||||
clearTimeout(timer);
|
||||
await pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchLatestServeSimEventIdAsync({
|
||||
port,
|
||||
token,
|
||||
signal,
|
||||
}: {
|
||||
port: number;
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<number | undefined> {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/event-log?limit=1`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`serve-sim event log request failed: ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
let body: { events?: unknown } | undefined;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {}
|
||||
if (!Array.isArray(body?.events)) {
|
||||
throw new Error('serve-sim event log response has no events array.');
|
||||
}
|
||||
if (body.events.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const id = (body.events.at(-1) as { id?: unknown } | null)?.id;
|
||||
if (typeof id !== 'number') {
|
||||
throw new Error('serve-sim event log entry has no numeric ID.');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
@@ -154,9 +154,8 @@ export namespace AndroidEmulatorUtils {
|
||||
try {
|
||||
let configIniFileContent = await fs.promises.readFile(configIniFile, 'utf-8');
|
||||
|
||||
// Keep the lower RAM default for older images; newer guests need more headroom for ADB.
|
||||
const apiLevel = systemImagePackage.match(/^system-images;android-(\d+)/)?.[1];
|
||||
const ramSize = !env.DEVICE_RUN_SESSION_ID && apiLevel && Number(apiLevel) < 35 ? 2048 : 4096;
|
||||
// Give interactive sessions more headroom for guest VSOCK packet allocations.
|
||||
const ramSize = env.DEVICE_RUN_SESSION_ID ? 4096 : 2048;
|
||||
logger.info(`Setting hw.ramSize to ${ramSize}.`);
|
||||
configIniFileContent = `${configIniFileContent}\nhw.ramSize=${ramSize}\n`;
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('AndroidEmulatorUtils', () => {
|
||||
'hw.lcd.density=420',
|
||||
'skin.path=pixel_9',
|
||||
'showDeviceFrame=yes',
|
||||
'hw.ramSize=4096',
|
||||
'hw.ramSize=2048',
|
||||
'',
|
||||
'hw.lcd.density=220',
|
||||
'',
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
|
||||
import {
|
||||
describeError,
|
||||
findErrorCode,
|
||||
findGraphqlErrorCode,
|
||||
isConnectFailure,
|
||||
isConnectionInterruptedError,
|
||||
isTransferFailure,
|
||||
isTransientGraphqlError,
|
||||
} from '../networkErrors';
|
||||
|
||||
const { FetchError } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
import { isConnectFailure, isConnectionInterruptedError } from '../networkErrors';
|
||||
|
||||
it.each([
|
||||
'EAI_AGAIN',
|
||||
@@ -46,77 +34,3 @@ it.each(['ETIMEDOUT', 'CERT_HAS_EXPIRED', undefined])('does not automatically re
|
||||
expect(isConnectFailure(error)).toBe(false);
|
||||
expect(isConnectionInterruptedError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it('finds the errno code on the cause of an undici fetch failure', () => {
|
||||
const error = new CombinedError({
|
||||
networkError: new TypeError('fetch failed', {
|
||||
cause: Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }),
|
||||
}),
|
||||
});
|
||||
expect(findErrorCode(error)).toBe('ECONNRESET');
|
||||
expect(findErrorCode(new Error('Request failed'))).toBeUndefined();
|
||||
expect(findErrorCode('Request failed')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('finds the www error code on a GraphQL error', () => {
|
||||
const error = new CombinedError({
|
||||
graphQLErrors: [
|
||||
{ message: 'Too many requests', extensions: { errorCode: 'TOO_MANY_REQUESTS' } },
|
||||
],
|
||||
});
|
||||
expect(findGraphqlErrorCode(error)).toBe('TOO_MANY_REQUESTS');
|
||||
expect(
|
||||
findGraphqlErrorCode(new CombinedError({ graphQLErrors: ['Build not found'] }))
|
||||
).toBeUndefined();
|
||||
expect(findGraphqlErrorCode(new Error('Request failed'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Request failed', 'ECONNRESET', 'Request failed (ECONNRESET)'],
|
||||
['read ECONNRESET', 'ECONNRESET', 'read ECONNRESET'],
|
||||
['Request failed', undefined, 'Request failed'],
|
||||
])('describes %p with code %p as %p', (message, code, expected) => {
|
||||
expect(describeError(Object.assign(new Error(message), { code }))).toBe(expected);
|
||||
});
|
||||
|
||||
it('treats a missing response, a 5xx response or an isTransient GraphQL error as transient', () => {
|
||||
expect(
|
||||
isTransientGraphqlError(new CombinedError({ networkError: new Error('fetch failed') }))
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTransientGraphqlError(
|
||||
new CombinedError({ graphQLErrors: ['Internal Server Error'], response: { status: 503 } })
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTransientGraphqlError(
|
||||
new CombinedError({
|
||||
graphQLErrors: [{ message: 'Database unavailable', extensions: { isTransient: true } }],
|
||||
response: { status: 200 },
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTransientGraphqlError(
|
||||
new CombinedError({ graphQLErrors: ['Build not found'], response: { status: 200 } })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a node-fetch socket failure',
|
||||
new FetchError('request failed', 'system', { code: 'ECONNRESET' } as any),
|
||||
true,
|
||||
],
|
||||
['a node-fetch body size failure', new FetchError('too large', 'max-size'), false],
|
||||
['a body reset', Object.assign(new Error('aborted'), { code: 'ECONNRESET' }), true],
|
||||
[
|
||||
'a body closed early',
|
||||
Object.assign(new Error('closed'), { code: 'ERR_STREAM_PREMATURE_CLOSE' }),
|
||||
true,
|
||||
],
|
||||
['an HTTP error response', new Error('Unexpected response from server (500)'), false],
|
||||
])('classifies %s as a transfer failure or not', (_label, error, expected) => {
|
||||
expect(isTransferFailure(error)).toBe(expected);
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
|
||||
const CONNECT_FAILURE_CODES = new Set([
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
@@ -32,50 +30,3 @@ export function isConnectionInterruptedError(error: unknown): boolean {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Errno code of the error or its causes; undici keeps it on cause, urql on networkError. */
|
||||
export function findErrorCode(error: unknown): string | undefined {
|
||||
let current: unknown = error;
|
||||
while (current instanceof Error) {
|
||||
if ('code' in current && typeof current.code === 'string') {
|
||||
return current.code;
|
||||
}
|
||||
current = current instanceof CombinedError ? current.networkError : current.cause;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findGraphqlErrorCode(error: unknown): string | undefined {
|
||||
if (!(error instanceof CombinedError)) {
|
||||
return undefined;
|
||||
}
|
||||
const code = error.graphQLErrors.at(0)?.extensions.errorCode;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
export function describeError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const code = findErrorCode(error);
|
||||
return code && !message.includes(code) ? `${message} (${code})` : message;
|
||||
}
|
||||
|
||||
export function isTransientGraphqlError(error: CombinedError): boolean {
|
||||
return (
|
||||
Boolean(error.networkError) ||
|
||||
error.response?.status >= 500 ||
|
||||
error.graphQLErrors.some(graphqlError => graphqlError.extensions.isTransient === true)
|
||||
);
|
||||
}
|
||||
|
||||
// node-fetch throws type 'system' for a socket failure before the headers; after them it forwards
|
||||
// ECONNRESET or ERR_STREAM_PREMATURE_CLOSE to the body. HTTP error responses never throw.
|
||||
export function isTransferFailure(error: unknown): boolean {
|
||||
return (
|
||||
(error instanceof Error &&
|
||||
error.name === 'FetchError' &&
|
||||
'type' in error &&
|
||||
error.type === 'system') ||
|
||||
isConnectionInterruptedError(error) ||
|
||||
findErrorCode(error) === 'ERR_STREAM_PREMATURE_CLOSE'
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Datadog } from '../datadog';
|
||||
import { findErrorCode, findGraphqlErrorCode } from './networkErrors';
|
||||
import { promiseRetryWithCondition } from './promiseRetryWithCondition';
|
||||
|
||||
export async function retryOnNetworkFailureAsync<T>(
|
||||
attemptAsync: () => Promise<T>,
|
||||
{
|
||||
operation,
|
||||
signal,
|
||||
isRetryable,
|
||||
}: {
|
||||
operation: string;
|
||||
signal?: AbortSignal;
|
||||
isRetryable: (error: unknown) => boolean;
|
||||
}
|
||||
): Promise<T> {
|
||||
const shouldRetry = (error: unknown): boolean => !signal?.aborted && isRetryable(error);
|
||||
let attempts = 1;
|
||||
let lastError: unknown;
|
||||
const report = (outcome: 'recovered' | 'failed', error: unknown): void => {
|
||||
Datadog.log(`${operation} ${outcome} on attempt ${attempts}`, {
|
||||
event: 'network_retry',
|
||||
operation,
|
||||
outcome,
|
||||
attempts: String(attempts),
|
||||
code: findErrorCode(error) ?? findGraphqlErrorCode(error) ?? 'unknown',
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await promiseRetryWithCondition(
|
||||
async () => {
|
||||
signal?.throwIfAborted();
|
||||
return await attemptAsync();
|
||||
},
|
||||
shouldRetry,
|
||||
{ retries: 5, minTimeout: 1_000, factor: 2 },
|
||||
({ attemptNumber, error }) => {
|
||||
attempts = attemptNumber;
|
||||
lastError = error;
|
||||
}
|
||||
)();
|
||||
if (attempts > 1) {
|
||||
report('recovered', lastError);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (attempts > 1 && !signal?.aborted) {
|
||||
report('failed', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "25.3.0",
|
||||
"version": "25.1.1",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "25.3.0"
|
||||
"@expo/steps": "25.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "25.3.0",
|
||||
"version": "25.1.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "25.3.0",
|
||||
"version": "25.1.1",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 25.3.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.2.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "25.3.0",
|
||||
"version": "25.1.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "25.3.0",
|
||||
"@expo/turtle-spawn": "25.1.1",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
DeviceRunSessionEvent,
|
||||
parseDeviceRunSessionEvents,
|
||||
quoteAndroidShellCommand,
|
||||
validateAndroidLaunchOptions,
|
||||
} from '../deviceRunSession';
|
||||
import { DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '../deviceRunSession';
|
||||
|
||||
describe('parseDeviceRunSessionEvents', () => {
|
||||
it('parses, deduplicates, and sorts events from the dedicated event log', () => {
|
||||
@@ -38,56 +33,6 @@ describe('parseDeviceRunSessionEvents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(quoteAndroidShellCommand, () => {
|
||||
it('quotes only arguments the device shell would split or expand', () => {
|
||||
expect(
|
||||
quoteAndroidShellCommand(['am', 'start', '--es', 'text', 'hello world', "it's", '', '-n'])
|
||||
).toEqual(['am', 'start', '--es', 'text', "'hello world'", "'it'\\''s'", "''", '-n']);
|
||||
});
|
||||
|
||||
it('rejects NUL characters', () => {
|
||||
expect(() => quoteAndroidShellCommand(['am', 'start', 'a\0b'])).toThrow(
|
||||
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(validateAndroidLaunchOptions, () => {
|
||||
it('rejects only commands that are too long without the launch component', () => {
|
||||
const fixedLength = 'am start --es key -n'.length;
|
||||
const launchArgs = (valueLength: number) => ['--es', 'key', 'a'.repeat(valueLength)];
|
||||
|
||||
expect(() =>
|
||||
validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength) })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength + 1) })
|
||||
).toThrow(expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }));
|
||||
});
|
||||
|
||||
it('counts bytes, not characters', () => {
|
||||
// "é" is two bytes in UTF-8 and is quoted, which adds two more.
|
||||
const fixedLength = "am start --es key '' -n".length;
|
||||
const launchArgs = (extraLength: number) => [
|
||||
'--es',
|
||||
'key',
|
||||
'a'.repeat(extraLength) + 'é'.repeat(32_488),
|
||||
];
|
||||
|
||||
expect(fixedLength + 1 + 2 * 32_488).toBe(65_000);
|
||||
expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(1) })).not.toThrow();
|
||||
expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(2) })).toThrow(
|
||||
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the URL to open', () => {
|
||||
expect(() => validateAndroidLaunchOptions({ openUrl: 'exp://example.test/a\0b' })).toThrow(
|
||||
expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createEvent(overrides: Partial<DeviceRunSessionEvent> = {}): string {
|
||||
return JSON.stringify(createEventValue(overrides));
|
||||
}
|
||||
|
||||
@@ -7,3 +7,24 @@ export enum ManagedArtifactType {
|
||||
*/
|
||||
XCODE_BUILD_LOGS = 'XCODE_BUILD_LOGS',
|
||||
}
|
||||
|
||||
export enum GenericArtifactType {
|
||||
ANDROID_APK = 'android-apk',
|
||||
ANDROID_AAB = 'android-aab',
|
||||
|
||||
IOS_SIMULATOR_APP = 'ios-simulator-app',
|
||||
IOS_IPA = 'ios-ipa',
|
||||
|
||||
OTHER = 'other',
|
||||
}
|
||||
|
||||
export const isGenericArtifact = <
|
||||
TSpec extends { type: GenericArtifactType | ManagedArtifactType },
|
||||
>(
|
||||
artifactSpec: TSpec
|
||||
): artifactSpec is TSpec & { type: GenericArtifactType } => {
|
||||
if (Object.values(GenericArtifactType).includes(artifactSpec.type as GenericArtifactType)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -1,7 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { UserError } from './errors';
|
||||
|
||||
/**
|
||||
* Canonical record format for the activity events emitted during a remote device run (simulator)
|
||||
* session. Producers (e.g. the worker and agent-device) write these as NDJSON to the
|
||||
@@ -48,54 +46,3 @@ export function parseDeviceRunSessionEvents(eventLog: string): DeviceRunSessionE
|
||||
(left, right) => new Date(left.ts).getTime() - new Date(right.ts).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
// adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35).
|
||||
export const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000;
|
||||
|
||||
/**
|
||||
* Check Android launch inputs before a session starts. The launch component is only known once the
|
||||
* build is installed, so this rejects only commands that are too long without it; the launch itself
|
||||
* checks the exact command.
|
||||
*/
|
||||
export function validateAndroidLaunchOptions({
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: {
|
||||
launchArgs?: string[];
|
||||
openUrl?: string;
|
||||
}): void {
|
||||
quoteAndroidShellCommand(['am', 'start', ...launchArgs, '-n']);
|
||||
if (openUrl) {
|
||||
quoteAndroidShellCommand(['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `adb shell` joins its arguments with spaces and the device shell parses the result again, so
|
||||
* each argument is quoted to reach the command as one literal word.
|
||||
*/
|
||||
export function quoteAndroidShellCommand(command: string[]): string[] {
|
||||
if (command.some(argument => argument.includes('\0'))) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Android "launch_args" and "open_url" cannot contain NUL characters.'
|
||||
);
|
||||
}
|
||||
const quotedCommand = command.map(quoteAndroidShellArgument);
|
||||
if (
|
||||
new TextEncoder().encode(quotedCommand.join(' ')).byteLength > ANDROID_SHELL_COMMAND_MAX_BYTES
|
||||
) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`The Android launch command is longer than ${ANDROID_SHELL_COMMAND_MAX_BYTES} bytes after shell quoting. Shorten "launch_args" or "open_url".`
|
||||
);
|
||||
}
|
||||
return quotedCommand;
|
||||
}
|
||||
|
||||
function quoteAndroidShellArgument(argument: string): string {
|
||||
if (/^[\w@%+=:,./-]+$/.test(argument)) {
|
||||
return argument;
|
||||
}
|
||||
return `'${argument.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
@@ -1,20 +1,5 @@
|
||||
# eas-cli
|
||||
|
||||
## 25.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add a Free plan build limit checkout experiment to `eas build` that offers to open the Starter plan checkout. ([#4633](https://github.com/expo/eas-cli/pull/4633) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add a `--system-image` flag to `eas simulator:start` to select the Android SDK system image for the emulator. ([#4500](https://github.com/expo/eas-cli/pull/4500) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
|
||||
## 25.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Add experimentation client. ([#4558](https://github.com/expo/eas-cli/pull/4558) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add a Free plan build limit guidance experiment for coding agents to `eas build`. ([#4631](https://github.com/expo/eas-cli/pull/4631) by [@williamgrosset](https://github.com/williamgrosset))
|
||||
- Add `eas simulator:control` to run serve-sim actions, with file upload and download, on a remote iOS simulator session. ([#4641](https://github.com/expo/eas-cli/pull/4641) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## 25.1.1
|
||||
|
||||
No changes in this release.
|
||||
|
||||
+162
-282
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "25.3.0",
|
||||
"version": "25.1.1",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,12 +48,12 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "25.3.0",
|
||||
"@expo/eas-json": "25.3.0",
|
||||
"@expo/eas-build-job": "25.1.1",
|
||||
"@expo/eas-json": "25.1.1",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/experimentation": "0.0.5",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "25.3.0",
|
||||
"@expo/logger": "25.1.1",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -65,7 +65,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "25.3.0",
|
||||
"@expo/steps": "25.1.1",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -185,7 +185,6 @@
|
||||
],
|
||||
"bin": "eas",
|
||||
"commands": "./build/commands",
|
||||
"helpClass": "./build/help",
|
||||
"plugins": [
|
||||
"@oclif/plugin-autocomplete",
|
||||
"@expo/plugin-help",
|
||||
@@ -252,7 +251,7 @@
|
||||
},
|
||||
"update": {
|
||||
"node": {
|
||||
"version": "22.23.4"
|
||||
"version": "22.23.3"
|
||||
},
|
||||
"s3": {
|
||||
"templates": {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Config, Help } from '@oclif/core';
|
||||
|
||||
import SimulatorControl from '../commands/simulator/control';
|
||||
import EasHelp from '../help';
|
||||
|
||||
describe(EasHelp, () => {
|
||||
const config = new Config({ root: __dirname });
|
||||
let run: jest.SpyInstance;
|
||||
let showHelp: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
run = jest.spyOn(SimulatorControl, 'run').mockResolvedValue(Promise.resolve());
|
||||
showHelp = jest.spyOn(Help.prototype, 'showHelp').mockResolvedValue();
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it.each([
|
||||
['simulator:control', 'rotate', '--help'],
|
||||
['sim:control', 'screenshot.capture', '-h', '--id', 'session-123'],
|
||||
['simulator:control', '--id', 'session-123', '--help', 'rotate'],
|
||||
])('routes action help through the command: %j', async (...argv) => {
|
||||
await new EasHelp(config).showHelp(argv);
|
||||
expect(run).toHaveBeenCalledWith(argv.slice(1), config);
|
||||
expect(showHelp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['simulator:control', '--help'],
|
||||
['sim:control', '-h'],
|
||||
['build', '--help'],
|
||||
['simulator:control', 'clipboard.write', '--', '--help'],
|
||||
])('preserves regular help and literal values: %j', async (...argv) => {
|
||||
await new EasHelp(config).showHelp(argv);
|
||||
expect(showHelp).toHaveBeenCalledWith(argv);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,6 @@ export enum BuildEvent {
|
||||
BUILD_REQUEST_ATTEMPT = 'build cli build request attempt',
|
||||
BUILD_REQUEST_SUCCESS = 'build cli build request success',
|
||||
BUILD_REQUEST_FAIL = 'build cli build request fail',
|
||||
FREE_TIER_LIMIT_CHECKOUT_PROMPT = 'build cli free tier limit checkout prompt',
|
||||
|
||||
BUILD_STATUS_COMMAND = 'build cli build status',
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ function hasBillingPermission(permissions: readonly Permission[]): boolean {
|
||||
* Whether the viewer can manage billing on the account with the credential used for this command.
|
||||
* The scopes of a scoped credential cap `effectivePermissions`.
|
||||
*/
|
||||
export function canManageBilling(account: { viewerUserPermission2: ViewerPermissions }): boolean {
|
||||
function canManageBilling(account: { viewerUserPermission2: ViewerPermissions }): boolean {
|
||||
return hasBillingPermission(account.viewerUserPermission2.effectivePermissions);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,471 +0,0 @@
|
||||
import type { ExperimentationDefinition } from '@expo/experimentation';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { getAgentTelemetryContext } from '../../analytics/agent';
|
||||
import { BillingClient } from '../../billing/billingClient';
|
||||
import { openOrPrintUrlAsync } from '../../billing/openUrl';
|
||||
import { ExperimentOverrides } from '../../experimentation/ExperimentOverrides';
|
||||
import {
|
||||
EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
ExperimentationClient,
|
||||
createLoggerConfig,
|
||||
} from '../../experimentation/ExperimentationClient';
|
||||
import { LazyExperimentationClient } from '../../experimentation/LazyExperimentationClient';
|
||||
import { createMockAnalytics, findUnitsByValue } from '../../experimentation/__tests__/fixtures';
|
||||
import { getMockViewerUserPermission } from '../../__tests__/viewerUserPermission';
|
||||
import { Permission, Role } from '../../graphql/generated';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { confirmAsync } from '../../prompts';
|
||||
import { Actor } from '../../user/User';
|
||||
import {
|
||||
FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE,
|
||||
FreeTierLimitCheckoutExperiment,
|
||||
getFreeTierLimitCheckoutExperiment,
|
||||
isEligibleForFreeTierLimitCheckout,
|
||||
maybeOfferFreeTierLimitCheckoutAsync,
|
||||
resolveFreeTierLimitCheckoutVariantAsync,
|
||||
} from '../freeTierLimitCheckout';
|
||||
|
||||
jest.mock('../../analytics/agent');
|
||||
jest.mock('../../billing/billingClient');
|
||||
jest.mock('../../billing/openUrl');
|
||||
jest.mock('../../ora');
|
||||
jest.mock('../../prompts');
|
||||
|
||||
const EXPERIMENT_NAME = 'cli-build-free-tier-limit-checkout';
|
||||
const ACCOUNT_NAME = 'acme';
|
||||
const AUTHENTICATION_INFO = { accessToken: 'token', sessionSecret: null } as const;
|
||||
const ANALYTICS_EVENT_PROPERTIES = { tracking_id: 'tracking-id', platform: Platform.ANDROID };
|
||||
const LIMIT_MESSAGE = 'Error 1\nRun eas billing:subscribe starter --account acme to upgrade to the Starter plan.';
|
||||
|
||||
/** Every account is in the experiment: 50% control, 50% treatment. */
|
||||
const ACCOUNT_DEFINITION: ExperimentationDefinition = {
|
||||
experiments: [
|
||||
{
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
paramDefinitions: {
|
||||
variant: { type: 'uniform_choice', choices: ['control', 'treatment'] },
|
||||
},
|
||||
exposureLoggerType: 'rudderstack',
|
||||
},
|
||||
],
|
||||
namespaces: [
|
||||
{
|
||||
name: FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE,
|
||||
numSegments: 1000,
|
||||
segmentExperimentSetupDefinitions: [
|
||||
{
|
||||
method: 'ADD',
|
||||
name: `${EXPERIMENT_NAME} full`,
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
numSegments: 1000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function createClient(definition: ExperimentationDefinition = ACCOUNT_DEFINITION): {
|
||||
client: ExperimentationClient;
|
||||
analytics: ReturnType<typeof createMockAnalytics>;
|
||||
} {
|
||||
const analytics = createMockAnalytics();
|
||||
const client = new ExperimentationClient(
|
||||
{
|
||||
user: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
account: definition,
|
||||
device: EMPTY_EXPERIMENTATION_DEFINITION,
|
||||
},
|
||||
{ user: null, device: null },
|
||||
ExperimentOverrides.empty(),
|
||||
createLoggerConfig(analytics)
|
||||
);
|
||||
return { client, analytics };
|
||||
}
|
||||
|
||||
/** Probes a throwaway client, so the exposures it logs do not affect the client under test. */
|
||||
function findAccountIdsByVariant(): Map<string, string> {
|
||||
const { client } = createClient();
|
||||
return findUnitsByValue(
|
||||
accountId =>
|
||||
client.getAccountNamespace(FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE, { accountId }),
|
||||
'variant',
|
||||
'control',
|
||||
['control', 'treatment']
|
||||
);
|
||||
}
|
||||
|
||||
function createOverriddenClient(variant: string): ExperimentationClient {
|
||||
return ExperimentationClient.createDisabled(
|
||||
new ExperimentOverrides(
|
||||
new Map([[FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE, new Map([['variant', variant]])]])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function account(
|
||||
id: string,
|
||||
role: Parameters<typeof getMockViewerUserPermission>[0],
|
||||
{ effectivePermissions }: { effectivePermissions?: Permission[] } = {}
|
||||
): Actor['accounts'][number] {
|
||||
const viewerUserPermission2 = getMockViewerUserPermission(role);
|
||||
return {
|
||||
id,
|
||||
name: ACCOUNT_NAME,
|
||||
viewerUserPermission2: {
|
||||
...viewerUserPermission2,
|
||||
effectivePermissions: effectivePermissions ?? viewerUserPermission2.effectivePermissions,
|
||||
},
|
||||
} as Actor['accounts'][number];
|
||||
}
|
||||
|
||||
function createExperiment(
|
||||
accountId: string,
|
||||
{
|
||||
client = createClient().client,
|
||||
accounts = [account(accountId, Role.Admin)],
|
||||
nonInteractive = false,
|
||||
runFromCI = false,
|
||||
analytics = createMockAnalytics(),
|
||||
}: {
|
||||
client?: ExperimentationClient;
|
||||
accounts?: Actor['accounts'];
|
||||
nonInteractive?: boolean;
|
||||
runFromCI?: boolean;
|
||||
analytics?: ReturnType<typeof createMockAnalytics>;
|
||||
} = {}
|
||||
): FreeTierLimitCheckoutExperiment {
|
||||
return {
|
||||
experimentation: LazyExperimentationClient.fromClient(client),
|
||||
authenticationInfo: AUTHENTICATION_INFO,
|
||||
analytics,
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
actor: { id: 'actor-id', accounts } as Actor,
|
||||
accountId,
|
||||
accountName: ACCOUNT_NAME,
|
||||
nonInteractive,
|
||||
runFromCI,
|
||||
};
|
||||
}
|
||||
|
||||
const accountIdsByVariant = findAccountIdsByVariant();
|
||||
const treatmentAccountId = accountIdsByVariant.get('treatment')!;
|
||||
const controlAccountId = accountIdsByVariant.get('control')!;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe(getFreeTierLimitCheckoutExperiment, () => {
|
||||
const ctx = {
|
||||
analytics: createMockAnalytics(),
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
user: { id: 'actor-id', accounts: [] } as unknown as Actor,
|
||||
accountId: 'account-id',
|
||||
accountName: ACCOUNT_NAME,
|
||||
nonInteractive: false,
|
||||
runFromCI: false,
|
||||
};
|
||||
|
||||
it('returns the handle when the command has an experimentation client and auth info', () => {
|
||||
const experimentation = new LazyExperimentationClient(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
getFreeTierLimitCheckoutExperiment({
|
||||
...ctx,
|
||||
experimentation,
|
||||
authenticationInfo: AUTHENTICATION_INFO,
|
||||
})
|
||||
).toEqual({
|
||||
experimentation,
|
||||
authenticationInfo: AUTHENTICATION_INFO,
|
||||
analytics: ctx.analytics,
|
||||
analyticsEventProperties: ANALYTICS_EVENT_PROPERTIES,
|
||||
actor: ctx.user,
|
||||
accountId: 'account-id',
|
||||
accountName: ACCOUNT_NAME,
|
||||
nonInteractive: false,
|
||||
runFromCI: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined when the command has no experimentation client', () => {
|
||||
expect(
|
||||
getFreeTierLimitCheckoutExperiment({ ...ctx, authenticationInfo: AUTHENTICATION_INFO })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the command has no auth info', () => {
|
||||
expect(
|
||||
getFreeTierLimitCheckoutExperiment({
|
||||
...ctx,
|
||||
experimentation: new LazyExperimentationClient(async () => createClient().client),
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe(isEligibleForFreeTierLimitCheckout, () => {
|
||||
it.each([
|
||||
['admins', Role.Admin],
|
||||
['owners', Role.Owner],
|
||||
] as const)('accepts %s at an interactive terminal', (_label, role) => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(
|
||||
createExperiment('account-id', { accounts: [account('account-id', role)] })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects admins whose credential scopes do not allow managing billing', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(
|
||||
createExperiment('account-id', {
|
||||
accounts: [
|
||||
account('account-id', Role.Admin, { effectivePermissions: [Permission.View] }),
|
||||
],
|
||||
})
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['developers', Role.Developer],
|
||||
['view-only members', Role.ViewOnly],
|
||||
] as const)('rejects %s', (_label, role) => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(
|
||||
createExperiment('account-id', { accounts: [account('account-id', role)] })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects actors who are not a member of the account', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(
|
||||
createExperiment('account-id', { accounts: [account('other-account-id', Role.Admin)] })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-interactive runs', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(createExperiment('account-id', { nonInteractive: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects runs from CI', () => {
|
||||
expect(
|
||||
isEligibleForFreeTierLimitCheckout(createExperiment('account-id', { runFromCI: true }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects runs driven by a coding agent', () => {
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
|
||||
expect(isEligibleForFreeTierLimitCheckout(createExperiment('account-id'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveFreeTierLimitCheckoutVariantAsync, () => {
|
||||
it('does not load the client for ineligible runs', async () => {
|
||||
const loadAsync = jest.fn(async () => createClient().client);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitCheckoutVariantAsync({
|
||||
...createExperiment(treatmentAccountId, { nonInteractive: true }),
|
||||
experimentation: new LazyExperimentationClient(loadAsync),
|
||||
})
|
||||
).toBe('control');
|
||||
expect(loadAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the account variant and logs the exposure once', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
const experiment = createExperiment(treatmentAccountId, { client });
|
||||
|
||||
expect(await resolveFreeTierLimitCheckoutVariantAsync(experiment)).toBe('treatment');
|
||||
expect(await resolveFreeTierLimitCheckoutVariantAsync(experiment)).toBe('treatment');
|
||||
|
||||
expect(analytics.logEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'treatment',
|
||||
unit: treatmentAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs the exposure for control accounts too', async () => {
|
||||
const { client, analytics } = createClient();
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitCheckoutVariantAsync(createExperiment(controlAccountId, { client }))
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).toHaveBeenCalledWith('Experiment Viewed', {
|
||||
experimentName: EXPERIMENT_NAME,
|
||||
variationName: 'control',
|
||||
unit: controlAccountId,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns control and logs no exposure when the namespace is missing from the config', async () => {
|
||||
const { client, analytics } = createClient(EMPTY_EXPERIMENTATION_DEFINITION);
|
||||
|
||||
expect(
|
||||
await resolveFreeTierLimitCheckoutVariantAsync(
|
||||
createExperiment(treatmentAccountId, { client })
|
||||
)
|
||||
).toBe('control');
|
||||
expect(analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(maybeOfferFreeTierLimitCheckoutAsync, () => {
|
||||
const createCheckoutSessionAsync = jest.fn();
|
||||
const spinner = { start: jest.fn(), stop: jest.fn(), fail: jest.fn() };
|
||||
let logErrorSpy: jest.SpyInstance;
|
||||
let logLogSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
spinner.start.mockReturnValue(spinner);
|
||||
jest.mocked(ora).mockReturnValue(spinner as any);
|
||||
jest
|
||||
.mocked(BillingClient)
|
||||
.mockImplementation(() => ({ createCheckoutSessionAsync }) as unknown as BillingClient);
|
||||
logErrorSpy = jest.spyOn(Log, 'error').mockImplementation(() => {});
|
||||
logLogSpy = jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
function treatmentExperiment(
|
||||
options: Parameters<typeof createExperiment>[1] = {}
|
||||
): FreeTierLimitCheckoutExperiment {
|
||||
return createExperiment('account-id', { client: createOverriddenClient('treatment'), ...options });
|
||||
}
|
||||
|
||||
it('does nothing when the command has no experiment handle', async () => {
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, undefined)).toBe(false);
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing in the control arm', async () => {
|
||||
const experiment = createExperiment('account-id', { client: createOverriddenClient('control') });
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(false);
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing for ineligible runs even in the treatment arm', async () => {
|
||||
const experiment = treatmentExperiment({ nonInteractive: true });
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(false);
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints the error, then asks with No as the default', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, treatmentExperiment())).toBe(
|
||||
true
|
||||
);
|
||||
|
||||
expect(logErrorSpy).toHaveBeenCalledWith(LIMIT_MESSAGE);
|
||||
expect(confirmAsync).toHaveBeenCalledWith(
|
||||
{ message: 'Open the Starter plan checkout in your browser now?', initial: false },
|
||||
{ onCancel: expect.any(Function) }
|
||||
);
|
||||
expect(logErrorSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(confirmAsync).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the process alive on Ctrl+C so the build error still reports', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(undefined as unknown as boolean);
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
const [, options] = jest.mocked(confirmAsync).mock.calls[0];
|
||||
expect(options?.onCancel?.({} as any, {})).toBe(false);
|
||||
expect(BillingClient).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier limit checkout prompt',
|
||||
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'cancel', checkout_created: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('creates nothing when the person declines', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
expect(BillingClient).not.toHaveBeenCalled();
|
||||
expect(openOrPrintUrlAsync).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier limit checkout prompt',
|
||||
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'no', checkout_created: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('opens the Starter checkout when the person accepts', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs_1', url: 'https://checkout.test/cs_1' });
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
expect(BillingClient).toHaveBeenCalledWith(AUTHENTICATION_INFO);
|
||||
expect(createCheckoutSessionAsync).toHaveBeenCalledWith('account-id', 'STARTER');
|
||||
expect(spinner.stop).toHaveBeenCalled();
|
||||
expect(spinner.fail).not.toHaveBeenCalled();
|
||||
expect(openOrPrintUrlAsync).toHaveBeenCalledWith('https://checkout.test/cs_1', {
|
||||
label: 'Checkout page',
|
||||
open: true,
|
||||
});
|
||||
expect(logLogSpy).not.toHaveBeenCalled();
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier limit checkout prompt',
|
||||
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'yes', checkout_created: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the billing link when the checkout session request fails', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
createCheckoutSessionAsync.mockRejectedValue(new Error('network down'));
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
expect(spinner.fail).toHaveBeenCalledWith('Failed to create a checkout session');
|
||||
expect(openOrPrintUrlAsync).not.toHaveBeenCalled();
|
||||
expect(logLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/accounts/${ACCOUNT_NAME}/settings/billing`)
|
||||
);
|
||||
expect(experiment.analytics.logEvent).toHaveBeenCalledWith(
|
||||
'build cli free tier limit checkout prompt',
|
||||
{ ...ANALYTICS_EVENT_PROPERTIES, answer: 'yes', checkout_created: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the billing link when the checkout session has no URL', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
createCheckoutSessionAsync.mockResolvedValue({ id: 'cs_1', url: null });
|
||||
const experiment = treatmentExperiment();
|
||||
|
||||
expect(await maybeOfferFreeTierLimitCheckoutAsync(LIMIT_MESSAGE, experiment)).toBe(true);
|
||||
|
||||
expect(spinner.fail).toHaveBeenCalledWith('Failed to create a checkout session');
|
||||
expect(openOrPrintUrlAsync).not.toHaveBeenCalled();
|
||||
expect(logLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/accounts/${ACCOUNT_NAME}/settings/billing`)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -24,27 +24,16 @@ import {
|
||||
RequestValidationError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
} from '../errors';
|
||||
import {
|
||||
FreeTierLimitCheckoutExperiment,
|
||||
maybeOfferFreeTierLimitCheckoutAsync,
|
||||
} from '../freeTierLimitCheckout';
|
||||
import {
|
||||
FREE_TIER_LIMIT_AGENT_GUIDANCE_EXPERIMENT_NAMESPACE,
|
||||
FreeTierLimitAgentGuidanceExperiment,
|
||||
} from '../freeTierLimitGuidance';
|
||||
|
||||
jest.mock('../../analytics/agent');
|
||||
jest.mock('../freeTierLimitCheckout', () => ({
|
||||
...jest.requireActual('../freeTierLimitCheckout'),
|
||||
maybeOfferFreeTierLimitCheckoutAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const CHECKOUT_EXPERIMENT = { accountId: 'account-id' } as FreeTierLimitCheckoutExperiment;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(getAgentTelemetryContext).mockReturnValue(null);
|
||||
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
/** An experiment handle whose client is forced to the given variant through an override. */
|
||||
@@ -105,15 +94,11 @@ const getGraphQLError = (message: string, errorCode: string): GraphQLError => {
|
||||
|
||||
const assertReThrownError = (
|
||||
caughtError: CombinedError | Error,
|
||||
expectedType: new (...args: any[]) => Error,
|
||||
expectedMessage: string = 'Error 1',
|
||||
{ messageAlreadyPrinted = false }: { messageAlreadyPrinted?: boolean } = {}
|
||||
expectedType: typeof EasCommandError,
|
||||
expectedMessage: string = 'Error 1'
|
||||
): void => {
|
||||
expect(caughtError).toBeInstanceOf(expectedType);
|
||||
expect((caughtError as Error).message).toEqual(expectedMessage);
|
||||
expect((caughtError as EasCommandError).messageAlreadyPrinted ?? false).toBe(
|
||||
messageAlreadyPrinted
|
||||
);
|
||||
};
|
||||
|
||||
describe(Build.name, () => {
|
||||
@@ -464,153 +449,6 @@ describe(Build.name, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for free tier limit errors in the checkout experiment', () => {
|
||||
it('offers the checkout with the limit message and marks the message as printed', async () => {
|
||||
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(true);
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
const expectedMessage =
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.';
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
|
||||
expectedMessage,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
expectedMessage,
|
||||
{ messageAlreadyPrinted: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('offers the checkout for the iOS limit error too', async () => {
|
||||
jest.mocked(maybeOfferFreeTierLimitCheckoutAsync).mockResolvedValue(true);
|
||||
const platform = Platform.IOS;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
const expectedMessage =
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.';
|
||||
|
||||
const handleBuildRequestErrorThrownError =
|
||||
await getErrorAsync<EasBuildFreeTierIosLimitExceededError>(async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
|
||||
expectedMessage,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierIosLimitExceededError,
|
||||
expectedMessage,
|
||||
{ messageAlreadyPrinted: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the message for the command to print when nothing was offered', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierLimitExceededError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledTimes(1);
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierLimitExceededError,
|
||||
'Error 1\nRun eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
|
||||
{ messageAlreadyPrinted: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the agent guidance along when both experiments are present', async () => {
|
||||
jest
|
||||
.mocked(getAgentTelemetryContext)
|
||||
.mockReturnValue({ id: 'claude-code', sessionId: 'session-1' });
|
||||
const platform = Platform.ANDROID;
|
||||
const accountName = 'test-account';
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
await getErrorAsync(async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
accountName,
|
||||
createExperiment('treatment'),
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).toHaveBeenCalledWith(
|
||||
[
|
||||
'Error 1',
|
||||
'Run eas billing:subscribe starter --account test-account to upgrade to the Starter plan.',
|
||||
'Fix: Human required: Android builds for this account are blocked until the Free plan resets. Upgrading costs money, so an account owner or admin decides.',
|
||||
].join('\n'),
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
});
|
||||
|
||||
it('does not offer the checkout for other server-side errors', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
const graphQLError = getGraphQLError('Error 1', 'EAS_BUILD_FREE_TIER_DISABLED');
|
||||
const error = new CombinedError({ graphQLErrors: [graphQLError] });
|
||||
|
||||
const handleBuildRequestErrorThrownError = await getErrorAsync<EasBuildFreeTierDisabledError>(
|
||||
async () => {
|
||||
await handleBuildRequestErrorAsync(
|
||||
error,
|
||||
platform,
|
||||
undefined,
|
||||
undefined,
|
||||
CHECKOUT_EXPERIMENT
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
expect(maybeOfferFreeTierLimitCheckoutAsync).not.toHaveBeenCalled();
|
||||
assertReThrownError(
|
||||
handleBuildRequestErrorThrownError,
|
||||
EasBuildFreeTierDisabledError,
|
||||
'Error 1'
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('for EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED', () => {
|
||||
it('throws correct EasCommandError subclass', async () => {
|
||||
const platform = Platform.ANDROID;
|
||||
|
||||
@@ -28,11 +28,6 @@ import {
|
||||
RequestValidationError,
|
||||
TurtleDeprecatedJobFormatError,
|
||||
} from './errors';
|
||||
import {
|
||||
FreeTierLimitCheckoutExperiment,
|
||||
getFreeTierLimitCheckoutExperiment,
|
||||
maybeOfferFreeTierLimitCheckoutAsync,
|
||||
} from './freeTierLimitCheckout';
|
||||
import {
|
||||
FreeTierLimitAgentGuidanceExperiment,
|
||||
getFreeTierLimitAgentGuidanceExperiment,
|
||||
@@ -56,9 +51,9 @@ import {
|
||||
maybeWarnAboutProjectTarballSize,
|
||||
reviewAndCommitChangesAsync,
|
||||
} from './utils/repository';
|
||||
import { getAccountBillingUrl } from './utils/url';
|
||||
import { BuildEvent } from '../analytics/AnalyticsManager';
|
||||
import { withAnalyticsAsync } from '../analytics/common';
|
||||
import { getExpoWebsiteBaseUrl } from '../api';
|
||||
import { formatStarterSubscribeCommand } from '../billing/plans';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
@@ -233,8 +228,7 @@ export async function prepareBuildRequestForPlatformAsync<
|
||||
error,
|
||||
job.platform,
|
||||
ctx.accountName,
|
||||
getFreeTierLimitAgentGuidanceExperiment(ctx),
|
||||
getFreeTierLimitCheckoutExperiment(ctx)
|
||||
getFreeTierLimitAgentGuidanceExperiment(ctx)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -260,8 +254,7 @@ export async function handleBuildRequestErrorAsync(
|
||||
error: any,
|
||||
platform: Platform,
|
||||
accountName?: string,
|
||||
freeTierLimitAgentGuidanceExperiment?: FreeTierLimitAgentGuidanceExperiment,
|
||||
freeTierLimitCheckoutExperiment?: FreeTierLimitCheckoutExperiment
|
||||
freeTierLimitAgentGuidanceExperiment?: FreeTierLimitAgentGuidanceExperiment
|
||||
): Promise<never> {
|
||||
logBuildRequestErrorDebugInfo(error);
|
||||
|
||||
@@ -277,19 +270,15 @@ export async function handleBuildRequestErrorAsync(
|
||||
const isFreeTierLimitError =
|
||||
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_LIMIT_EXCEEDED' ||
|
||||
graphQLErrorCode === 'EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED';
|
||||
if (!isFreeTierLimitError) {
|
||||
throw new ErrorClass(message);
|
||||
}
|
||||
const limitMessage = await maybeAppendFreeTierLimitAgentGuidanceAsync(
|
||||
`${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`,
|
||||
platform,
|
||||
freeTierLimitAgentGuidanceExperiment
|
||||
throw new ErrorClass(
|
||||
isFreeTierLimitError
|
||||
? await maybeAppendFreeTierLimitAgentGuidanceAsync(
|
||||
`${message}\nRun ${formatStarterSubscribeCommand(accountName)} to upgrade to the Starter plan.`,
|
||||
platform,
|
||||
freeTierLimitAgentGuidanceExperiment
|
||||
)
|
||||
: message
|
||||
);
|
||||
const messageAlreadyPrinted = await maybeOfferFreeTierLimitCheckoutAsync(
|
||||
limitMessage,
|
||||
freeTierLimitCheckoutExperiment
|
||||
);
|
||||
throw new ErrorClass(limitMessage, { messageAlreadyPrinted });
|
||||
} else if (graphQLErrorCode === 'EAS_BUILD_DOWN_FOR_MAINTENANCE') {
|
||||
throw new EasBuildDownForMaintenanceError(
|
||||
`EAS Build is down for maintenance. Try again later. Check ${link(
|
||||
@@ -706,7 +695,7 @@ async function handleSingleBuildProgressAsync(
|
||||
} else {
|
||||
updateSpinnerText(spinner, {
|
||||
text: `Build concurrency limit reached for your account. Build will enter queue once a concurrency becomes available. Add additional concurrencies at ${link(
|
||||
getAccountBillingUrl(accountName)
|
||||
formatAccountBillingUrl(accountName)
|
||||
)}.`,
|
||||
});
|
||||
}
|
||||
@@ -729,7 +718,7 @@ async function handleSingleBuildProgressAsync(
|
||||
if (build.priority !== BuildPriority.High) {
|
||||
Log.newLine();
|
||||
Log.log('Start builds sooner in the priority queue.');
|
||||
Log.log(`Sign up for a paid plan at ${link(getAccountBillingUrl(accountName))}`);
|
||||
Log.log(`Sign up for a paid plan at ${link(formatAccountBillingUrl(accountName))}`);
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
@@ -921,6 +910,10 @@ function formatEstimatedWaitTime(estimatedWaitTimeLeftSeconds: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatAccountBillingUrl(accountName: string): string {
|
||||
return new URL(`/accounts/${accountName}/settings/billing`, getExpoWebsiteBaseUrl()).toString();
|
||||
}
|
||||
|
||||
async function computeAndMaybeUploadRuntimeAndFingerprintMetadataAsync<T extends Platform>(
|
||||
ctx: BuildContext<T>
|
||||
): Promise<{
|
||||
|
||||
@@ -12,7 +12,6 @@ import { LazyExperimentationClient } from '../experimentation/LazyExperimentatio
|
||||
import { Target } from '../credentials/ios/types';
|
||||
import { BuildResourceClass } from '../graphql/generated';
|
||||
import { GradleBuildContext } from '../project/android/gradle';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
|
||||
import { XcodeBuildContext } from '../project/ios/scheme';
|
||||
import { Actor } from '../user/User';
|
||||
@@ -69,5 +68,4 @@ export interface BuildContext<T extends Platform> {
|
||||
whatToTest?: string;
|
||||
env: Env;
|
||||
experimentation?: LazyExperimentationClient;
|
||||
authenticationInfo?: LoggedInAuthenticationInfo;
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import { LazyExperimentationClient } from '../experimentation/LazyExperimentatio
|
||||
import { CustomBuildConfigMetadata } from '../project/customBuildConfig';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../project/projectUtils';
|
||||
import { resolveWorkflowAsync } from '../project/workflow';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import { Actor } from '../user/User';
|
||||
import { Client } from '../vcs/vcs';
|
||||
|
||||
@@ -49,7 +48,6 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
whatToTest,
|
||||
env,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
}: {
|
||||
buildProfileName: string;
|
||||
buildProfile: BuildProfile<T>;
|
||||
@@ -75,7 +73,6 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
whatToTest?: string;
|
||||
env: Env;
|
||||
experimentation?: LazyExperimentationClient;
|
||||
authenticationInfo?: LoggedInAuthenticationInfo;
|
||||
}): Promise<BuildContext<T>> {
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
|
||||
env,
|
||||
@@ -166,7 +163,6 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
env,
|
||||
whatToTest,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
};
|
||||
if (platform === Platform.ANDROID) {
|
||||
const common = commonContext as CommonContext<Platform.ANDROID>;
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { getAccountBillingUrl } from './utils/url';
|
||||
import { getAgentTelemetryContext } from '../analytics/agent';
|
||||
import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager';
|
||||
import { BillingClient } from '../billing/billingClient';
|
||||
import { openOrPrintUrlAsync } from '../billing/openUrl';
|
||||
import { SUBSCRIBABLE_PLANS } from '../billing/plans';
|
||||
import { canManageBilling } from '../billing/resolveAccount';
|
||||
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
|
||||
import Log, { link } from '../log';
|
||||
import { ora } from '../ora';
|
||||
import { confirmAsync } from '../prompts';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import { Actor } from '../user/User';
|
||||
|
||||
export const FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE = 'cli-build-free-tier-limit-checkout';
|
||||
|
||||
export type FreeTierLimitCheckoutVariant = 'control' | 'treatment';
|
||||
|
||||
export type FreeTierLimitCheckoutPromptAnswer = 'yes' | 'no' | 'cancel';
|
||||
|
||||
export type FreeTierLimitCheckoutExperiment = {
|
||||
experimentation: LazyExperimentationClient;
|
||||
authenticationInfo: LoggedInAuthenticationInfo;
|
||||
analytics: Analytics;
|
||||
analyticsEventProperties: AnalyticsEventProperties;
|
||||
actor: Actor;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
nonInteractive: boolean;
|
||||
runFromCI: boolean;
|
||||
};
|
||||
|
||||
const STARTER_PLAN = SUBSCRIBABLE_PLANS.starter;
|
||||
|
||||
export function getFreeTierLimitCheckoutExperiment(ctx: {
|
||||
experimentation?: LazyExperimentationClient;
|
||||
authenticationInfo?: LoggedInAuthenticationInfo;
|
||||
analytics: Analytics;
|
||||
analyticsEventProperties: AnalyticsEventProperties;
|
||||
user: Actor;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
nonInteractive: boolean;
|
||||
runFromCI: boolean;
|
||||
}): FreeTierLimitCheckoutExperiment | undefined {
|
||||
if (!ctx.experimentation || !ctx.authenticationInfo) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
experimentation: ctx.experimentation,
|
||||
authenticationInfo: ctx.authenticationInfo,
|
||||
analytics: ctx.analytics,
|
||||
analyticsEventProperties: ctx.analyticsEventProperties,
|
||||
actor: ctx.user,
|
||||
accountId: ctx.accountId,
|
||||
accountName: ctx.accountName,
|
||||
nonInteractive: ctx.nonInteractive,
|
||||
runFromCI: ctx.runFromCI,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a person at an interactive terminal who can manage billing for the account is eligible.
|
||||
* Non-interactive runs, CI, coding agents, and credentials that cannot manage billing are not.
|
||||
*/
|
||||
export function isEligibleForFreeTierLimitCheckout({
|
||||
actor,
|
||||
accountId,
|
||||
nonInteractive,
|
||||
runFromCI,
|
||||
}: FreeTierLimitCheckoutExperiment): boolean {
|
||||
if (nonInteractive || runFromCI || getAgentTelemetryContext()) {
|
||||
return false;
|
||||
}
|
||||
const account = actor.accounts.find(candidate => candidate.id === accountId);
|
||||
return account !== undefined && canManageBilling(account);
|
||||
}
|
||||
|
||||
export async function resolveFreeTierLimitCheckoutVariantAsync(
|
||||
experiment: FreeTierLimitCheckoutExperiment
|
||||
): Promise<FreeTierLimitCheckoutVariant> {
|
||||
if (!isEligibleForFreeTierLimitCheckout(experiment)) {
|
||||
return 'control';
|
||||
}
|
||||
const client = await experiment.experimentation.getClientAsync();
|
||||
return client
|
||||
.getAccountNamespace(FREE_TIER_LIMIT_CHECKOUT_EXPERIMENT_NAMESPACE, {
|
||||
accountId: experiment.accountId,
|
||||
})
|
||||
.getParam<FreeTierLimitCheckoutVariant>('variant', 'control');
|
||||
}
|
||||
|
||||
async function createCheckoutUrlAsync({
|
||||
authenticationInfo,
|
||||
accountId,
|
||||
}: FreeTierLimitCheckoutExperiment): Promise<string | null> {
|
||||
const spinner = ora(`Creating a checkout session for the ${STARTER_PLAN.label} plan`).start();
|
||||
try {
|
||||
const session = await new BillingClient(authenticationInfo).createCheckoutSessionAsync(
|
||||
accountId,
|
||||
STARTER_PLAN.planType
|
||||
);
|
||||
if (!session.url) {
|
||||
throw new Error('The checkout session did not include a URL.');
|
||||
}
|
||||
spinner.stop();
|
||||
return session.url;
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to create a checkout session');
|
||||
Log.debug(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In the treatment arm, prints the limit error and offers to open the Starter plan checkout.
|
||||
*/
|
||||
export async function maybeOfferFreeTierLimitCheckoutAsync(
|
||||
message: string,
|
||||
experiment: FreeTierLimitCheckoutExperiment | undefined
|
||||
): Promise<boolean> {
|
||||
if (!experiment || (await resolveFreeTierLimitCheckoutVariantAsync(experiment)) !== 'treatment') {
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.error(message);
|
||||
// Ctrl+C resolves to no answer instead of exiting, so the build error below still reports.
|
||||
const confirmed: boolean | undefined = await confirmAsync(
|
||||
{
|
||||
message: `Open the ${STARTER_PLAN.label} plan checkout in your browser now?`,
|
||||
initial: false,
|
||||
},
|
||||
{ onCancel: () => false }
|
||||
);
|
||||
const answer: FreeTierLimitCheckoutPromptAnswer =
|
||||
confirmed === true ? 'yes' : confirmed === false ? 'no' : 'cancel';
|
||||
|
||||
let checkoutCreated = false;
|
||||
if (answer === 'yes') {
|
||||
const checkoutUrl = await createCheckoutUrlAsync(experiment);
|
||||
if (checkoutUrl) {
|
||||
checkoutCreated = true;
|
||||
await openOrPrintUrlAsync(checkoutUrl, { label: 'Checkout page', open: true });
|
||||
} else {
|
||||
Log.log(`Upgrade your plan at ${link(getAccountBillingUrl(experiment.accountName))}`);
|
||||
}
|
||||
}
|
||||
|
||||
experiment.analytics.logEvent(BuildEvent.FREE_TIER_LIMIT_CHECKOUT_PROMPT, {
|
||||
...experiment.analyticsEventProperties,
|
||||
answer,
|
||||
checkout_created: checkoutCreated,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import { createAndLinkChannelAsync, doesChannelExistAsync } from '../channel/que
|
||||
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
|
||||
import { LoggedInAuthenticationInfo } from '../user/SessionManager';
|
||||
import {
|
||||
AppPlatform,
|
||||
BuildFragment,
|
||||
@@ -121,7 +120,6 @@ export async function runBuildAndSubmitAsync({
|
||||
downloadSimBuildAutoConfirm,
|
||||
envOverride,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
@@ -132,10 +130,8 @@ export async function runBuildAndSubmitAsync({
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
downloadSimBuildAutoConfirm?: boolean;
|
||||
envOverride?: Env;
|
||||
/** Enables the Free plan limit experiments on the build request error path. */
|
||||
/** Enables the Free plan limit guidance experiment for builds driven by a coding agent. */
|
||||
experimentation?: LazyExperimentationClient;
|
||||
/** Lets the Free plan limit checkout experiment create a checkout session. */
|
||||
authenticationInfo?: LoggedInAuthenticationInfo;
|
||||
}): Promise<{
|
||||
buildIds: string[];
|
||||
buildProfiles?: ProfileData<'build'>[];
|
||||
@@ -249,7 +245,6 @@ export async function runBuildAndSubmitAsync({
|
||||
env,
|
||||
easJsonAccessor,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
});
|
||||
if (maybeBuild) {
|
||||
startedBuilds.push({ build: maybeBuild, buildProfile });
|
||||
@@ -389,7 +384,6 @@ async function prepareAndStartBuildAsync({
|
||||
env,
|
||||
easJsonAccessor,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
}: {
|
||||
projectDir: string;
|
||||
flags: BuildFlags;
|
||||
@@ -405,7 +399,6 @@ async function prepareAndStartBuildAsync({
|
||||
easJsonAccessor: EasJsonAccessor;
|
||||
env: Env;
|
||||
experimentation?: LazyExperimentationClient;
|
||||
authenticationInfo?: LoggedInAuthenticationInfo;
|
||||
}): Promise<{ build: BuildFragment | undefined; buildCtx: BuildContext<Platform> }> {
|
||||
const buildCtx = await createBuildContextAsync({
|
||||
buildProfileName: buildProfile.profileName,
|
||||
@@ -432,7 +425,6 @@ async function prepareAndStartBuildAsync({
|
||||
whatToTest: flags.whatToTest,
|
||||
env,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
});
|
||||
|
||||
if (!lockfileChecked && !flags.localBuildOptions.localBuildMode) {
|
||||
|
||||
@@ -18,10 +18,6 @@ export function getProjectPageUrl(
|
||||
return new URL(page ? `${projectPath}/${page}` : projectPath, getExpoWebsiteBaseUrl()).toString();
|
||||
}
|
||||
|
||||
export function getAccountBillingUrl(accountName: string): string {
|
||||
return new URL(`/accounts/${accountName}/settings/billing`, getExpoWebsiteBaseUrl()).toString();
|
||||
}
|
||||
|
||||
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
|
||||
const { app } = build;
|
||||
const url = `/accounts/${app.ownerAccount.name}/projects/${app.slug}/builds/${build.id}${
|
||||
|
||||
@@ -271,9 +271,7 @@ export default abstract class EasCommand extends Command {
|
||||
const commandId = this.id ?? 'unknown';
|
||||
let baseMessage = `${commandId} command failed.`;
|
||||
if (err instanceof EasCommandError) {
|
||||
if (!err.messageAlreadyPrinted) {
|
||||
Log.error(err.message);
|
||||
}
|
||||
Log.error(err.message);
|
||||
} else if (err instanceof CombinedError && err?.graphQLErrors) {
|
||||
const cleanGQLErrorsMessage = err?.graphQLErrors
|
||||
.map((graphQLError: GraphQLError) => {
|
||||
|
||||
@@ -215,30 +215,6 @@ describe('EasCommand', () => {
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
});
|
||||
|
||||
it('skips the message when the command already printed it', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
const logErrorSpy = jest.spyOn(Log, 'error');
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug');
|
||||
const { EasCommandError } = require('../errors');
|
||||
const Sentry = jest.requireMock('../../sentry').default;
|
||||
Sentry.withScope.mockImplementation((cb: (scope: any) => void) => {
|
||||
cb({ setTag: jest.fn(), setExtra: jest.fn() });
|
||||
});
|
||||
const runAsyncMock = jest.spyOn(TestEasCommand.prototype, 'runAsync');
|
||||
const error = new EasCommandError('Already printed', { messageAlreadyPrinted: true });
|
||||
runAsyncMock.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
try {
|
||||
await TestEasCommand.run([], mockConfig);
|
||||
} catch {}
|
||||
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
expect(logDebugSpy).toBeCalledWith(error);
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('logs the cleaned message if needed', async () => {
|
||||
const TestEasCommand = createTestEasCommand();
|
||||
const Log = jest.requireMock('../../log').default;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import ContextField, { ContextOptions } from './ContextField';
|
||||
import { ExpoGraphqlClient } from './contextUtils/createGraphqlClient';
|
||||
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
|
||||
import { LoggedInAuthenticationInfo } from '../../user/SessionManager';
|
||||
import { Actor } from '../../user/User';
|
||||
import FeatureGateEnvOverrides from '../gating/FeatureGateEnvOverrides';
|
||||
@@ -22,7 +22,7 @@ export default class LoggedInContextField extends ContextField<LoggedInContextTy
|
||||
});
|
||||
const featureGateServerValues: { [key: string]: boolean } = actor?.featureGates ?? {};
|
||||
|
||||
const graphqlClient = sessionManager.getGraphqlClient(authenticationInfo);
|
||||
const graphqlClient = createGraphqlClient(authenticationInfo);
|
||||
|
||||
return {
|
||||
actor,
|
||||
|
||||
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user