Compare commits

...
14 Commits
Author SHA1 Message Date
Expo CI aa9f5370d4 v25.1.1 2026-10-16 21:35:07 +00:00
Gabe Debes 60a0b483eb [ci] Generate build-tools GraphQL types before typechecking (#4663) 2026-10-16 23:17:55 +02:00
Szymon Dziedzic 070f15c633 [build-tools] Remove the GraphQL fallbacks for device run session jobs (#4638) 2026-10-16 23:00:28 +02:00
Expo CI c8aa5eb05d v25.1.0 2026-10-16 19:51:06 +00:00
Krystof WoldrichandClaude Fable 5.1 68596f8c0c [eas-cli] add --cors-origin to simulator:start (#4658)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 21:23:09 +02:00
Krystof WoldrichandClaude Fable 5.1 1e38ada929 [eas-cli] add --frame-ancestor to simulator:start (#4656)
* [eas-cli] add --frame-ancestor to simulator:start and print the embed URL

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [eas-cli] cover the embed output fallbacks

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* [eas-cli] format the embed output tests

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 21:23:09 +02:00
Krystof WoldrichandClaude Fable 5.1 d52629e9f3 [build-tools] pass cors_origins session input to the preview hosts (#4657)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 20:40:20 +02:00
Krystof WoldrichandClaude Fable 5.1 bed6354ef5 [build-tools] pass frame_ancestors session input to the preview hosts (#4655)
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-16 20:05:51 +02:00
Stanisław Chmiela 9aac1a0dff [build-tools] Retry commits while Google Play processes bundles (#4651)
# Why

https://sentry.io/organizations/expoio/issues/7792509578/events/f6692b78705a40ab9380fd177c265b35/

# How

Supposedly Fastlane retries committing.

# Test Plan

CI should pass. Land and see that the error no longer occurs.
2026-10-16 15:26:32 +02:00
Stanisław Chmiela c1c2b748b4 [build-tools] Report Google Play error responses to Sentry (#4652)
# Why

Google Play submissions sometimes fail with generic errors like `Precondition check failed.` (HTTP 400, `FAILED_PRECONDITION`). We currently drop everything except `error.message` and `errors[].reason`, so neither the user nor we can tell what Google actually objected to.

This replaces #4650, which also put Google's details into user-facing messages and structured logs, and added a redacting sanitizer. That made the code a lot more complex. This PR keeps build logs unchanged and only makes the raw response available in Sentry. Once real payloads show up there, we can add targeted classifiers to `mapGooglePlayError` with clear user-facing messages.

# How

- `GooglePlayApiError` now keeps:
  - the JSON error response body, as a string capped at 8 KB
  - the request URL path, without the query (the upload session query contains a secret)

  Non-JSON bodies are still dropped, as before.
- `GooglePlayApiError.getErrorMetadata()` finds the API error in the cause chain, through retry `AggregateError`s and commit-outcome-unknown wrappers. It returns `googlePlayRequestPath` and `googlePlayErrorResponse`.
- `mapGooglePlayError` and `GooglePlayCommitOutcomeUnknownError` attach that as `ExpoError.metadata`. The worker already sends it to Sentry as `extras.errorMetadata`, and it is not part of the external build error.
- The body is stored as a string because `@sentry/node` 7 normalizes extras to depth 3, so nested `details` would otherwise show up as `[Object]`.

User-facing messages are unchanged.

# Test Plan

- New tests in `GooglePlaySubmission.test.ts`:
  - a `FAILED_PRECONDITION` commit with `details` ends up in metadata but not in the message
  - metadata survives retries when the commit outcome is unknown
  - the upload-session query and non-JSON bodies are not stored
- `yarn test src/steps/utils/android src/steps/functions/__tests__/submitToGooglePlay.test.ts` (153 passed), plus `yarn typecheck`, `yarn lint`, `yarn fmt:check`

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-10-16 15:17:05 +02:00
Gabe Debes 7b39b92df8 [build-tools] Experiment with 4 GiB RAM for Android device sessions (#4640) 2026-10-16 12:57:51 +02:00
Stanisław Chmiela 929905ba7e [build-tools] Retry transient Google Play API requests (#4629)
# Why

While testing submissions on staging I found Google APIs flake sometimes.

# How

Wrapped requests with auto-retry when we can be sure we can retry.

# Test Plan

CI should pass.
2026-10-16 12:01:48 +02:00
Philippe Loulidi ef7fceba86 [eas-cli] ENG-27937 Show EAS Simulator minutes in eas account:usage (#4583)
* [eas-cli] Show EAS Simulator minutes in eas account:usage

* [eas-cli] report unavailable Simulator usage estimates

* [eas-cli] Replace Simulator usage changelog entry with changeset
2026-10-15 17:21:22 -06:00
Expo CI c2dc766c38 bump STAGING and PRODUCTION to 25.0.0 in cli-versions.json 2026-10-15 23:01:41 +00:00
89 changed files with 3041 additions and 1210 deletions

No files matched your search

-1
View File
@@ -22,4 +22,3 @@ jobs:
uses: ./.github/actions/setup-mise
- run: yarn install --immutable
- run: yarn verify-graphql-code
working-directory: packages/eas-cli
+23
View File
@@ -2,6 +2,29 @@
This is the log of notable changes to EAS CLI and related packages.
## [25.1.1](https://github.com/expo/eas-cli/releases/tag/v25.1.1) - 2026-10-16
### 🐛 Bug fixes
- [build-tools] Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
## [25.1.0](https://github.com/expo/eas-cli/releases/tag/v25.1.0) - 2026-10-16
### 🎉 New features
- [eas-cli] Show EAS Simulator minutes in `eas account:usage`. ([#4583](https://github.com/expo/eas-cli/pull/4583) by [@LipLoody](https://github.com/LipLoody))
- [build-tools] Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [build-tools] Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `--cors-origin` to `eas simulator:start`, so a third party page can call the simulator preview API, for example with `@expo/hub-client`. ([#4658](https://github.com/expo/eas-cli/pull/4658) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- [eas-cli] Add `--frame-ancestor` to `eas simulator:start`, so a third party site can embed the simulator preview, and print the embeddable preview URL. ([#4656](https://github.com/expo/eas-cli/pull/4656) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### 🐛 Bug fixes
- [build-tools] Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
## [25.0.0](https://github.com/expo/eas-cli/releases/tag/v25.0.0) - 2026-10-15
### 🛠 Breaking changes
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "24.14.0",
"PRODUCTION": "24.14.0"
"STAGING": "25.0.0",
"PRODUCTION": "25.0.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "25.0.0",
"version": "25.1.1",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+1
View File
@@ -7,6 +7,7 @@
"scripts": {
"build": "lerna run build",
"typecheck": "lerna run typecheck",
"verify-graphql-code": "lerna run verify-graphql-code --concurrency 1",
"start": "lerna run watch --stream --parallel",
"start-allow-unused": "lerna run watch-allow-unused --stream --parallel",
"watch": "yarn start",
+20
View File
@@ -1,5 +1,25 @@
# @expo/build-tools
## 25.1.1
### Patch Changes
- Call www only through the device run session worker API in device run session jobs, and remove their GraphQL fallbacks. ([#4638](https://github.com/expo/eas-cli/pull/4638) by [@szdziedzic](https://github.com/szdziedzic))
## 25.1.0
### Minor Changes
- Pass the `cors_origins` session input to serve-sim and expo-device-hub as `--cors-origin` origins, so a third party page can call the preview host API, for example with `@expo/hub-client`. ([#4657](https://github.com/expo/eas-cli/pull/4657) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Pass the `frame_ancestors` session input to serve-sim and expo-device-hub as `--frame-ancestor` origins, so a third party can embed the preview host. ([#4655](https://github.com/expo/eas-cli/pull/4655) by [@krystofwoldrich](https://github.com/krystofwoldrich))
### Patch Changes
- Allocate 4 GiB of guest RAM to Android emulators in EAS Simulator sessions. ([#4640](https://github.com/expo/eas-cli/pull/4640) by [@gwdp](https://github.com/gwdp))
- Retry transient Google Play API failures with exponential backoff while keeping uncertain commit results explicit. ([#4629](https://github.com/expo/eas-cli/pull/4629) by [@sjchmiela](https://github.com/sjchmiela))
- Retry Google Play edit commits when an uploaded Android App Bundle is still processing, with bounded retries and progress warnings. ([#4651](https://github.com/expo/eas-cli/pull/4651) by [@sjchmiela](https://github.com/sjchmiela))
- Report Google Play error responses to Sentry so unexplained submission failures can be classified. ([#4652](https://github.com/expo/eas-cli/pull/4652) by [@sjchmiela](https://github.com/sjchmiela))
## 25.0.0
### Minor Changes
+8 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "25.0.0",
"version": "25.1.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -36,22 +36,23 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\"",
"gql": "gql.tada generate-schema ${API_SERVER_URL:-https://api.expo.dev}/graphql --output ./schema.graphql && gql.tada generate-output",
"gql:local": "API_SERVER_URL=http://api.expo.test yarn gql",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"test": "yarn jest-unit"
},
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "25.0.0",
"@expo/eas-build-job": "25.0.0",
"@expo/downloader": "25.1.1",
"@expo/eas-build-job": "25.1.1",
"@expo/env": "^0.4.0",
"@expo/logger": "25.0.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.0.0",
"@expo/template-file": "25.0.0",
"@expo/turtle-spawn": "25.0.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",
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -eox pipefail
# Generated GraphQL files are untracked, so always regenerate and check compatibility.
yarn gql
yarn run -T lerna run build --scope @expo/build-tools --include-dependencies
@@ -108,7 +108,11 @@ describe.each([
['Appium', createStartAppiumRemoteSessionBuildFunction],
['Agent Device', createStartAgentDeviceSessionBuildFunction],
] as const)('%s host ownership', (name, createFunction) => {
async function runAsync(publicPreview?: boolean) {
async function runAsync(
publicPreview?: boolean,
frameAncestors?: string[],
corsOrigins?: string[]
) {
const fn = createFunction({} as CustomBuildContext);
await fn.fn!(
{
@@ -118,7 +122,11 @@ describe.each([
{
// Inputs a step does not get default to undefined, like in a real step call.
inputs: new Proxy(
{ public_preview: { value: publicPreview } } as Record<string, { value: unknown }>,
{
public_preview: { value: publicPreview },
frame_ancestors: { value: frameAncestors },
cors_origins: { value: corsOrigins },
} as Record<string, { value: unknown }>,
{
get: (target, id: string) => target[id] ?? { value: undefined },
}
@@ -179,6 +187,34 @@ describe.each([
);
});
it('maps frame_ancestors to the host and defaults to none', async () => {
await runAsync(undefined, ['https://embed.example.test']);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
jest.mocked(startDeviceSessionHostAsync).mockClear();
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ frameAncestors: [] })
);
});
it('maps cors_origins to the host and defaults to none', async () => {
await runAsync(undefined, undefined, ['https://app.example.test']);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
jest.mocked(startDeviceSessionHostAsync).mockClear();
await runAsync();
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ corsOrigins: [] })
);
});
it('stops automation while recording finalization or upload is pending', async () => {
let release!: () => void;
let stopped!: () => void;
@@ -88,6 +88,8 @@ function runStep(
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
@@ -417,6 +419,8 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
@@ -440,6 +444,26 @@ describe(createStartAgentDeviceSessionBuildFunction, () => {
}
);
it('passes frame_ancestors to the session host', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
frame_ancestors: ['https://embed.example.test'],
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('passes cors_origins to the session host', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
cors_origins: ['https://app.example.test'],
});
expect(runAgentDeviceRemoteSessionAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it('passes network capture to the session', async () => {
await runStep(BuildRuntimePlatform.DARWIN, {
network_capture: true,
@@ -422,6 +422,54 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
);
});
it('passes frame_ancestors to the session host', async () => {
const ctx = {} as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
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: undefined },
frame_ancestors: { value: ['https://embed.example.test'] },
},
outputs: {},
env: {},
} as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('passes cors_origins to the session host', async () => {
const ctx = {} as CustomBuildContext;
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
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: undefined },
cors_origins: { value: ['https://app.example.test'] },
},
outputs: {},
env: {},
} as never
);
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it('continues the session when disabling the video watermark fails', async () => {
const error = new Error('bun x failed');
jest
@@ -210,6 +210,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
: {}),
packageVersion: '1.2.3',
disableShare: false,
frameAncestors: [],
corsOrigins: [],
launchAppIdentifier: undefined,
launchArgs: [],
openUrl: undefined,
@@ -258,6 +260,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
'network_capture_fields',
'public_preview',
'private_preview_url',
'frame_ancestors',
'cors_origins',
'package_version',
'max_duration_seconds',
]);
@@ -322,6 +326,26 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
}
);
it('hands frame_ancestors to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
frame_ancestors: { value: ['https://embed.example.test'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ frameAncestors: ['https://embed.example.test'] })
);
});
it('hands cors_origins to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
cors_origins: { value: ['https://app.example.test'] },
});
expect(startDeviceSessionHostAsync).toHaveBeenCalledWith(
ctx,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
});
it('hands network capture to the session host', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
network_capture: { value: true },
@@ -15,6 +15,11 @@ import {
import { createSubmitToGooglePlayBuildFunction } from '../submitToGooglePlay';
jest.unmock('node-fetch');
jest.mock('promise-retry', () => {
const actual = jest.requireActual('promise-retry');
return (fn: Parameters<typeof actual>[0], options: object) =>
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
});
jest.unmock('fs');
jest.unmock('node:fs');
jest.unmock('fs/promises');
@@ -174,7 +179,7 @@ it('preserves the original failure when cleanup fails', async () => {
api()
.put(`${editPath}/tracks/internal`)
.reply(400, { error: { message: 'unknown track' } });
api().delete(editPath).reply(500);
api().delete(editPath).times(6).reply(500);
await expect(submission().run()).rejects.toMatchObject({
cause: expect.objectContaining({ apiMessage: 'unknown track' }),
});
@@ -183,6 +188,7 @@ it('does not delete the edit or publish outputs when commit outcome is unknown',
upload();
api().put(`${editPath}/tracks/internal`).reply(200, { track: 'internal' });
api().post(`${editPath}:commit`).query(true).reply(503);
api().post(`${editPath}:commit`).query(true).reply(404);
let deleted = false;
api()
.delete(editPath)
@@ -98,6 +98,8 @@ export async function runAgentDeviceRemoteSessionAsync(
capture,
disableShare = false,
privatePreviewUrl,
frameAncestors,
corsOrigins,
tasks,
device,
}: {
@@ -112,6 +114,8 @@ export async function runAgentDeviceRemoteSessionAsync(
capture: ReturnType<typeof parseNetworkCaptureInputs>;
disableShare?: boolean;
privatePreviewUrl?: string;
frameAncestors?: string[];
corsOrigins?: string[];
tasks: StartupTasks;
device: { booted: Promise<unknown>; ready: Promise<unknown> };
}
@@ -169,6 +173,8 @@ export async function runAgentDeviceRemoteSessionAsync(
timeoutMs: STARTUP_TIMEOUT_MS,
disableShare,
privatePreviewUrl,
frameAncestors,
corsOrigins,
networkCapture: capture.networkCapture,
networkCaptureFields: capture.networkCaptureFields,
});
@@ -19,9 +19,13 @@ import { IosSimulatorName, IosSimulatorUuid } from '../../utils/IosSimulatorUtil
import { getDeviceRunSessionWorkerApi } from '../utils/deviceRunSessionWorkerApi';
import { withLocalEgressSession } from '../utils/localEgressSession';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
} from '../utils/remoteDeviceRunSession';
import {
@@ -122,6 +126,8 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -271,6 +277,8 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte
capture,
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),
tasks,
device: { booted, ready },
});
@@ -37,6 +37,8 @@ import { turtleFetch } from '../../utils/turtleFetch';
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
@@ -47,6 +49,8 @@ import {
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
@@ -77,6 +81,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -192,6 +198,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
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,
@@ -34,6 +34,8 @@ import { sleepAsync } from '../../utils/retry';
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
createServeSimLaunchInputProviders,
@@ -44,6 +46,8 @@ import {
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
selectXcodeDeveloperDirectoryAsync,
spawnDetached,
@@ -89,6 +93,8 @@ export function createStartArgentRemoteSessionBuildFunction(
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -289,6 +295,8 @@ export function createStartArgentRemoteSessionBuildFunction(
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,
@@ -17,6 +17,8 @@ import {
withLocalEgressSession,
} from '../utils/localEgressSession';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
@@ -26,6 +28,8 @@ import {
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
omitNgrokAuthtoken,
parseCorsOriginsInput,
parseFrameAncestorsInput,
selectXcodeDeveloperDirectoryAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
@@ -46,6 +50,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
...createNetworkCaptureInputProviders(),
createPublicPreviewInputProvider(),
createPrivatePreviewUrlInputProvider(),
createFrameAncestorsInputProvider(),
createCorsOriginsInputProvider(),
BuildStepInput.createProvider({
id: 'package_version',
required: false,
@@ -104,6 +110,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
packageVersion,
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,
@@ -131,7 +131,7 @@ export function createSubmitToGooglePlayBuildFunction(): BuildFunction {
keyPath: path.resolve(ctx.workingDirectory, submission.service_account_key_path),
});
const artifactPath = path.resolve(ctx.workingDirectory, submission.artifact_path);
const client = new GooglePlayClient(credentials);
const client = new GooglePlayClient(credentials, ctx.logger);
const versionCode = await submitToGooglePlayAsync({
artifactType: submission.artifact_type,
client,
@@ -7,7 +7,12 @@ import { type CustomBuildContext } from '../../../customBuildContext';
import HttpLogStream from '../../../logging/HttpLogStream';
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startAgentDeviceEventCollectionAsync } from '../agentDeviceEvents';
import {
DEVICE_RUN_SESSION_TOKEN_HEADER,
DeviceRunSessionWorkerApiError,
} from '../deviceRunSessionWorkerApi';
const mockEventLogStream = {
init: jest.fn(async () => undefined),
@@ -28,10 +33,18 @@ jest.mock('../../../logging/RemoteLoggerStream', () => ({
default: jest.fn(() => mockEventLogStream),
}));
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
const EVENT_LOG_UPLOAD_SESSION_URL =
'https://api.expo.test/v2/device-run-session-worker/event-log-upload-session';
describe(startAgentDeviceEventCollectionAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(turtleFetch).mockImplementation(async () => eventLogUploadSessionResponse());
});
it('uploads normalized events to the session event artifact once', async () => {
@@ -88,9 +101,13 @@ describe(startAgentDeviceEventCollectionAsync, () => {
await fs.promises.rm(stateDir, { recursive: true, force: true });
}
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'session-id',
});
expect(turtleFetch).toHaveBeenCalledWith(
EVENT_LOG_UPLOAD_SESSION_URL,
'POST',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
})
);
expect(RemoteLoggerStream).toHaveBeenCalledWith({
logger,
uploadMethod: {
@@ -535,11 +552,13 @@ describe(startAgentDeviceEventCollectionAsync, () => {
});
it('does not fail the session when event log setup fails', async () => {
const error = new Error('WWW unavailable');
const ctx = createContext({ realtimeLogs: true });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce({
toPromise: async () => ({ error }),
} as unknown as ReturnType<CustomBuildContext['graphqlClient']['mutation']>);
jest.mocked(turtleFetch).mockResolvedValueOnce(
new Response(JSON.stringify({ errors: [{ message: 'WWW unavailable' }] }), {
status: 503,
headers: { 'content-type': 'application/json' },
})
);
const logger = createLogger();
const collection = await startAgentDeviceEventCollectionAsync({
@@ -554,8 +573,11 @@ describe(startAgentDeviceEventCollectionAsync, () => {
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.objectContaining({
message: 'Failed to create device run session event log upload session: WWW unavailable',
cause: error,
message:
'Failed to create device run session event log upload session: ' +
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
'WWW unavailable',
cause: expect.any(DeviceRunSessionWorkerApiError),
}),
},
'Could not persist device run session events to the artifact.'
@@ -563,8 +585,11 @@ describe(startAgentDeviceEventCollectionAsync, () => {
expect(Sentry.capture).toHaveBeenCalledWith(
'Could not persist device run session events',
expect.objectContaining({
message: 'Failed to create device run session event log upload session: WWW unavailable',
cause: error,
message:
'Failed to create device run session event log upload session: ' +
'POST /v2/device-run-session-worker/event-log-upload-session failed with status 503: ' +
'WWW unavailable',
cause: expect.any(DeviceRunSessionWorkerApiError),
}),
{
level: 'warning',
@@ -669,32 +694,35 @@ describe(startAgentDeviceEventCollectionAsync, () => {
function createContext({
realtimeLogs = false,
}: { realtimeLogs?: boolean } = {}): CustomBuildContext {
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createEventLogUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/events',
headers: {
'content-disposition': 'attachment; filename="events.ndjson"',
'x-goog-content-length-range': '0,104857600',
},
},
},
},
},
}),
});
return {
graphqlClient: { mutation },
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
env: {
EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN,
...(realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {}),
},
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
job: {
secrets: realtimeLogs ? { robotAccessToken: 'robot-access-token' } : {},
},
} as unknown as CustomBuildContext;
}
function eventLogUploadSessionResponse(): InstanceType<typeof Response> {
return new Response(
JSON.stringify({
data: {
uploadSession: {
url: 'https://uploads.expo.test/events',
headers: {
'content-disposition': 'attachment; filename="events.ndjson"',
'x-goog-content-length-range': '0,104857600',
},
},
},
}),
{ headers: { 'content-type': 'application/json' } }
);
}
function createLogger(): bunyan {
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
}
@@ -7,7 +7,9 @@ import { type CustomBuildContext } from '../../../customBuildContext';
import HttpLogStream from '../../../logging/HttpLogStream';
import RemoteLoggerStream from '../../../logging/RemoteLoggerStream';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import { startArgentEventCollectionAsync } from '../argentEvents';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
const mockEventLogStream = {
init: jest.fn(async () => undefined),
@@ -28,12 +30,20 @@ jest.mock('../../../logging/RemoteLoggerStream', () => ({
default: jest.fn(() => mockEventLogStream),
}));
jest.mock('../../../sentry');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
const EVENT_LOG_UPLOAD_SESSION_URL =
'https://api.expo.test/v2/device-run-session-worker/event-log-upload-session';
const EVENT_LOG_FILENAME = 'tool-server-events.jsonl';
describe(startArgentEventCollectionAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(turtleFetch).mockImplementation(async () => eventLogUploadSessionResponse());
});
it('maps tool lifecycle records onto the shared operation vocabulary', async () => {
@@ -84,9 +94,13 @@ describe(startArgentEventCollectionAsync, () => {
await fs.promises.rm(stateDir, { recursive: true, force: true });
}
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'session-id',
});
expect(turtleFetch).toHaveBeenCalledWith(
EVENT_LOG_UPLOAD_SESSION_URL,
'POST',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
})
);
expect(RemoteLoggerStream).toHaveBeenCalledWith({
logger,
uploadMethod: {
@@ -318,29 +332,32 @@ function bunyanRecord(fields: Record<string, unknown>): Record<string, unknown>
function createContext({
realtimeLogs = false,
}: { realtimeLogs?: boolean } = {}): CustomBuildContext {
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createEventLogUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/events',
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
},
},
},
},
}),
});
return {
graphqlClient: { mutation },
env: realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {},
env: {
EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN,
...(realtimeLogs ? { EXPO_STAGING: '1', EAS_BUILD_ID: 'job-run-id' } : {}),
},
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
job: {
secrets: realtimeLogs ? { robotAccessToken: 'robot-access-token' } : {},
},
} as unknown as CustomBuildContext;
}
function eventLogUploadSessionResponse(): InstanceType<typeof Response> {
return new Response(
JSON.stringify({
data: {
uploadSession: {
url: 'https://uploads.expo.test/events',
headers: { 'content-disposition': 'attachment; filename="events.ndjson"' },
},
},
}),
{ headers: { 'content-type': 'application/json' } }
);
}
function createLogger(): bunyan {
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
}
@@ -2,46 +2,56 @@ import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { turtleFetch } from '../../../utils/turtleFetch';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.mock('node-fetch');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const TOKEN = 'device-run-session-token-value';
function jsonResponse(body: unknown): InstanceType<typeof Response> {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
function createCtx(): CustomBuildContext {
return {
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
}
describe(uploadDeviceRunSessionArtifactAsync, () => {
beforeEach(() => {
jest.mocked(fetch).mockReset();
jest.mocked(turtleFetch).mockReset();
});
it('streams an artifact through a signed upload URL', async () => {
const stream = Readable.from(Buffer.from('artifact-data'));
const reportedSize = 1024;
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {
'Content-Length': String(reportedSize),
'Content-Type': 'application/octet-stream',
},
},
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {
'Content-Length': String(reportedSize),
'Content-Type': 'application/octet-stream',
},
},
},
}),
});
const ctx = {
graphqlClient: {
mutation,
},
} as unknown as CustomBuildContext;
})
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
await uploadDeviceRunSessionArtifactAsync(ctx, {
await uploadDeviceRunSessionArtifactAsync(createCtx(), {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'Artifact report.json (artifact-id)',
@@ -52,19 +62,20 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
openStream: () => stream,
});
expect(mutation).toHaveBeenCalledWith(
expect.anything(),
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-session-worker/artifact-upload-sessions',
'POST',
expect.objectContaining({
deviceRunSessionId: 'drs-id',
input: {
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: TOKEN },
json: {
name: 'Artifact report.json (artifact-id)',
filename: 'report.json',
kind: 'agent-device-test-report',
metadata: { firstFrameRecordAt: 'test-time' },
size: reportedSize,
},
}),
{ fetch: expect.any(Function) }
signal: expect.any(AbortSignal),
})
);
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
'https://uploads.expo.test/artifact',
@@ -77,26 +88,19 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
it('forwards cancellation through upload allocation and skips PUT when allocation aborts', async () => {
const controller = new AbortController();
const mutation = jest.fn().mockReturnValue({
toPromise: async () => {
controller.abort();
return {
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {},
},
},
},
jest.mocked(turtleFetch).mockImplementationOnce(async () => {
controller.abort();
return jsonResponse({
data: {
uploadSession: {
url: 'https://uploads.expo.test/artifact',
headers: {},
},
};
},
},
});
});
const ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
await expect(
uploadDeviceRunSessionArtifactAsync(ctx, {
uploadDeviceRunSessionArtifactAsync(createCtx(), {
deviceRunSessionId: 'session',
artifactId: 'id',
name: 'Logs',
@@ -108,18 +112,8 @@ describe(uploadDeviceRunSessionArtifactAsync, () => {
})
).rejects.toThrow();
expect(fetch).not.toHaveBeenCalled();
const allocationFetch = mutation.mock.calls[0][2].fetch;
const mockFetch = jest.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('aborted'));
try {
await expect(allocationFetch('https://api.expo.test/graphql', {})).rejects.toThrow('aborted');
expect(mockFetch).toHaveBeenCalledWith(
'https://api.expo.test/graphql',
expect.objectContaining({
signal: expect.objectContaining({ aborted: true }),
})
);
} finally {
mockFetch.mockRestore();
}
const allocationSignal = jest.mocked(turtleFetch).mock.calls[0][2].signal;
expect(allocationSignal).toBeInstanceOf(AbortSignal);
expect(allocationSignal?.aborted).toBe(true);
});
});
@@ -1,7 +1,7 @@
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform } from '@expo/steps';
import { SystemError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { Client, fetchExchange } from '@urql/core';
import { getEventListeners } from 'node:events';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { Readable } from 'node:stream';
@@ -10,20 +10,43 @@ import fetch from 'node-fetch';
import { type CustomBuildContext } from '../../../customBuildContext';
import * as processes from '../../../utils/processes';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
captureDeviceRunSessionPreviewAsync,
ensureMacosPreviewEncoderInstalledAsync,
startDeviceRunSessionPreview,
} from '../deviceRunSessionPreview';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.mock('node-fetch');
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token-value';
const PREVIEW_UPLOAD_SESSION_URL =
'https://api.expo.test/v2/device-run-session-worker/preview-upload-session';
const uploadSession = {
url: 'https://uploads.expo.test/preview',
headers: { 'content-type': 'image/webp' },
};
function createCtx(): CustomBuildContext {
return {
env: { EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN },
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext;
}
function uploadSessionResponse(): InstanceType<typeof Response> {
return new Response(JSON.stringify({ data: { uploadSession } }), {
headers: { 'content-type': 'application/json' },
});
}
describe(startDeviceRunSessionPreview, () => {
const image = Buffer.from('webp-image');
let captureAsync: jest.Mock<Promise<Buffer>, [AbortSignal]>;
let mutation: jest.Mock;
let logger: bunyan;
let ctx: CustomBuildContext;
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
@@ -31,21 +54,11 @@ describe(startDeviceRunSessionPreview, () => {
beforeEach(() => {
jest.useFakeTimers();
captureAsync = jest.fn().mockResolvedValue(image);
mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createPreviewUploadSession: {
uploadSession: {
url: 'https://uploads.expo.test/preview',
headers: { 'content-type': 'image/webp' },
},
},
},
},
}),
});
ctx = { graphqlClient: { mutation } } as unknown as CustomBuildContext;
jest
.mocked(turtleFetch)
.mockReset()
.mockImplementation(async () => uploadSessionResponse());
ctx = createCtx();
logger = { warn: jest.fn() } as unknown as bunyan;
jest
.mocked(fetch)
@@ -82,7 +95,7 @@ describe(startDeviceRunSessionPreview, () => {
expect(captureAsync).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(captureAsync).toHaveBeenCalledTimes(2);
expect(mutation).toHaveBeenCalledTimes(1);
expect(turtleFetch).toHaveBeenCalledTimes(1);
await preview!.stopAsync();
await jest.advanceTimersByTimeAsync(120_000);
expect(captureAsync).toHaveBeenCalledTimes(2);
@@ -92,7 +105,7 @@ describe(startDeviceRunSessionPreview, () => {
captureAsync.mockRejectedValueOnce(new Error('device not ready'));
start();
await jest.advanceTimersByTimeAsync(0);
expect(mutation).not.toHaveBeenCalled();
expect(turtleFetch).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
@@ -102,7 +115,7 @@ describe(startDeviceRunSessionPreview, () => {
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 403 }));
start();
await jest.advanceTimersByTimeAsync(60_000);
expect(mutation).toHaveBeenCalledTimes(1);
expect(turtleFetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledTimes(2);
});
@@ -126,7 +139,7 @@ describe(startDeviceRunSessionPreview, () => {
it('reuses the upload URL for the full session', async () => {
start();
await jest.advanceTimersByTimeAsync(119 * 60_000);
expect(mutation).toHaveBeenCalledTimes(1);
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('does not overlap captures and aborts pending work when stopped', async () => {
@@ -156,29 +169,20 @@ describe(startDeviceRunSessionPreview, () => {
const stopping = handle.stopAsync();
finish(image);
await stopping;
expect(mutation).not.toHaveBeenCalled();
expect(turtleFetch).not.toHaveBeenCalled();
});
});
describe('session preview with the real GraphQL client', () => {
const uploadSession = {
url: 'https://uploads.expo.test/preview',
headers: { 'content-type': 'image/webp' },
};
let graphqlFetch: jest.SpyInstance;
describe('session preview upload session from the worker API', () => {
let preview: ReturnType<typeof startDeviceRunSessionPreview> | undefined;
let logger: bunyan;
beforeEach(() => {
jest.useFakeTimers();
graphqlFetch = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
new globalThis.Response(
JSON.stringify({
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
}),
{ headers: { 'content-type': 'application/json' } }
)
);
jest
.mocked(turtleFetch)
.mockReset()
.mockImplementation(async () => uploadSessionResponse());
logger = { warn: jest.fn() } as unknown as bunyan;
jest
.mocked(fetch)
@@ -193,14 +197,9 @@ describe('session preview with the real GraphQL client', () => {
jest.useRealTimers();
});
const start = () => {
const graphqlClient = new Client({
url: 'https://api.expo.test/graphql',
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer worker-token' } },
});
const start = (ctx: CustomBuildContext = createCtx()) => {
preview = startDeviceRunSessionPreview({
ctx: { graphqlClient } as CustomBuildContext,
ctx,
deviceRunSessionId: 'session-id',
captureAsync: async () => Buffer.from('webp-image'),
logger,
@@ -208,25 +207,49 @@ describe('session preview with the real GraphQL client', () => {
return preview;
};
it('preserves the worker authentication when creating the upload session', async () => {
it('sends the device run session token when creating the upload session', async () => {
start();
await jest.advanceTimersByTimeAsync(0);
const [, options] = graphqlFetch.mock.calls[0];
expect(new Headers(options.headers).get('authorization')).toBe('Bearer worker-token');
expect(turtleFetch).toHaveBeenCalledWith(
PREVIEW_UPLOAD_SESSION_URL,
'POST',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
signal: expect.any(AbortSignal),
})
);
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each(['shutdown', 'timeout'])('aborts a stalled GraphQL request on %s', async reason => {
it('logs a failure and uploads nothing when the job has no device run session token', async () => {
start({
env: {},
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
} as unknown as CustomBuildContext);
await jest.advanceTimersByTimeAsync(0);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(SystemError) },
'Could not refresh the session preview; retrying in 60 seconds.'
);
const [{ err }] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
expect(err.message).toMatch(
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
);
expect(turtleFetch).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it.each(['shutdown', 'timeout'])('aborts a stalled worker API request on %s', async reason => {
const timeout = new AbortController();
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(timeout.signal);
let requestSignal: AbortSignal | undefined;
let rejectRequest!: (error: Error) => void;
graphqlFetch.mockImplementation(
(_url, options) =>
jest.mocked(turtleFetch).mockImplementation(
(_url, _method, options) =>
new Promise((_, reject) => {
rejectRequest = reject;
requestSignal = options.signal;
requestSignal!.addEventListener('abort', () => reject(requestSignal!.reason), {
requestSignal = options.signal as AbortSignal;
requestSignal.addEventListener('abort', () => reject(requestSignal!.reason), {
once: true,
});
})
@@ -255,14 +278,7 @@ describe('session preview with the real GraphQL client', () => {
await stopping;
} else {
expect(logger.warn).toHaveBeenCalled();
graphqlFetch.mockResolvedValue(
new globalThis.Response(
JSON.stringify({
data: { deviceRunSession: { createPreviewUploadSession: { uploadSession } } },
}),
{ headers: { 'content-type': 'application/json' } }
)
);
jest.mocked(turtleFetch).mockImplementation(async () => uploadSessionResponse());
jest.spyOn(AbortSignal, 'timeout').mockReturnValue(new AbortController().signal);
await jest.advanceTimersByTimeAsync(60_000);
expect(fetch).toHaveBeenCalledTimes(1);
@@ -1,6 +1,5 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import { Client, fetchExchange } from '@urql/core';
import { execFileSync } from 'node:child_process';
import { copyFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
@@ -25,28 +24,23 @@ let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creationBodies: string[];
let creationTokens: (string | undefined)[];
beforeEach(async () => {
creationBodies = [];
creationTokens = [];
directory = await mkdtemp(path.join(tmpdir(), 'recording-recovery-'));
server = createServer((request, response) => {
if (request.url === '/graphql') {
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
request.setEncoding('utf8');
let body = '';
request.on('data', chunk => (body += chunk));
request.on('end', () => {
creationBodies.push(body);
creationTokens.push(request.headers['expo-device-run-session-token'] as string | undefined);
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact`, headers: {} },
},
},
},
})
JSON.stringify({ data: { uploadSession: { url: `${url}/artifact`, headers: {} } } })
);
});
} else {
@@ -60,12 +54,9 @@ beforeEach(async () => {
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'session-token' },
expoApiV2BaseUrl: `${url}/v2/`,
} as unknown as CustomBuildContext;
});
afterEach(async () => {
@@ -181,8 +172,9 @@ it('recovers unlisted recordings from a killed host and flags the unfinished one
})
).resolves.toBe(true);
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).variables.input)
.map(body => JSON.parse(body))
.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]);
@@ -1,5 +1,5 @@
import type { bunyan } from '@expo/logger';
import { Client, fetchExchange } from '@urql/core';
import { BuildRuntimePlatform } from '@expo/steps';
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import { tmpdir } from 'node:os';
@@ -10,6 +10,7 @@ import {
screenshotArtifactDetails,
} from '../deviceRunSessionScreenshotNames';
import { uploadDeviceRunSessionScreenshotsAsync } from '../deviceRunSessionScreenshots';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.unmock('node-fetch');
jest.mock('../../../sentry');
function deferred<T>() {
@@ -26,7 +27,7 @@ let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creates: number;
let authorization: string | undefined;
let deviceRunSessionToken: string | string[] | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
let creationBodies: string[];
@@ -40,42 +41,32 @@ beforeEach(async () => {
sessionLookups = 0;
sessionLookupFails = false;
uploadQuery = '';
authorization = undefined;
deviceRunSessionToken = undefined;
stallCreation = false;
creationBodies = [];
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
if (request.url?.startsWith('/graphql')) {
authorization = request.headers.authorization;
if (request.url?.startsWith('/v2/device-run-session-worker/')) {
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
request.setEncoding('utf8');
let body = '';
if (request.method === 'GET') {
const requestUrl = new URL(request.url, 'http://localhost');
body = JSON.stringify({ query: requestUrl.searchParams.get('query') });
}
request.on('data', chunk => (body += chunk));
request.on('end', () => {
const operation = JSON.parse(body);
if (operation.query.includes('query ScreenshotSession')) {
if (request.url === '/v2/device-run-session-worker/application') {
sessionLookups++;
response.setHeader('Content-Type', 'application/json');
if (sessionLookupFails) {
response.statusCode = 503;
response.end(JSON.stringify({ errors: [{ message: 'Lookup unavailable' }] }));
} else {
response.end(
JSON.stringify({
data: {
deviceRunSessions: {
byId: {
platform: 'IOS',
app: { slug: 'session-project' },
build: {
id: 'installed-build',
gitCommitHash: installedCommit,
app: { slug: 'my-app' },
},
},
build: {
id: 'installed-build',
gitCommitHash: installedCommit,
applicationArchiveUrl: null,
},
},
})
@@ -92,13 +83,7 @@ beforeEach(async () => {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} },
},
},
},
data: { uploadSession: { url: `${url}/artifact${uploadQuery}`, headers: {} } },
})
);
});
@@ -113,12 +98,10 @@ beforeEach(async () => {
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
expoApiV2BaseUrl: `${url}/v2/`,
runtimePlatform: BuildRuntimePlatform.DARWIN,
} as unknown as CustomBuildContext;
});
afterEach(async () => {
@@ -127,7 +110,7 @@ afterEach(async () => {
await rm(directory, { recursive: true, force: true });
});
it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload path', async () => {
it('publishes a manual preview PNG through the worker API and HTTP upload path', async () => {
const payload = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aF9sAAAAASUVORK5CYII=',
'base64'
@@ -157,25 +140,20 @@ it('publishes a manual preview PNG through the artifact GraphQL and HTTP upload
expect(bodies).toEqual([payload]);
expect(creates).toBe(1);
expect(sessionLookups).toBe(1);
expect(authorization).toBe('Bearer test-auth');
expect(JSON.parse(creationBodies[0]).variables).toEqual(
expect.objectContaining({
deviceRunSessionId: 'drs-id',
input: expect.objectContaining({
name: 'Screenshot 2026-09-24 08:45:59 UTC',
filename: 'my-app-ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
kind: 'screenshot',
size: payload.length,
metadata: {
__eas_type: 'screenshot',
appSlug: 'my-app',
platform: 'ios',
buildId: 'installed-build',
gitCommitHash: installedCommit,
},
}),
})
);
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
// The worker API does not return the app slug, so the filename starts with the platform.
expect(JSON.parse(creationBodies[0])).toEqual({
name: 'Screenshot 2026-09-24 08:45:59 UTC',
filename: 'ios-a1b2c3d-2026-09-24T08-45-59-123Z.png',
kind: 'screenshot',
size: payload.length,
metadata: {
__eas_type: 'screenshot',
platform: 'ios',
buildId: 'installed-build',
gitCommitHash: installedCommit,
},
});
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
});
@@ -206,7 +184,7 @@ it('rereads the PNG when the artifact PUT is retried', async () => {
await expect(stat(path.join(directory, filename))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('aborts a stalled GraphQL upload request and keeps the PNG', async () => {
it('aborts a stalled worker API upload session request and keeps the PNG', async () => {
stallCreation = true;
const filename = 'screenshot-2026-09-24T08-45-59-123Z-a1b2c3d4e5f6.png';
await writeFile(path.join(directory, filename), 'png');
@@ -1,6 +1,5 @@
import type { bunyan } from '@expo/logger';
import type { BuildStepEnv } from '@expo/steps';
import { Client, fetchExchange } from '@urql/core';
import { createReadStream } from 'node:fs';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
@@ -13,6 +12,7 @@ import { setTimeout as delay } from 'node:timers/promises';
import type { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
import { findUnlistedDeviceScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.unmock('node-fetch');
jest.unmock('node:fs');
@@ -40,20 +40,20 @@ let ctx: CustomBuildContext;
let url: string;
let directory: string;
let creates: number;
let authorization: string | undefined;
let deviceRunSessionToken: string | string[] | undefined;
let stallCreation: boolean;
let creationStarted: ReturnType<typeof deferred<IncomingMessage>>;
beforeEach(async () => {
creates = 0;
authorization = undefined;
deviceRunSessionToken = undefined;
stallCreation = false;
creationStarted = deferred();
directory = await mkdtemp(path.join(tmpdir(), 'recording-upload-http-'));
server = createServer((request, response) => {
if (request.url === '/graphql') {
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
creates++;
authorization = request.headers.authorization;
deviceRunSessionToken = request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER];
creationStarted.resolve(request);
request.resume();
request.on('end', () => {
@@ -63,13 +63,7 @@ beforeEach(async () => {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: `${url}/artifact`, headers: {} },
},
},
},
data: { uploadSession: { url: `${url}/artifact`, headers: {} } },
})
);
});
@@ -84,12 +78,9 @@ beforeEach(async () => {
}
url = `http://127.0.0.1:${address.port}`;
ctx = {
graphqlClient: new Client({
url: `${url}/graphql`,
exchanges: [fetchExchange],
fetchOptions: { headers: { Authorization: 'Bearer test-auth' } },
}),
} as CustomBuildContext;
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'test-device-run-session-token' },
expoApiV2BaseUrl: `${url}/v2/`,
} as unknown as CustomBuildContext;
});
afterEach(async () => {
@@ -126,7 +117,7 @@ it('replays the full file after a transient PUT failure without recreating the u
attempts: 3,
});
expect(creates).toBe(1);
expect(authorization).toBe('Bearer test-auth');
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
expect(bodies).toEqual([payload, payload]);
expect(streams).toHaveLength(2);
expect(streams.every(stream => stream.destroyed)).toBe(true);
@@ -281,7 +272,7 @@ it.each([200, 400, 503])(
}
);
it('cancels urql session creation with auth intact and never starts a PUT', async () => {
it('cancels worker API session creation with the token intact and never starts a PUT', async () => {
stallCreation = true;
const puts = jest.fn();
handle = puts;
@@ -298,7 +289,7 @@ it('cancels urql session creation with auth intact and never starts a PUT', asyn
controller.abort(new Error('test cancellation'));
await rejected;
await closed;
expect(authorization).toBe('Bearer test-auth');
expect(deviceRunSessionToken).toBe('test-device-run-session-token');
expect(creates).toBe(1);
expect(puts).not.toHaveBeenCalled();
expect(openStream).not.toHaveBeenCalled();
@@ -7,6 +7,7 @@ import {
DeviceRunSessionWorkerApi,
DeviceRunSessionWorkerApiError,
getDeviceRunSessionWorkerApi,
requireDeviceRunSessionWorkerApi,
} from '../deviceRunSessionWorkerApi';
jest.mock('../../../utils/turtleFetch');
@@ -88,6 +89,36 @@ describe(getDeviceRunSessionWorkerApi, () => {
});
});
describe(requireDeviceRunSessionWorkerApi, () => {
it('returns the client when the job has a device run session token', () => {
expect(
requireDeviceRunSessionWorkerApi({
env: { EAS_DEVICE_RUN_SESSION_TOKEN: TOKEN },
expoApiV2BaseUrl: BASE_URL,
} as unknown as CustomBuildContext)
).toBeInstanceOf(DeviceRunSessionWorkerApi);
});
it('throws a system error when the job has no device run session token', () => {
const error = (() => {
try {
requireDeviceRunSessionWorkerApi({
env: { __API_SERVER_URL: 'https://api.expo.test' },
expoApiV2BaseUrl: BASE_URL,
} as unknown as CustomBuildContext);
} catch (err) {
return err;
}
return undefined;
})();
expect(error).toBeInstanceOf(SystemError);
expect((error as Error).message).toMatch(
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
);
});
});
describe(DeviceRunSessionWorkerApi, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
@@ -101,7 +132,7 @@ describe(DeviceRunSessionWorkerApi, () => {
route: 'session',
json: undefined,
data: { id: 'drs-id', status: 'in-progress' },
expected: { id: 'drs-id', status: 'IN_PROGRESS' },
expected: { id: 'drs-id', status: 'in-progress' },
},
{
name: 'startAsync',
@@ -111,7 +142,7 @@ describe(DeviceRunSessionWorkerApi, () => {
route: 'start',
json: { remoteConfig: { type: 'agent-device', url: 'https://x.test' } },
data: { id: 'drs-id', status: 'in-progress' },
expected: { id: 'drs-id', status: 'IN_PROGRESS' },
expected: { id: 'drs-id', status: 'in-progress' },
},
{
name: 'stopAsync',
@@ -120,7 +151,7 @@ describe(DeviceRunSessionWorkerApi, () => {
route: 'stop',
json: undefined,
data: { id: 'drs-id', status: 'stopped' },
expected: { id: 'drs-id', status: 'STOPPED' },
expected: { id: 'drs-id', status: 'stopped' },
},
{
name: 'createArtifactUploadSessionAsync',
@@ -217,20 +248,16 @@ describe(DeviceRunSessionWorkerApi, () => {
}
);
it.each([
['new', 'NEW'],
['queued', 'QUEUED'],
['starting', 'STARTING'],
['in-progress', 'IN_PROGRESS'],
['stopped', 'STOPPED'],
['errored', 'ERRORED'],
])('maps the www status %s to the GraphQL status %s', async (wwwStatus, status) => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: wwwStatus } }));
it.each(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored'])(
'returns the www status %s unchanged',
async status => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status } }));
await expect(createApi().getSessionAsync()).resolves.toEqual({ id: 'drs-id', status });
});
await expect(createApi().getSessionAsync()).resolves.toEqual({ id: 'drs-id', status });
}
);
it('rejects an application response without the build field', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { platform: 'ios' } }));
@@ -1,6 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import { BuildRuntimePlatform } from '@expo/steps';
import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import { setImmediate } from 'node:timers/promises';
@@ -105,8 +105,6 @@ async function waitForCallAsync(mock: jest.Mock): Promise<void> {
}
}
const env = { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
const eventSource = {
producer: 'test-producer',
findEventFilesAsync: async () => [],
@@ -203,7 +201,7 @@ describe('with a device run session token', () => {
})
);
const args = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const args = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
expect(args).toEqual([
'--stun-url',
@@ -426,29 +424,38 @@ describe('with a device run session token', () => {
});
describe('without a device run session token', () => {
it('polls the session status with GraphQL', async () => {
const MISSING_TOKEN_MESSAGE =
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./;
function expectMissingTokenError(error: unknown): void {
expect(error).toBeInstanceOf(SystemError);
expect((error as Error).message).toMatch(MISSING_TOKEN_MESSAGE);
}
function expectMissingTokenWarning(message: string): void {
const warning = (jest.mocked(logger.warn).mock.calls as unknown[][]).find(
([, warningMessage]) => warningMessage === message
);
expect(warning).toBeDefined();
expectMissingTokenError((warning![0] as { err: unknown }).err);
}
it('fails the status poll with a system error', async () => {
const ctx = createCtx({ withToken: false });
jest
.mocked(ctx.graphqlClient.query)
.mockReturnValueOnce(
graphqlResult({ deviceRunSessions: { byId: { id: 'drs-id', status: 'STOPPED' } } }) as never
);
await waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId: 'drs-id', logger });
expect(ctx.graphqlClient.query).toHaveBeenCalledWith(expect.anything(), {
const error = await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
});
logger,
}).catch(err => err);
expectMissingTokenError(error);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
});
it('stops an idle session with GraphQL', async () => {
it('logs the failed idle stop and ends the wait', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: { ensureDeviceRunSessionStopped: { id: 'drs-id', status: 'STOPPED' } },
}) as never
);
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -457,65 +464,43 @@ describe('without a device run session token', () => {
idleTimeout: { maxIdleTimeMinutes: 0, getLastEventObservedAt: () => undefined },
});
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'drs-id',
});
expectMissingTokenWarning(
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('starts the session with GraphQL', async () => {
it('fails the start with a system error', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: { startDeviceRunSession: { id: 'drs-id', status: 'IN_PROGRESS' } },
}) as never
);
await uploadRemoteSessionConfigAsync({
const error = await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: { type: 'agent-device' },
logger,
});
}).catch(err => err);
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'drs-id',
remoteConfig: { type: 'agent-device' },
});
expectMissingTokenError(error);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('falls back to no TURN ICE servers', async () => {
const ctx = createCtx({ withToken: false });
await expect(fetchWebPreviewTurnArgsAsync(ctx, { logger })).resolves.toEqual([]);
expectMissingTokenWarning(
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
it('fetches TURN ICE servers from the device run sessions route with the robot token', async () => {
it('fails the artifact upload with a system error before any PUT', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
'POST',
expect.objectContaining({
headers: { Authorization: `Bearer ${ROBOT_ACCESS_TOKEN}` },
timeout: 5000,
retries: 1,
})
);
});
it('creates artifact upload sessions with GraphQL', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: { url: 'https://uploads.expo.test/artifact', headers: {} },
},
},
}) as never
);
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
await uploadDeviceRunSessionArtifactAsync(ctx, {
const error = await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
artifactId: 'artifact-id',
name: 'Report',
@@ -523,23 +508,16 @@ describe('without a device run session token', () => {
kind: undefined,
size: 4,
openStream: () => Readable.from(Buffer.from('data')),
});
}).catch(err => err);
expect(ctx.graphqlClient.mutation).toHaveBeenCalled();
expectMissingTokenError(error);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('creates the event log upload session with GraphQL', async () => {
it('runs the session without persisting events', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: {
createEventLogUploadSession: {
uploadSession: { url: 'https://uploads.expo.test/events', headers: {} },
},
},
}) as never
);
const collection = await startDeviceRunSessionEventCollectionAsync({
ctx,
@@ -549,24 +527,14 @@ describe('without a device run session token', () => {
});
await collection.stopAsync();
expect(ctx.graphqlClient.mutation).toHaveBeenCalledWith(expect.anything(), {
deviceRunSessionId: 'drs-id',
});
expectMissingTokenWarning('Could not persist device run session events to the artifact.');
expect(jest.mocked(RemoteLoggerStream)).not.toHaveBeenCalled();
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('creates the preview upload session with GraphQL', async () => {
it('logs the failed preview upload session and uploads no preview', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.mutation).mockReturnValueOnce(
graphqlResult({
deviceRunSession: {
createPreviewUploadSession: {
uploadSession: { url: 'https://uploads.expo.test/preview', headers: {} },
},
},
}) as never
);
jest.mocked(fetch).mockResolvedValue(new Response('', { status: 200 }));
const preview = startDeviceRunSessionPreview({
ctx,
@@ -574,29 +542,25 @@ describe('without a device run session token', () => {
captureAsync: async () => Buffer.from('webp'),
logger,
});
await waitForCallAsync(jest.mocked(fetch));
await waitForCallAsync(jest.mocked(logger.warn));
await preview.stopAsync();
expect(ctx.graphqlClient.mutation).toHaveBeenCalled();
expectMissingTokenWarning('Could not refresh the session preview; retrying in 60 seconds.');
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('loads the screenshot naming details with GraphQL', async () => {
it('names screenshots with capture timestamps', async () => {
const ctx = createCtx({ withToken: false });
jest.mocked(ctx.graphqlClient.query).mockReturnValueOnce(
graphqlResult({
deviceRunSessions: {
byId: { platform: 'ANDROID', app: { slug: 'app' }, build: null },
},
}) as never
);
await expect(loadScreenshotSessionAsync(ctx, 'drs-id', logger)).resolves.toEqual({
platform: 'ANDROID',
app: { slug: 'app' },
build: null,
});
await expect(loadScreenshotSessionAsync(ctx, 'drs-id', logger)).resolves.toBeNull();
expectMissingTokenWarning(
'Could not load screenshot session details; using capture timestamps.'
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
});
it('looks up the build archive URL with GraphQL', async () => {
@@ -874,6 +874,23 @@ it.each([
await host.finishAsync();
});
it('lets the session add frame ancestors and CORS origins after the website origins', async () => {
const host = await startDeviceSessionHostAsync(ctx, {
runtimePlatform: BuildRuntimePlatform.LINUX,
env,
logger,
separateLogPhase: true,
timeoutMs: 10_000,
frameAncestors: ['https://embed.example.test', 'https://*.example.test'],
corsOrigins: ['https://app.example.test'],
});
const { args } = jest.mocked(spawnDetached).mock.calls[0][0];
expect(args.join(' ')).toContain(
'--frame-ancestor https://expo.dev --frame-ancestor https://embed.example.test --frame-ancestor https://*.example.test --cors-origin https://app.example.test'
);
await host.finishAsync();
});
it('stops when expo-device-hub records no session token', async () => {
jest.mocked(spawnDetached).mockImplementationOnce(options => {
const flag = options.args.indexOf('--android-recording-directory');
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
@@ -26,7 +26,10 @@ import * as remoteDeviceRunSession from '../remoteDeviceRunSession';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
import { sleepAsync } from '../../../utils/retry';
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
import {
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
createPrivatePreviewUrlInputProvider,
createPublicPreviewInputProvider,
@@ -34,6 +37,8 @@ import {
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
spawnDetached,
startNgrokTunnelAsync,
@@ -44,8 +49,10 @@ import {
import {
EXPO_DEVICE_HUB_STATE_DIR,
corsOriginArgs,
createExpoDeviceHubArgs,
createServeSimArgs,
frameAncestorArgs,
simulatorPreviewPageUrl,
startDeviceSessionHostAsync,
waitForWebPreviewReadyAsync,
@@ -148,73 +155,70 @@ describe(spawnDetached, () => {
);
});
function createCtxMock(): CustomBuildContext {
const DEVICE_RUN_SESSION_TOKEN = 'device-run-session-token';
const DEVICE_RUN_SESSION_WORKER_URL = 'https://api.expo.test/v2/device-run-session-worker/';
function createCtxMock({ withToken = true }: { withToken?: boolean } = {}): CustomBuildContext {
return {
env: {
__API_SERVER_URL: 'https://api.expo.test',
...(withToken ? { EAS_DEVICE_RUN_SESSION_TOKEN: DEVICE_RUN_SESSION_TOKEN } : {}),
},
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
job: {
secrets: { robotAccessToken: 'robot-token' },
},
} as unknown as CustomBuildContext;
}
/** A successful device run session worker API response with the `{ data }` body. */
function workerApiResponse(data: unknown): Awaited<ReturnType<typeof turtleFetch>> {
return {
ok: true,
status: 200,
json: async () => ({ data }),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>;
}
/** The number of device run session worker API requests to the route. */
function workerApiCallCount(route: string): number {
return jest
.mocked(turtleFetch)
.mock.calls.filter(([url]) => url === `${DEVICE_RUN_SESSION_WORKER_URL}${route}`).length;
}
/**
* Serves session status polls from `results` in order, and idle stops, through the device run
* session worker API.
*/
function createStatusCtxMock(
results: (
| { status: 'NEW' | 'IN_PROGRESS' | 'STOPPED' | 'ERRORED' }
| { status: 'new' | 'in-progress' | 'stopped' | 'errored' }
| { error: Error }
| { data: unknown }
)[],
{ ensureStoppedError }: { ensureStoppedError?: Error } = {}
{ stopError }: { stopError?: Error } = {}
): CustomBuildContext {
const query = jest.fn(() => {
jest.mocked(turtleFetch).mockImplementation(async url => {
if (url === `${DEVICE_RUN_SESSION_WORKER_URL}stop`) {
if (stopError) {
throw stopError;
}
return workerApiResponse({ id: 'drs-id', status: 'stopped' });
}
const result = results.shift();
if (!result) {
throw new Error('No mocked status result available');
}
return {
toPromise: async () => {
if ('error' in result) {
throw result.error;
}
if ('data' in result) {
return { data: result.data };
}
return {
data: {
deviceRunSessions: {
byId: {
id: 'drs-id',
status: result.status,
},
},
},
};
},
};
if ('error' in result) {
throw result.error;
}
if ('data' in result) {
return workerApiResponse(result.data);
}
return workerApiResponse({ id: 'drs-id', status: result.status });
});
const mutation = jest.fn(() => ({
toPromise: async () => {
if (ensureStoppedError) {
return { error: ensureStoppedError };
}
return {
data: {
deviceRunSession: {
ensureDeviceRunSessionStopped: { id: 'drs-id', status: 'STOPPED' },
},
},
};
},
}));
return {
graphqlClient: {
query,
mutation,
},
} as unknown as CustomBuildContext;
return createCtxMock();
}
function createEnvMock(): BuildStepEnv {
@@ -321,6 +325,88 @@ describe(createPrivatePreviewUrlInputProvider, () => {
});
});
describe(createFrameAncestorsInputProvider, () => {
it('accepts an optional JSON list of origins from the session job', () => {
const input = createFrameAncestorsInputProvider()(createGlobalContextMock(), 'step-id');
expect(input).toMatchObject({
id: 'frame_ancestors',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
});
});
describe(parseFrameAncestorsInput, () => {
it('defaults to no extra origins when the step declares nothing', () => {
expect(parseFrameAncestorsInput(undefined)).toEqual([]);
});
it('returns the origins as given', () => {
expect(
parseFrameAncestorsInput(['https://embed.example.test', 'https://*.example.test'])
).toEqual(['https://embed.example.test', 'https://*.example.test']);
});
it.each([['https://embed.example.test'], [['https://embed.example.test', '']], [[1]]])(
'rejects %j',
value => {
expect(() => parseFrameAncestorsInput(value)).toThrow(
'Input "frame_ancestors" must be an array of non-empty strings, one origin each.'
);
}
);
});
describe(createCorsOriginsInputProvider, () => {
it('accepts an optional JSON list of origins from the session job', () => {
const input = createCorsOriginsInputProvider()(createGlobalContextMock(), 'step-id');
expect(input).toMatchObject({
id: 'cors_origins',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
});
});
describe(parseCorsOriginsInput, () => {
it('defaults to no extra origins and returns the origins as given', () => {
expect(parseCorsOriginsInput(undefined)).toEqual([]);
expect(parseCorsOriginsInput(['https://app.example.test'])).toEqual([
'https://app.example.test',
]);
});
it('rejects anything but a list of non-empty strings', () => {
expect(() => parseCorsOriginsInput(['https://app.example.test', ''])).toThrow(
'Input "cors_origins" must be an array of non-empty strings, one origin each.'
);
});
});
describe(corsOriginArgs, () => {
it('repeats the flag once per origin', () => {
expect(corsOriginArgs(['https://a.example.test', 'https://b.example.test'])).toEqual([
'--cors-origin',
'https://a.example.test',
'--cors-origin',
'https://b.example.test',
]);
expect(corsOriginArgs([])).toEqual([]);
});
});
describe(frameAncestorArgs, () => {
it('repeats the flag once per origin', () => {
expect(frameAncestorArgs(['https://a.example.test', 'https://b.example.test'])).toEqual([
'--frame-ancestor',
'https://a.example.test',
'--frame-ancestor',
'https://b.example.test',
]);
expect(frameAncestorArgs([])).toEqual([]);
});
});
describe(parseServeSimLaunchInputs, () => {
const darwin = { runtimePlatform: BuildRuntimePlatform.DARWIN };
@@ -488,6 +574,26 @@ describe(createServeSimArgs, () => {
]);
});
it('appends the session frame ancestors after the website args', () => {
const args = createServeSimArgs({
port: 4321,
websiteArgs: ['--frame-ancestor', 'https://expo.dev'],
frameAncestorArgs: ['--frame-ancestor', 'https://embed.example.test'],
corsOriginArgs: ['--cors-origin', 'https://app.example.test'],
shareUrl: 'https://expo.dev/simulator-preview/preview-id',
});
expect(args.slice(-8)).toEqual([
'--frame-ancestor',
'https://expo.dev',
'--frame-ancestor',
'https://embed.example.test',
'--cors-origin',
'https://app.example.test',
'--share-url',
'https://expo.dev/simulator-preview/preview-id',
]);
});
it('pins the requested package version', () => {
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' })[0]).toBe(
'@expo/[email protected]'
@@ -685,6 +791,23 @@ describe(createExpoDeviceHubArgs, () => {
]);
});
it('appends the session frame ancestors after the website args', () => {
const args = createExpoDeviceHubArgs({
port: 4321,
websiteArgs: ['--frame-ancestor', 'https://expo.dev'],
frameAncestorArgs: ['--frame-ancestor', 'https://embed.example.test'],
corsOriginArgs: ['--cors-origin', 'https://app.example.test'],
});
expect(args.slice(-6)).toEqual([
'--frame-ancestor',
'https://expo.dev',
'--frame-ancestor',
'https://embed.example.test',
'--cors-origin',
'https://app.example.test',
]);
});
it('pins the requested package version', () => {
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' })[0]).toBe(
'[email protected]'
@@ -973,6 +1096,8 @@ describe(startDeviceSessionHostAsync, () => {
jest.mocked(turtleFetch).mockImplementation(async url => {
if (url.endsWith('/turn-ice-servers')) {
return {
ok: true,
status: 200,
json: async () => ({
data: {
iceServers: [
@@ -1470,24 +1595,21 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
jest.mocked(turtleFetch).mockReset();
});
it('requests TURN ICE servers from the device run session endpoint and returns web preview args', async () => {
jest.mocked(turtleFetch).mockResolvedValue({
json: async () => ({
data: {
iceServers: [
{ urls: ['stun:stun.cloudflare.com:3478'] },
{
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
username: 'u',
credential: 'c',
},
],
},
}),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
it('requests TURN ICE servers from the device run session worker API and returns web preview args', async () => {
jest.mocked(turtleFetch).mockResolvedValue(
workerApiResponse({
iceServers: [
{ urls: ['stun:stun.cloudflare.com:3478'] },
{
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
username: 'u',
credential: 'c',
},
],
})
);
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger: createLoggerMock(),
});
@@ -1502,10 +1624,10 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
'c',
]);
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
`${DEVICE_RUN_SESSION_WORKER_URL}turn-ice-servers`,
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer robot-token' },
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
})
);
});
@@ -1514,15 +1636,33 @@ describe(fetchWebPreviewTurnArgsAsync, () => {
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
const logger = createLoggerMock();
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger,
});
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock(), { logger });
expect(args).toEqual([]);
expect(logger.warn).toHaveBeenCalled();
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
});
it('returns [] and warns when the job has no device run session token', async () => {
const logger = createLoggerMock();
const args = await fetchWebPreviewTurnArgsAsync(createCtxMock({ withToken: false }), {
logger,
});
expect(args).toEqual([]);
expect(logger.warn).toHaveBeenCalledWith(
{
err: expect.objectContaining({
message: expect.stringMatching(
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
),
}),
},
'Could not fetch Cloudflare TURN ICE servers; the web preview will fall back to P2P/STUN.'
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
});
describe(waitForDeviceRunSessionStoppedAsync, () => {
@@ -1538,7 +1678,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
});
it('continues polling until the device run session is stopped', async () => {
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }, { status: 'STOPPED' }]);
const ctx = createStatusCtxMock([{ status: 'in-progress' }, { status: 'stopped' }]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
@@ -1547,13 +1687,59 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
logger,
});
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
expect(workerApiCallCount('session')).toBe(2);
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
expect(setTimeoutCallback).not.toHaveBeenCalled();
});
it('polls the session status through the worker API with the abort signal', async () => {
const ctx = createStatusCtxMock([{ status: 'stopped' }]);
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
});
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
`${DEVICE_RUN_SESSION_WORKER_URL}session`,
'GET',
expect.objectContaining({
headers: { [DEVICE_RUN_SESSION_TOKEN_HEADER]: DEVICE_RUN_SESSION_TOKEN },
signal: expect.any(AbortSignal),
timeout: 30_000,
})
);
});
it('keeps polling while the session status is new', async () => {
const ctx = createStatusCtxMock([{ status: 'new' }, { status: 'stopped' }]);
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
});
expect(workerApiCallCount('session')).toBe(2);
});
it('fails with a system error when the job has no device run session token', async () => {
const error = await waitForDeviceRunSessionStoppedAsync({
ctx: createCtxMock({ withToken: false }),
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
}).catch(err => err);
expect(error).toBeInstanceOf(SystemError);
expect(error.message).toMatch(
/^This device run session job has no EAS_DEVICE_RUN_SESSION_TOKEN secret\./
);
expect(jest.mocked(turtleFetch)).not.toHaveBeenCalled();
});
it('returns normally when the maximum duration elapses', async () => {
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
const ctx = createStatusCtxMock([{ status: 'in-progress' }]);
const logger = createLoggerMock();
const waitPromise = waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -1567,7 +1753,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
durationTimeoutCallback();
await waitPromise;
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
expect(workerApiCallCount('session')).toBe(1);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id reached its maximum duration.'
);
@@ -1576,7 +1762,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
it('clears the duration timeout when the session stops first', async () => {
await waitForDeviceRunSessionStoppedAsync({
ctx: createStatusCtxMock([{ status: 'STOPPED' }]),
ctx: createStatusCtxMock([{ status: 'stopped' }]),
deviceRunSessionId: 'drs-id',
logger: createLoggerMock(),
maxDurationSeconds: 30,
@@ -1598,12 +1784,12 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
signal: abortController.signal,
});
expect(ctx.graphqlClient.query).not.toHaveBeenCalled();
expect(workerApiCallCount('session')).toBe(0);
expect(setTimeoutCallback).not.toHaveBeenCalled();
});
it('throws when the device run session errors', async () => {
const ctx = createStatusCtxMock([{ status: 'ERRORED' }]);
const ctx = createStatusCtxMock([{ status: 'errored' }]);
await expect(
waitForDeviceRunSessionStoppedAsync({
@@ -1617,7 +1803,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
});
it('clears the duration timeout when the build step is aborted', async () => {
const ctx = createStatusCtxMock([{ status: 'IN_PROGRESS' }]);
const ctx = createStatusCtxMock([{ status: 'in-progress' }]);
const abortController = new AbortController();
const waitPromise = waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -1630,12 +1816,12 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
abortController.abort();
await waitPromise;
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(1);
expect(workerApiCallCount('session')).toBe(1);
expect(clearTimeoutCallback).toHaveBeenCalledWith(durationTimeout);
});
it('logs and retries transient polling errors', async () => {
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'STOPPED' }]);
const ctx = createStatusCtxMock([{ error: new Error('network down') }, { status: 'stopped' }]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
@@ -1644,7 +1830,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
logger,
});
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
expect(workerApiCallCount('session')).toBe(2);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ failedStatusPollCount: 1 }),
'Could not poll device run session status; will retry.'
@@ -1656,11 +1842,8 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
);
});
it('logs and retries when the status response is missing', async () => {
const ctx = createStatusCtxMock([
{ data: { deviceRunSessions: { byId: null } } },
{ status: 'STOPPED' },
]);
it('logs and retries when the status response does not match the route contract', async () => {
const ctx = createStatusCtxMock([{ data: { id: 'drs-id' } }, { status: 'stopped' }]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
@@ -1669,7 +1852,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
logger,
});
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(2);
expect(workerApiCallCount('session')).toBe(2);
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ failedStatusPollCount: 1 }),
'Could not poll device run session status; will retry.'
@@ -1677,7 +1860,9 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
expect(jest.mocked(Sentry).capture).toHaveBeenCalledWith(
'Could not poll device run session status',
expect.objectContaining({
message: 'Device run session drs-id status response was missing.',
message: expect.stringContaining(
'GET /v2/device-run-session-worker/session returned an unexpected response'
),
}),
{ level: 'warning' }
);
@@ -1698,8 +1883,8 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
jest.useRealTimers();
});
function manyInProgressStatuses(): { status: 'IN_PROGRESS' }[] {
return Array.from({ length: 20 }, () => ({ status: 'IN_PROGRESS' as const }));
function manyInProgressStatuses(): { status: 'in-progress' }[] {
return Array.from({ length: 20 }, () => ({ status: 'in-progress' as const }));
}
it('stops the session when no activity is observed within the max idle time', async () => {
@@ -1717,15 +1902,15 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
});
// One minute at the 5-second poll interval is 12 status polls.
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(12);
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(workerApiCallCount('session')).toBe(12);
expect(workerApiCallCount('stop')).toBe(1);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id had no activity for 1 minute(s) (max idle time). Stopping the session.'
);
});
it('keeps the session alive while events keep arriving', async () => {
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'STOPPED' }]);
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'stopped' }]);
const logger = createLoggerMock();
await waitForDeviceRunSessionStoppedAsync({
@@ -1740,14 +1925,14 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
},
});
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(21);
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
expect(workerApiCallCount('session')).toBe(21);
expect(workerApiCallCount('stop')).toBe(0);
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
});
it('still returns when the session cannot be marked stopped', async () => {
const ctx = createStatusCtxMock(manyInProgressStatuses(), {
ensureStoppedError: new Error('forbidden'),
stopError: new Error('forbidden'),
});
const logger = createLoggerMock();
@@ -1761,7 +1946,7 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
},
});
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(workerApiCallCount('stop')).toBe(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
@@ -6,11 +6,13 @@ import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../../customBuildContext';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
import { uploadServeSimLogsFileAsync } from '../serveSimLogsArtifacts';
import { streamServeSimLogsToFileAsync } from '../serveSimLogsRecorder';
// This test exercises actual sockets, UTF-8 decoding, files, and PUT uploads.
// Only WWW's allocation response is substituted; it does not verify cloud storage.
// WWW's device run session worker API is served by the local server; it does not verify cloud
// storage.
jest.mock('../../../sentry');
jest.unmock('fs');
jest.unmock('node:fs');
@@ -55,7 +57,29 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se
let streamRequest: { url?: string; authorization?: string } | undefined;
let uploaded: Buffer | undefined;
let uploadedLength: string | undefined;
let uploadSessionRequest: { token?: string | string[]; body: unknown } | undefined;
server.on('request', (request, response) => {
if (request.url === '/v2/device-run-session-worker/artifact-upload-sessions') {
const chunks: Buffer[] = [];
request.on('data', chunk => chunks.push(Buffer.from(chunk)));
request.on('end', () => {
uploadSessionRequest = {
token: request.headers[DEVICE_RUN_SESSION_TOKEN_HEADER],
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
};
response.writeHead(200, { 'Content-Type': 'application/json' }).end(
JSON.stringify({
data: {
uploadSession: {
url: `${baseUrl}/upload`,
headers: { 'Content-Length': String(expected.length) },
},
},
})
);
});
return;
}
if (request.method === 'GET') {
streamRequest = { url: request.url, authorization: request.headers.authorization };
response.writeHead(200, {
@@ -95,27 +119,16 @@ it('streams selected-device logs into NDJSON and uploads the exact bytes as a se
});
expect(await readFile(filePath)).toEqual(expected);
const mutation = jest.fn().mockReturnValue({
toPromise: async () => ({
data: {
deviceRunSession: {
createArtifactUploadSession: {
uploadSession: {
url: `${baseUrl}/upload`,
headers: { 'Content-Length': String(expected.length) },
},
},
},
},
}),
});
await uploadServeSimLogsFileAsync(
{ graphqlClient: { mutation } } as unknown as CustomBuildContext,
{
env: { EAS_DEVICE_RUN_SESSION_TOKEN: 'device-run-session-token' },
expoApiV2BaseUrl: `${baseUrl}/v2/`,
} as unknown as CustomBuildContext,
{ deviceRunSessionId: 'session-test', udid: 'device-B', filePath, logger }
);
expect(mutation.mock.calls[0][1]).toEqual({
deviceRunSessionId: 'session-test',
input: {
expect(uploadSessionRequest).toEqual({
token: 'device-run-session-token',
body: {
name: 'App logs (device-B)',
filename: 'app-logs.ndjson',
kind: 'simulator-log',
@@ -0,0 +1,70 @@
import { ErrorMetadata } from '@expo/eas-build-job';
import { Response } from 'node-fetch';
import { z } from 'zod';
const MAX_RESPONSE_BODY_LENGTH = 8_000;
export class GooglePlayApiError extends Error {
constructor(
readonly status: number,
readonly apiMessage: string,
readonly reasons: string[],
// Kept for Sentry only, so build logs stay short.
readonly diagnostics: { requestPath?: string; responseBody?: string } = {}
) {
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
}
static isRetryableStatus(status: number): boolean {
return status === 408 || status === 429 || (status >= 500 && status < 600);
}
// Error metadata for the first Google Play API error in the cause chain. Retries and
// uncertain commits wrap the API error, e.g. in an AggregateError.
static getErrorMetadata(error: unknown): ErrorMetadata | undefined {
let cause = error;
for (let depth = 0; cause instanceof Error && depth < 5; depth++) {
if (cause instanceof GooglePlayApiError) {
return {
googlePlayRequestPath: cause.diagnostics.requestPath,
// A string, so Sentry does not cut off nested error details.
googlePlayErrorResponse: cause.diagnostics.responseBody,
};
}
cause = cause.cause;
}
return undefined;
}
static async fromResponseAsync(
response: Response,
signal?: AbortSignal
): Promise<GooglePlayApiError> {
let data: unknown;
try {
data = await response.json();
} catch {
signal?.throwIfAborted();
// Raw text or HTML can contain the request URL, including an upload secret.
}
const parsed = z
.object({
error: z.object({
message: z.string().optional(),
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
}),
})
.safeParse(data);
return new GooglePlayApiError(
response.status,
parsed.data?.error.message ?? '',
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? [],
{
// Path only: the upload session query contains a secret.
requestPath: response.url ? new URL(response.url).pathname : undefined,
responseBody:
data === undefined ? undefined : JSON.stringify(data).slice(0, MAX_RESPONSE_BODY_LENGTH),
}
);
}
}
@@ -5,6 +5,7 @@ import { z } from 'zod';
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
import { GooglePlayApiError } from './GooglePlayApiError';
const PostApi = {
'/token': {
request: z.object({
@@ -61,7 +62,7 @@ export class GooglePlayAuthClient {
error =>
!signal?.aborted &&
error instanceof GooglePlayAuthRequestError &&
(error.status === 429 || error.status >= 500),
GooglePlayApiError.isRetryableStatus(error.status),
{ retries: 3, factor: 2, minTimeout: 1000, maxTimeout: 4000 }
)();
}
@@ -1,45 +1,13 @@
import { SystemError } from '@expo/eas-build-job';
import fetch, { RequestInit, Response } from 'node-fetch';
import { bunyan } from '@expo/logger';
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
import { z } from 'zod';
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
import { GooglePlayApiError } from './GooglePlayApiError';
import { GooglePlayAuthUtils, GoogleServiceAccount } from './GooglePlayAuthUtils';
export class GooglePlayApiError extends Error {
constructor(
readonly status: number,
readonly apiMessage: string,
readonly reasons: string[]
) {
super(`Google Play request failed (HTTP ${status})${apiMessage ? `: ${apiMessage}` : '.'}`);
}
static async fromResponseAsync(
response: Response,
signal?: AbortSignal
): Promise<GooglePlayApiError> {
let data: unknown;
try {
data = await response.json();
} catch {
signal?.throwIfAborted();
// Raw text or HTML can contain the request URL, including an upload secret.
}
const parsed = z
.object({
error: z.object({
message: z.string().optional(),
errors: z.array(z.object({ reason: z.string().optional() })).optional(),
}),
})
.safeParse(data);
return new GooglePlayApiError(
response.status,
parsed.data?.error.message ?? '',
parsed.data?.error.errors?.flatMap(error => (error.reason ? [error.reason] : [])) ?? []
);
}
}
export const ReleaseStatusZ = z.enum(['draft', 'inProgress', 'halted', 'completed']);
export type ReleaseStatus = z.output<typeof ReleaseStatusZ>;
@@ -88,7 +56,9 @@ const PostApi = {
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit': {
path: z.object({ packageName: z.string().min(1), editId: z.string().min(1) }),
request: z.object({}),
query: z.object({ changesNotSentForReview: z.boolean().optional() }),
query: z.object({
changesNotSentForReview: z.boolean().optional(),
}),
response: z.object({ id: z.string().min(1), expiryTimeSeconds: z.string().optional() }),
},
'/androidpublisher/v3/applications/:packageName/edits/:editId:validate': {
@@ -128,7 +98,10 @@ export class GooglePlayClient {
private readonly baseUrl = 'https://androidpublisher.googleapis.com';
private cachedToken?: Promise<{ value: string; expiresAt: number }>;
constructor(private readonly serviceAccount: GoogleServiceAccount) {}
constructor(
private readonly serviceAccount: GoogleServiceAccount,
private readonly logger?: bunyan
) {}
private async getTokenAsync(signal?: AbortSignal): Promise<string> {
signal?.throwIfAborted();
@@ -204,7 +177,21 @@ export class GooglePlayClient {
for (const [key, value] of Object.entries(DeleteApi[path].path.parse(params))) {
url = url.replace(`:${key}`, encodeURIComponent(value));
}
await this.requestAsync('DELETE', url, undefined, signal);
try {
await this.requestWithRetriesAsync(
() => this.requestAsync('DELETE', url, undefined, signal),
signal
);
} catch (error) {
if (
error instanceof AggregateError &&
error.cause instanceof GooglePlayApiError &&
error.cause.status === 404
) {
return;
}
throw error;
}
}
private async sendJsonRequestAsync(
@@ -236,12 +223,16 @@ export class GooglePlayClient {
}
}
const response = await this.requestAsync(
method,
search.size ? `${path}?${search}` : path,
method === 'GET' ? undefined : JSON.stringify(parsedBody),
signal,
{ 'Content-Type': 'application/json' }
const response = await this.requestWithRetriesAsync(
() =>
this.requestAsync(
method,
search.size ? `${path}?${search}` : path,
method === 'GET' ? undefined : JSON.stringify(parsedBody),
signal,
{ 'Content-Type': 'application/json' }
),
signal
);
let data: unknown;
@@ -264,6 +255,47 @@ export class GooglePlayClient {
return parsed.data;
}
private async requestWithRetriesAsync<T>(
request: () => Promise<T>,
signal?: AbortSignal
): Promise<T> {
const errors: unknown[] = [];
try {
return await promiseRetryWithCondition(
async () => {
try {
signal?.throwIfAborted();
return await request();
} catch (error) {
errors.push(error);
throw error;
}
},
error =>
!signal?.aborted &&
(error instanceof GooglePlayApiError
? GooglePlayApiError.isRetryableStatus(error.status)
: isConnectFailure(error) || isConnectionInterruptedError(error)),
{ retries: 5, factor: 2, minTimeout: 1000, maxTimeout: 32_000, randomize: true },
({ attemptNumber, maxAttemptsCount, error }) => {
const reason =
error instanceof GooglePlayApiError ? `HTTP ${error.status}` : 'network error';
this.logger?.warn(
`Retrying Google Play request (${reason}, attempt ${attemptNumber}/${maxAttemptsCount})...`
);
}
)();
} catch (error) {
// Keep earlier failures: a later rejection does not prove that every attempt failed.
if (errors.length > 1) {
throw new AggregateError(errors, 'Google Play request failed after retries.', {
cause: error,
});
}
throw error;
}
}
async requestAsync(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
@@ -1,10 +1,12 @@
import { SystemError } from '@expo/eas-build-job';
import fetch, { FetchError, Response } from 'node-fetch';
import fetch, { Response } from 'node-fetch';
import promiseRetry from 'promise-retry';
import { FileHandle } from 'node:fs/promises';
import { z } from 'zod';
import { GooglePlayApiError, GooglePlayClient } from './GooglePlayClient';
import { isConnectFailure, isConnectionInterruptedError } from '../../../utils/networkErrors';
import { GooglePlayApiError } from './GooglePlayApiError';
import { GooglePlayClient } from './GooglePlayClient';
import { AndroidArtifactType } from './appArtifact';
const UploadResponseZ = z.object({
@@ -53,8 +55,10 @@ export namespace GooglePlayResumableUpload {
signal?.throwIfAborted();
if (
timeout.aborted ||
isNetworkRetryable(error) ||
(error instanceof GooglePlayApiError && (error.status === 429 || error.status >= 500))
isConnectFailure(error) ||
isConnectionInterruptedError(error) ||
(error instanceof GooglePlayApiError &&
GooglePlayApiError.isRetryableStatus(error.status))
) {
retry(
timeout.aborted
@@ -187,7 +191,7 @@ export namespace GooglePlayResumableUpload {
const failure = new SystemError(
'Google Play upload failed before a response was received.'
);
if (timeout.aborted || isNetworkRetryable(error)) {
if (timeout.aborted || isConnectFailure(error) || isConnectionInterruptedError(error)) {
checkServerOffset = true;
retry(failure);
}
@@ -200,7 +204,7 @@ export namespace GooglePlayResumableUpload {
checkServerOffset = true;
return retry(new SystemError('Google Play upload response timed out.'));
}
if (response.status === 429 || response.status >= 500) {
if (GooglePlayApiError.isRetryableStatus(response.status)) {
checkServerOffset = true;
retry(error);
}
@@ -276,15 +280,3 @@ function getContentTypeFromArtifactType(artifactType: AndroidArtifactType): stri
? 'application/vnd.android.package-archive'
: 'application/octet-stream';
}
function isNetworkRetryable(error: unknown): boolean {
const visited = new Set<Error>();
while (error instanceof Error && !visited.has(error)) {
if (error instanceof FetchError && error.type === 'system') {
return true;
}
visited.add(error);
error = error.cause;
}
return false;
}
@@ -3,8 +3,11 @@ import { bunyan } from '@expo/logger';
import fs from 'node:fs/promises';
import promiseRetry from 'promise-retry';
import { promiseRetryWithCondition } from '../../../utils/promiseRetryWithCondition';
import { GooglePlayApiError } from './GooglePlayApiError';
import { GooglePlayAuthRequestError } from './GooglePlayAuthClient';
import { GooglePlayApiError, GooglePlayClient, ReleaseStatus } from './GooglePlayClient';
import { GooglePlayClient, ReleaseStatus } from './GooglePlayClient';
import { GooglePlayResumableUpload } from './GooglePlayResumableUpload';
import { AndroidArtifactType } from './appArtifact';
@@ -141,7 +144,7 @@ export namespace GooglePlayUtils {
signal?: AbortSignal;
}
): Promise<void> {
const commitOnceAsync = async ({
const sendCommitAsync = async ({
changesNotSentForReview,
}: {
changesNotSentForReview?: boolean;
@@ -163,13 +166,11 @@ export namespace GooglePlayUtils {
}
if (
!(error instanceof GooglePlayApiError) ||
error.status >= 500 ||
error.status === 408 ||
error.status === 429
GooglePlayApiError.isRetryableStatus(error.status)
) {
throw new GooglePlayCommitOutcomeUnknownError(
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again. The request was not retried because it may have succeeded.`,
{ cause: error }
`Could not confirm whether Google Play saved the release for ${packageName}. Check its release status at https://play.google.com/console/ before submitting again.`,
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
}
throw error;
@@ -180,10 +181,42 @@ export namespace GooglePlayUtils {
: 'Google Play saved the release using its normal review process. Check its review and publishing status at https://play.google.com/console/.'
);
};
// Google can accept the bundle upload before the edit is ready to commit.
// Retry only this explicit rejection, not other HTTP 400 preconditions.
const isUploadStillProcessing = (error: unknown): boolean =>
error instanceof GooglePlayApiError &&
error.status === 400 &&
error.apiMessage === 'Some of the Android App Bundle uploads are not completed yet.';
const commitAsync = async (options: { changesNotSentForReview?: boolean }): Promise<void> => {
try {
await promiseRetryWithCondition(
sendCommitAsync,
error => {
signal?.throwIfAborted();
return isUploadStillProcessing(error);
},
{ 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}).`
);
}
)(options);
} catch (error) {
if (isUploadStillProcessing(error)) {
throw new SystemError(
`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) }
);
}
throw error;
}
};
try {
// Omit the optional parameter for normal review submission. Some app states
// reject its presence, even when false. True saves changes for review in Console.
await commitOnceAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
await commitAsync({ changesNotSentForReview: changesNotSentForReview || undefined });
} catch (error) {
if (!(error instanceof GooglePlayApiError) || error.status !== 400) {
throw error;
@@ -196,7 +229,7 @@ export namespace GooglePlayUtils {
logger.warn(
'Google does not allow changesNotSentForReview for this app right now. Retrying commit once without it. The changes will be sent for review.'
);
await commitOnceAsync({});
await commitAsync({});
} else if (
!changesNotSentForReview &&
error.apiMessage.includes('Please set the query parameter changesNotSentForReview to true')
@@ -204,7 +237,7 @@ export namespace GooglePlayUtils {
logger.warn(
'Google requires changesNotSentForReview=true. Retrying commit once. Send the saved changes for review from Play Console.'
);
await commitOnceAsync({ changesNotSentForReview: true });
await commitAsync({ changesNotSentForReview: true });
} else {
throw error;
}
@@ -212,21 +245,23 @@ export namespace GooglePlayUtils {
}
export function mapGooglePlayError(error: unknown, packageName: string): unknown {
if (error instanceof AggregateError) {
return mapGooglePlayError(error.cause, packageName);
}
if (
(error instanceof GooglePlayApiError || error instanceof GooglePlayAuthRequestError) &&
(error.status === 408 || error.status === 429 || error.status >= 500)
GooglePlayApiError.isRetryableStatus(error.status)
) {
return new SystemError(
`Google Play is temporarily unavailable or rate limited (HTTP ${error.status}). Try again later.`,
{ cause: error }
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
}
if (
error instanceof GooglePlayAuthRequestError &&
error.status >= 400 &&
error.status < 500 &&
error.status !== 408 &&
error.status !== 429
!GooglePlayApiError.isRetryableStatus(error.status)
) {
return new UserError(
'EAS_GOOGLE_PLAY_INVALID_CREDENTIALS',
@@ -282,9 +317,7 @@ export namespace GooglePlayUtils {
return new UserError(
code,
`${detail}\n\nApp: ${packageName}\nGoogle Play Console: https://play.google.com/console/`,
{
cause: error,
}
{ cause: error, metadata: GooglePlayApiError.getErrorMetadata(error) }
);
}
}
@@ -1,9 +1,9 @@
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import { SystemError } from '@expo/eas-build-job';
import { ZodError } from 'zod';
import { GooglePlayClient } from '../GooglePlayClient';
import { createMockLogger } from '../../../../__tests__/utils/logger';
const packageName = 'dev.expo.submitfixture';
const editPath = `/androidpublisher/v3/applications/${packageName}/edits/edit`;
@@ -13,6 +13,11 @@ const api = (): nock.Scope =>
});
jest.unmock('node-fetch');
jest.mock('promise-retry', () => {
const actual = jest.requireActual('promise-retry');
return (fn: Parameters<typeof actual>[0], options: object) =>
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
});
const serviceAccount = {
client_email: '[email protected]',
@@ -75,11 +80,12 @@ it('rejects invalid rollout requests before sending them', async () => {
).rejects.toBeInstanceOf(ZodError);
});
it('does not retry an ambiguous edit commit', async () => {
it('keeps the outcome unknown when a lost commit response is followed by an invalid edit', async () => {
api()
.post(`${editPath}:commit`, {})
.query({ changesNotSentForReview: false })
.replyWithError('Lost response');
.replyWithError(Object.assign(new Error('Lost response'), { code: 'ECONNRESET' }));
api().post(`${editPath}:commit`, {}).query({ changesNotSentForReview: false }).reply(404);
await expect(
client.postAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId:commit',
@@ -87,7 +93,62 @@ it('does not retry an ambiguous edit commit', async () => {
{ packageName, editId: 'edit' },
{ query: { changesNotSentForReview: false } }
)
).rejects.toBeInstanceOf(SystemError);
).rejects.toMatchObject({
errors: [expect.any(Error), expect.objectContaining({ status: 404 })],
});
});
it.each([408, 429, 500, 503])('stops after five retries for HTTP %s', async status => {
api().get(editPath).times(6).reply(status);
await expect(
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).rejects.toMatchObject({
cause: expect.objectContaining({ status }),
errors: expect.arrayContaining([expect.objectContaining({ status })]),
});
});
it('retries the same track update body after a temporary error', async () => {
const body = {
track: 'internal',
releases: [{ versionCodes: ['123'], status: 'draft' as const }],
};
api().put(`${editPath}/tracks/internal`, body).reply(503);
api().put(`${editPath}/tracks/internal`, body).reply(200, { track: 'internal' });
await expect(
client.putAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId/tracks/:track',
body,
{ packageName, editId: 'edit', track: 'internal' }
)
).resolves.toEqual({ track: 'internal' });
});
it('does not add retries to upload-session requests', async () => {
api().post('/upload/session').reply(503);
await expect(client.requestAsync('POST', '/upload/session')).rejects.toMatchObject({
status: 503,
});
});
it('stops retrying when cancelled after a transient response', async () => {
const controller = new AbortController();
api()
.get(editPath)
.reply(() => {
controller.abort();
return [503];
});
await expect(
client.getAsync(
'/androidpublisher/v3/applications/:packageName/edits/:editId',
{ packageName, editId: 'edit' },
controller.signal
)
).rejects.toBeDefined();
});
it('deletes an edit with an empty response', async () => {
@@ -100,6 +161,44 @@ it('deletes an edit with an empty response', async () => {
).resolves.toBeUndefined();
});
it('reports a retry even when the next request succeeds', async () => {
const logger = createMockLogger();
client = new GooglePlayClient(serviceAccount, logger);
api().get(editPath).reply(503);
api().get(editPath).reply(200, { id: 'edit' });
await client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
});
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
'Retrying Google Play request (HTTP 503, attempt 2/6)...'
);
});
it('accepts a missing edit after a lost delete response', async () => {
api()
.delete(editPath)
.replyWithError(Object.assign(new Error('Lost response'), { code: 'ECONNRESET' }));
api().delete(editPath).reply(404);
await expect(
client.deleteAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).resolves.toBeUndefined();
});
it('retains an initial delete rejection', async () => {
api().delete(editPath).reply(404);
await expect(
client.deleteAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).rejects.toMatchObject({ status: 404 });
});
it('rejects malformed success responses without exposing their contents', async () => {
api()
.get(editPath)
@@ -124,6 +223,42 @@ it('retains structured API errors', async () => {
).rejects.toMatchObject({ status: 403, apiMessage: 'Denied', reasons: ['forbidden'] });
});
it.each(['CERT_HAS_EXPIRED', 'EPROTO', 'EACCES', undefined])(
'does not retry a system error with code %s',
async code => {
const logger = createMockLogger();
client = new GooglePlayClient(serviceAccount, logger);
api()
.get(editPath)
.replyWithError(Object.assign(new Error('Request failed'), { code }));
await expect(
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).rejects.toMatchObject({
cause: expect.objectContaining({ type: 'system' }),
});
expect(logger.warn).not.toHaveBeenCalled();
}
);
it.each(['EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'UND_ERR_SOCKET'])(
'retries a recognised network error %s',
async code => {
api()
.get(editPath)
.replyWithError(Object.assign(new Error('Request failed'), { code }));
api().get(editPath).reply(200, { id: 'edit' });
await expect(
client.getAsync('/androidpublisher/v3/applications/:packageName/edits/:editId', {
packageName,
editId: 'edit',
})
).resolves.toEqual({ id: 'edit' });
}
);
it.each(['body', 'query'])('accepts extra fields in an empty %s object', async field => {
api()
.post(`/androidpublisher/v3/applications/${packageName}/edits`, {})
@@ -1,13 +1,20 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import nock from 'nock';
import { Response } from 'node-fetch';
import { api, editPath, mockToken, packageName, serviceAccount } from './googlePlayTestUtils';
import { createMockLogger } from '../../../../__tests__/utils/logger';
import { GooglePlayAuthRequestError } from '../GooglePlayAuthClient';
import { GooglePlayApiError, GooglePlayClient } from '../GooglePlayClient';
import { GooglePlayUtils } from '../GooglePlayUtils';
import { GooglePlayApiError } from '../GooglePlayApiError';
import { GooglePlayClient } from '../GooglePlayClient';
import { GooglePlayCommitOutcomeUnknownError, GooglePlayUtils } from '../GooglePlayUtils';
jest.unmock('node-fetch');
jest.mock('promise-retry', () => {
const actual = jest.requireActual('promise-retry');
return (fn: Parameters<typeof actual>[0], options: object) =>
actual(fn, { ...options, minTimeout: 1, maxTimeout: 1, randomize: false });
});
beforeEach(() => nock.disableNetConnect());
afterEach(() => {
const pending = nock.pendingMocks();
@@ -71,10 +78,11 @@ it.each([
);
}
);
it.each([408, 429, 503])('does not retry an uncertain HTTP %s commit', async status => {
it.each([408, 429, 503])('retries a transient HTTP %s commit on the same edit', async status => {
mockToken();
api().post(`${editPath}:commit`).query(true).reply(status);
await expect(commit()).rejects.toMatchObject({ cause: expect.objectContaining({ status }) });
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'edit' });
await expect(commit()).resolves.toBeUndefined();
});
it('preserves cancellation before commit is sent', async () => {
const controller = new AbortController();
@@ -82,6 +90,74 @@ it('preserves cancellation before commit is sent', async () => {
controller.abort(reason);
await expect(commit({ signal: controller.signal })).rejects.toBe(reason);
});
const pendingUploadMessage = 'Some of the Android App Bundle uploads are not completed yet.';
it.each([false, true])(
'waits for bundle processing without changing review settings (%s)',
async changesNotSentForReview => {
const logger = createMockLogger();
const query = changesNotSentForReview ? { changesNotSentForReview: 'true' } : {};
mockToken();
api()
.post(`${editPath}:commit`, {})
.query(query)
.reply(400, { error: { message: pendingUploadMessage } });
api().post(`${editPath}:commit`, {}).query(query).reply(200, { id: 'edit' });
await expect(commit({ changesNotSentForReview, logger })).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Google Play is still processing')
);
}
);
it('stops waiting for bundle processing after six attempts', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.times(6)
.reply(400, { error: { message: pendingUploadMessage } });
const error = await commit().catch(error => error);
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toMatchObject({
message: expect.stringContaining('Google Play did not finish processing'),
metadata: {
googlePlayRequestPath: `${editPath}:commit`,
googlePlayErrorResponse: JSON.stringify({ error: { message: pendingUploadMessage } }),
},
});
});
it('does not retry an unrelated commit precondition', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: 'Precondition check failed.' } });
await expect(commit()).rejects.toBeInstanceOf(GooglePlayApiError);
});
it('checks cancellation before sending a processing retry', async () => {
const controller = new AbortController();
const reason = new Error('cancelled while waiting');
const logger = createMockLogger();
jest.spyOn(logger, 'warn').mockImplementation(() => controller.abort(reason));
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: pendingUploadMessage } });
await expect(commit({ signal: controller.signal, logger })).rejects.toBe(reason);
});
it('does not report success if a processing retry has no valid commit receipt', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.reply(400, { error: { message: pendingUploadMessage } });
api().post(`${editPath}:commit`).query({}).reply(200, { id: 'different-edit' });
await expect(commit()).rejects.toBeInstanceOf(GooglePlayCommitOutcomeUnknownError);
});
it('preserves OAuth rejection before commit is sent', async () => {
nock('https://oauth2.googleapis.com').post('/token').reply(400, { error: 'invalid_grant' });
await expect(commit()).rejects.toBeInstanceOf(GooglePlayAuthRequestError);
@@ -140,3 +216,56 @@ it('keeps non-Google errors unchanged', () => {
const error = new SystemError('file failure');
expect(GooglePlayUtils.mapGooglePlayError(error, packageName)).toBe(error);
});
it('keeps the Google Play error response in metadata, not in the message', async () => {
const response = {
error: {
code: 400,
message: 'Precondition check failed.',
status: 'FAILED_PRECONDITION',
details: [
{
'@type': 'type.googleapis.com/google.rpc.PreconditionFailure',
violations: [{ type: 'APP_SETUP', description: 'Complete the app setup.' }],
},
],
},
};
mockToken();
api().post(`${editPath}:commit`).query({}).reply(400, response);
const error = await commit().catch(error => error);
const mapped = GooglePlayUtils.mapGooglePlayError(error, packageName);
expect(mapped).toMatchObject({
errorCode: 'EAS_GOOGLE_PLAY_INVALID_RELEASE',
metadata: {
googlePlayRequestPath: `${editPath}:commit`,
googlePlayErrorResponse: JSON.stringify(response),
},
});
expect((mapped as Error).message).not.toContain('APP_SETUP');
});
it('keeps the last Google Play error response when the commit outcome is unknown', async () => {
mockToken();
api()
.post(`${editPath}:commit`)
.query({})
.times(6)
.reply(503, { error: { message: 'Backend error' } });
await expect(commit()).rejects.toMatchObject({
metadata: {
googlePlayRequestPath: `${editPath}:commit`,
googlePlayErrorResponse: JSON.stringify({ error: { message: 'Backend error' } }),
},
});
});
it('does not keep the upload session query or a non-JSON response', async () => {
const error = await GooglePlayApiError.fromResponseAsync(
new Response('<html>upload_id=SECRET</html>', {
status: 400,
url: 'https://androidpublisher.googleapis.com/upload/session?upload_id=SECRET',
})
);
expect(GooglePlayApiError.getErrorMetadata(error)).toEqual({
googlePlayRequestPath: '/upload/session',
googlePlayErrorResponse: undefined,
});
});
@@ -89,14 +89,16 @@ it('uploads aligned chunks and reports server-confirmed progress', async () => {
]);
});
it.each(['network', 429, 503])(
it.each(['network', 408, 429, 503])(
'recovers the accepted offset after a %s interruption',
async failure => {
mockToken();
mockStart();
const interrupted = uploadApi().put(session).matchHeader('content-range', 'bytes 0-11/12');
if (failure === 'network') {
interrupted.replyWithError('connection reset');
interrupted.replyWithError(
Object.assign(new Error('connection reset'), { code: 'ECONNRESET' })
);
} else {
interrupted.reply(failure as number, { error: { message: 'retry' } });
}
@@ -121,7 +123,7 @@ it('recovers a completed upload when the final upload response is lost', async (
uploadApi()
.put(session)
.matchHeader('content-length', '12')
.replyWithError('lost final response');
.replyWithError(Object.assign(new Error('lost final response'), { code: 'ECONNRESET' }));
status().reply(200, { versionCode: 42 });
await expect(upload()).resolves.toBe(42);
});
@@ -286,7 +288,7 @@ it('retries a network failure during session creation', async () => {
api()
.post(`/upload${editPath}/apks`)
.query({ uploadType: 'resumable' })
.replyWithError('connection reset');
.replyWithError(Object.assign(new Error('connection reset'), { code: 'ECONNRESET' }));
mockStart();
uploadApi().put(session).reply(200, { versionCode: 42 });
await expect(upload()).resolves.toBe(42);
@@ -347,7 +349,9 @@ it.each([
mockStart();
const finalChunk = uploadApi().put(session).matchHeader('content-length', '12');
if (finalResponse === 'lost') {
finalChunk.replyWithError('lost final response');
finalChunk.replyWithError(
Object.assign(new Error('lost final response'), { code: 'ECONNRESET' })
);
} else {
finalChunk.reply(308, '', { Range: 'bytes=0-11' });
}
@@ -1,8 +1,6 @@
import { SystemError } from '@expo/eas-build-job';
import { graphql } from 'gql.tada';
import { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import {
ArtifactPutError,
ArtifactPutNetworkError,
@@ -11,23 +9,7 @@ import {
putArtifactWithRetriesAsync,
} from './artifactPut';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionArtifactUploadSession(
$deviceRunSessionId: ID!
$input: CreateDeviceRunSessionArtifactUploadSessionInput!
) {
deviceRunSession {
createArtifactUploadSession(deviceRunSessionId: $deviceRunSessionId, input: $input) {
uploadSession {
url
headers
}
}
}
}
`);
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
type ArtifactMetadata = {
deviceRunSessionId: string;
@@ -72,54 +54,28 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
signal: AbortSignal
): Promise<ArtifactUploadSession> {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
let uploadSession: ArtifactUploadSession;
try {
uploadSession = await workerApi.createArtifactUploadSessionAsync(
{
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
},
{ signal }
);
} catch (err) {
signal.throwIfAborted();
throw new SystemError(
`Failed to create upload session for device run session artifact ${artifactId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
signal.throwIfAborted();
return uploadSession;
}
const result = await ctx.graphqlClient
.mutation(
CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION,
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
let uploadSession: ArtifactUploadSession;
try {
uploadSession = await workerApi.createArtifactUploadSessionAsync(
{
deviceRunSessionId,
input: {
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
},
name,
filename,
...(kind !== undefined ? { kind } : {}),
...(metadata !== undefined ? { metadata } : {}),
size,
},
graphqlAbortContext(signal)
)
.toPromise();
signal.throwIfAborted();
if (result.error) {
{ signal }
);
} catch (err) {
signal.throwIfAborted();
throw new SystemError(
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
{ cause: result.error }
`Failed to create upload session for device run session artifact ${artifactId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
return result.data!.deviceRunSession.createArtifactUploadSession.uploadSession;
signal.throwIfAborted();
return uploadSession;
}
@@ -1,6 +1,5 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { graphql } from 'gql.tada';
import fs from 'node:fs';
import { StringDecoder } from 'node:string_decoder';
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
@@ -10,26 +9,13 @@ import HttpLogStream from '../../logging/HttpLogStream';
import RemoteLoggerStream from '../../logging/RemoteLoggerStream';
import { Sentry } from '../../sentry';
import { type SignedUrl } from '../../storage/uploadWithSignedUrl';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const POLL_INTERVAL_MS = 1_000;
const UPLOAD_INTERVAL_MS = 5_000;
const EAS_LOGS_THREAD = 'session-events';
const EAS_LOGS_BUFFER_RETENTION_MS = 30_000;
const CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionEventLogUploadSession($deviceRunSessionId: ID!) {
deviceRunSession {
createEventLogUploadSession(deviceRunSessionId: $deviceRunSessionId) {
uploadSession {
url
headers
}
}
}
}
`);
/**
* The normalized, producer-agnostic event shape uploaded to the API server.
* Every remote-session producer (agent-device, argent, ...) tails its own
@@ -350,35 +336,17 @@ async function createEventLogUploadSessionAsync(
ctx: CustomBuildContext,
deviceRunSessionId: string
): Promise<SignedUrl> {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
try {
return await workerApi.createEventLogUploadSessionAsync();
} catch (err) {
throw new SystemError(
`Failed to create device run session event log upload session: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
}
const result = await ctx.graphqlClient
.mutation(CREATE_DEVICE_RUN_SESSION_EVENT_LOG_UPLOAD_SESSION_MUTATION, {
deviceRunSessionId,
})
.toPromise();
if (result.error) {
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
try {
return await workerApi.createEventLogUploadSessionAsync();
} catch (err) {
throw new SystemError(
`Failed to create device run session event log upload session: ${result.error.message}`,
{ cause: result.error }
`Failed to create device run session event log upload session: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
const uploadSession = result.data!.deviceRunSession.createEventLogUploadSession.uploadSession;
return {
url: uploadSession.url,
headers: uploadSession.headers as Record<string, string>,
};
}
async function collectEventFileAsync({
@@ -2,7 +2,6 @@ import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
@@ -12,25 +11,12 @@ import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import { killProcessGroup } from '../../utils/processes';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const PREVIEW_INTERVAL_MS = 60_000;
const PREVIEW_MAX_DIMENSION = 320;
const PREVIEW_QUALITY = 70;
const MAX_PREVIEW_SIZE_BYTES = 5 * 1024 * 1024;
const CREATE_PREVIEW_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateDeviceRunSessionPreviewUploadSession($deviceRunSessionId: ID!) {
deviceRunSession {
createPreviewUploadSession(deviceRunSessionId: $deviceRunSessionId) {
uploadSession {
url
headers
}
}
}
}
`);
/**
* node-fetch includes the request URL in its error messages, and this URL is a signed upload URL
* that is valid for the whole session. Replace such errors with one that names only the failure.
@@ -122,35 +108,7 @@ async function createPreviewUploadSessionAsync(
deviceRunSessionId: string,
signal: AbortSignal
): Promise<{ url: string; headers: Record<string, string> }> {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
return await workerApi.createPreviewUploadSessionAsync({ signal });
}
const result = await ctx.graphqlClient
.mutation(
CREATE_PREVIEW_UPLOAD_SESSION_MUTATION,
{ deviceRunSessionId },
{
// Keep the client's auth headers and combine cancellation after URQL sets its signal.
fetch: (url, options) =>
globalThis.fetch(url, {
...options,
signal: AbortSignal.any([signal, ...(options?.signal ? [options.signal] : [])]),
}),
}
)
.toPromise();
if (result.error) {
throw result.error;
}
const session = result.data?.deviceRunSession.createPreviewUploadSession.uploadSession;
if (!session) {
throw new Error('No session preview upload URL was returned.');
}
return {
url: session.url,
headers: session.headers as Record<string, string>,
};
return await requireDeviceRunSessionWorkerApi(ctx).createPreviewUploadSessionAsync({ signal });
}
/**
@@ -1,11 +1,8 @@
import type { bunyan } from '@expo/logger';
import { BuildRuntimePlatform } from '@expo/steps';
import { graphql } from 'gql.tada';
import { z } from 'zod';
import type { CustomBuildContext } from '../../customBuildContext';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
const SHORT_COMMIT_HASH_LENGTH = 7;
const MAX_APP_SLUG_LENGTH = 80;
@@ -17,38 +14,9 @@ const LEADING_OR_TRAILING_DOTS_AND_HYPHENS = /^[.-]+|[.-]+$/g;
// A full git commit hash: 40 hex characters for SHA-1 or 64 for SHA-256.
const FULL_GIT_COMMIT_HASH = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i;
const SCREENSHOT_SESSION_QUERY = graphql(`
query ScreenshotSession($deviceRunSessionId: ID!) {
deviceRunSessions {
byId(deviceRunSessionId: $deviceRunSessionId) {
platform
app { slug }
build {
id
gitCommitHash
app { slug }
}
}
}
}
`);
const AppSchema = z.object({ slug: z.string() });
const ScreenshotSessionSchema = z.object({
platform: z.enum(['IOS', 'ANDROID']),
app: AppSchema,
build: z
.object({
id: z.string(),
gitCommitHash: z.string().nullable(),
app: AppSchema,
})
.nullable(),
});
/**
* Session details used to name screenshots. `app` is null when the source does not report an app
* slug: the device run session worker API does not return one.
* Session details used to name screenshots. `app` is always null: the device run session worker
* API does not return the app slug.
*/
export type ScreenshotSession = {
platform: 'IOS' | 'ANDROID';
@@ -63,30 +31,22 @@ export async function loadScreenshotSessionAsync(
): Promise<ScreenshotSession | null> {
try {
const signal = AbortSignal.timeout(SESSION_LOOKUP_TIMEOUT_MS);
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
const application = await workerApi.getApplicationAsync({ signal });
return {
// www runs iOS sessions only on macOS workers and Android sessions only on Linux workers,
// so the worker API does not return the platform.
platform: ctx.runtimePlatform === BuildRuntimePlatform.DARWIN ? 'IOS' : 'ANDROID',
app: null,
build: application.build
? {
id: application.build.id,
gitCommitHash: application.build.gitCommitHash,
app: null,
}
: null,
};
}
const result = await ctx.graphqlClient
.query(SCREENSHOT_SESSION_QUERY, { deviceRunSessionId }, graphqlAbortContext(signal))
.toPromise();
if (result.error) {
throw result.error;
}
return ScreenshotSessionSchema.parse(result.data?.deviceRunSessions.byId);
const application = await requireDeviceRunSessionWorkerApi(ctx).getApplicationAsync({
signal,
});
return {
// www runs iOS sessions only on macOS workers and Android sessions only on Linux workers,
// so the worker API does not return the platform.
platform: ctx.runtimePlatform === BuildRuntimePlatform.DARWIN ? 'IOS' : 'ANDROID',
app: null,
build: application.build
? {
id: application.build.id,
gitCommitHash: application.build.gitCommitHash,
app: null,
}
: null,
};
} catch (err) {
logger.warn(
{ err, deviceRunSessionId },
@@ -20,24 +20,10 @@ export const DEVICE_RUN_SESSION_TOKEN_HEADER = 'expo-device-run-session-token';
const DEVICE_RUN_SESSION_WORKER_PATH = 'device-run-session-worker/';
/**
* www returns the entity value of the status. The client maps it to the GraphQL enum value, so
* callers compare statuses the same way on both paths.
*/
const DEVICE_RUN_SESSION_STATUS_FROM_ENTITY_VALUE = {
new: 'NEW',
queued: 'QUEUED',
starting: 'STARTING',
'in-progress': 'IN_PROGRESS',
stopped: 'STOPPED',
errored: 'ERRORED',
} as const;
const DeviceRunSessionStatusSchema = z.object({
id: z.string(),
status: z
.enum(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored'])
.transform(status => DEVICE_RUN_SESSION_STATUS_FROM_ENTITY_VALUE[status]),
// The status entity values that www returns.
status: z.enum(['new', 'queued', 'starting', 'in-progress', 'stopped', 'errored']),
});
const UploadSessionSchema = z.object({
@@ -307,6 +293,24 @@ export function getDeviceRunSessionWorkerApi(
return new DeviceRunSessionWorkerApi(getExpoApiV2BaseUrl(ctx), token);
}
/**
* Returns the client for the device run session worker API, which every device run session job
* uses to call www about its session.
*/
export function requireDeviceRunSessionWorkerApi(
ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>
): DeviceRunSessionWorkerApi {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (!workerApi) {
throw new SystemError(
`This device run session job has no ${DEVICE_RUN_SESSION_TOKEN_ENV} secret. ` +
'www gives one to every device run session job, and the worker needs it to call the ' +
'device run session API. Start a new session, and contact support if it happens again.'
);
}
return workerApi;
}
function getExpoApiV2BaseUrl(ctx: Pick<CustomBuildContext, 'env' | 'expoApiV2BaseUrl'>): string {
if (ctx.expoApiV2BaseUrl) {
return ctx.expoApiV2BaseUrl;
@@ -102,6 +102,16 @@ export function websiteOriginArgs(env: BuildStepEnv): string[] {
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
}
// Both hosts take the same flag; it only applies under --require-token, which both run with.
export function frameAncestorArgs(frameAncestors: readonly string[]): string[] {
return frameAncestors.flatMap(origin => ['--frame-ancestor', origin]);
}
// Both hosts take the same flag and match the same origin shapes.
export function corsOriginArgs(corsOrigins: readonly string[]): string[] {
return corsOrigins.flatMap(origin => ['--cors-origin', origin]);
}
function createServeSimPackageSpec(packageVersion: string | undefined): string {
return `${SERVE_SIM_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
}
@@ -114,6 +124,8 @@ export function createServeSimArgs({
port,
turnArgs = [],
websiteArgs = [],
frameAncestorArgs: frameArgs = [],
corsOriginArgs: corsArgs = [],
shareUrl,
disableShare = false,
packageVersion,
@@ -128,6 +140,8 @@ export function createServeSimArgs({
port: number;
turnArgs?: string[];
websiteArgs?: string[];
frameAncestorArgs?: string[];
corsOriginArgs?: string[];
shareUrl?: string;
disableShare?: boolean;
packageVersion?: string;
@@ -158,6 +172,8 @@ export function createServeSimArgs({
SERVE_SIM_VIDEO_FPS,
...turnArgs,
...websiteArgs,
...frameArgs,
...corsArgs,
...(shareUrl ? ['--share-url', shareUrl] : []),
...(disableShare ? ['--disable-share'] : []),
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
@@ -179,12 +195,16 @@ export function createExpoDeviceHubArgs({
port,
turnArgs = [],
websiteArgs = [],
frameAncestorArgs: frameArgs = [],
corsOriginArgs: corsArgs = [],
packageVersion,
recordingDirectory,
}: {
port: number;
turnArgs?: string[];
websiteArgs?: string[];
frameAncestorArgs?: string[];
corsOriginArgs?: string[];
packageVersion?: string;
recordingDirectory?: string;
}): string[] {
@@ -214,6 +234,8 @@ export function createExpoDeviceHubArgs({
...(recordingDirectory ? ['--android-recording-directory', recordingDirectory] : []),
...turnArgs,
...websiteArgs,
...frameArgs,
...corsArgs,
];
}
@@ -353,6 +375,10 @@ type DeviceSessionHostOptions = {
packageVersion?: string;
disableShare?: boolean;
privatePreviewUrl?: string;
/** Origins allowed to embed the preview host, in addition to the website origins. */
frameAncestors?: string[];
/** Origins whose pages may call the preview host's API, in addition to the website origins. */
corsOrigins?: string[];
iosSimulatorUdid?: string;
installAppPath?: string;
bootEnv?: Record<string, string>;
@@ -403,6 +429,8 @@ async function startDeviceSessionHostInternalAsync(
onStartupError,
disableShare = false,
privatePreviewUrl,
frameAncestors = [],
corsOrigins = [],
launchAppIdentifier,
launchArgs,
openUrl,
@@ -464,7 +492,7 @@ async function startDeviceSessionHostInternalAsync(
const packageSpec = isAndroid
? createExpoDeviceHubPackageSpec(packageVersion)
: createServeSimPackageSpec(packageVersion);
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { logger });
const previewExec = resolvePackageExec(
resolveConfiguredPackageManager(env, PackageManager.NPM),
isAndroid
@@ -474,6 +502,8 @@ async function startDeviceSessionHostInternalAsync(
packageVersion,
recordingDirectory: recording?.directory,
websiteArgs: websiteOriginArgs(env),
frameAncestorArgs: frameAncestorArgs(frameAncestors),
corsOriginArgs: corsOriginArgs(corsOrigins),
})
: createServeSimArgs({
port,
@@ -481,6 +511,8 @@ async function startDeviceSessionHostInternalAsync(
packageVersion,
iosSimulatorUdid,
websiteArgs: websiteOriginArgs(env),
frameAncestorArgs: frameAncestorArgs(frameAncestors),
corsOriginArgs: corsOriginArgs(corsOrigins),
// serve-sim adds its bearer token to --share-url; private links are for worker logs only.
shareUrl: disableShare ? undefined : previewPageUrl,
disableShare,
@@ -10,8 +10,6 @@ import {
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { graphql } from 'gql.tada';
import nullthrows from 'nullthrows';
import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
@@ -29,49 +27,15 @@ import {
import { Sentry } from '../../sentry';
import { isProcessGroupRunning } from '../../utils/processes';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
import { createProcessOutput } from './processOutput';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import { getDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
import {
type DeviceRunSessionStatus,
requireDeviceRunSessionWorkerApi,
} from './deviceRunSessionWorkerApi';
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
deviceRunSession {
startDeviceRunSession(
deviceRunSessionId: $deviceRunSessionId
remoteConfig: $remoteConfig
) {
id
status
}
}
}
`);
const DEVICE_RUN_SESSION_STATUS_QUERY = graphql(`
query DeviceRunSessionStatus($deviceRunSessionId: ID!) {
deviceRunSessions {
byId(deviceRunSessionId: $deviceRunSessionId) {
id
status
}
}
}
`);
const ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION = graphql(`
mutation EnsureDeviceRunSessionStopped($deviceRunSessionId: ID!) {
deviceRunSession {
ensureDeviceRunSessionStopped(deviceRunSessionId: $deviceRunSessionId) {
id
status
}
}
}
`);
const DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS = 5_000;
// A request to the device run session worker API that hangs counts as a failed poll or stop.
const DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS = 30_000;
@@ -230,15 +194,12 @@ export async function waitForDeviceRunSessionStoppedAsync({
}
try {
const status = await fetchDeviceRunSessionStatusAsync(ctx, deviceRunSessionId, signal);
if (!status) {
throw new Error(`Device run session ${deviceRunSessionId} status response was missing.`);
}
pollErrorCount = 0;
if (status === 'STOPPED') {
if (status === 'stopped') {
logger.info(`Device run session ${deviceRunSessionId} was stopped.`);
return;
}
if (status === 'ERRORED') {
if (status === 'errored') {
throw new SystemError(`Device run session ${deviceRunSessionId} errored.`);
}
} catch (err) {
@@ -269,24 +230,14 @@ async function fetchDeviceRunSessionStatusAsync(
ctx: CustomBuildContext,
deviceRunSessionId: string,
signal: AbortSignal
): Promise<string | undefined> {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
// One request per poll; the polling loop retries on its own schedule.
return (
await workerApi.getSessionAsync({
signal,
timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS,
})
).status;
}
const result = await ctx.graphqlClient
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
.toPromise();
if (result.error) {
throw result.error;
}
return result.data?.deviceRunSessions?.byId?.status;
): Promise<DeviceRunSessionStatus['status']> {
// One request per poll; the polling loop retries on its own schedule.
return (
await requireDeviceRunSessionWorkerApi(ctx).getSessionAsync({
signal,
timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS,
})
).status;
}
// Best effort: when this fails, the caller still tears the session down and the
@@ -301,17 +252,9 @@ async function ensureDeviceRunSessionStoppedSafelyAsync({
logger: bunyan;
}): Promise<void> {
try {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
await workerApi.stopAsync({ timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS });
} else {
const result = await ctx.graphqlClient
.mutation(ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION, { deviceRunSessionId })
.toPromise();
if (result.error) {
throw result.error;
}
}
await requireDeviceRunSessionWorkerApi(ctx).stopAsync({
timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS,
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not mark idle device run session as stopped', error, {
@@ -476,12 +419,6 @@ export async function ensureFfmpegInstalledOnceAsync({
}
}
const TurnIceServersResponseSchema = z.object({
data: z.object({
iceServers: TurnIceServersSchema,
}),
});
/**
* Translate Cloudflare ICE servers into web preview CLI flags: `--stun-url` (the
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
@@ -523,10 +460,10 @@ export function turnIceServersToWebPreviewArgs(iceServers: TurnIceServers): stri
*/
export async function fetchWebPreviewTurnArgsAsync(
ctx: CustomBuildContext,
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
{ logger }: { logger: bunyan }
): Promise<string[]> {
try {
const iceServers = await fetchTurnIceServersAsync(ctx, { env, logger });
const iceServers = await fetchTurnIceServersAsync(ctx, { logger });
const args = turnIceServersToWebPreviewArgs(iceServers);
if (args.length > 0) {
logger.info('Configured the web preview with Cloudflare TURN ICE servers.');
@@ -545,38 +482,13 @@ export async function fetchWebPreviewTurnArgsAsync(
async function fetchTurnIceServersAsync(
ctx: CustomBuildContext,
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
{ logger }: { logger: bunyan }
): Promise<TurnIceServers> {
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
return await workerApi.fetchTurnIceServersAsync({ timeoutMs: 5000, retries: 1, logger });
}
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const expoApiServerUrl = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const robotAccessToken = nullthrows(
ctx.job.secrets?.robotAccessToken,
'robot access token is not set'
);
const response = await turtleFetch(
new URL(
`/v2/device-run-sessions/${deviceRunSessionId}/turn-ice-servers`,
expoApiServerUrl
).toString(),
'POST',
{
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
timeout: 5000,
retries: 1,
logger,
}
);
const { data } = TurnIceServersResponseSchema.parse(await response.json());
return data.iceServers;
return await requireDeviceRunSessionWorkerApi(ctx).fetchTurnIceServersAsync({
timeoutMs: 5000,
retries: 1,
logger,
});
}
export async function uploadRemoteSessionConfigAsync({
@@ -593,26 +505,15 @@ export async function uploadRemoteSessionConfigAsync({
logger.info(
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
);
const workerApi = getDeviceRunSessionWorkerApi(ctx);
if (workerApi) {
try {
await workerApi.startAsync({ remoteConfig });
} catch (err) {
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
return;
}
const result = await ctx.graphqlClient
.mutation(START_DEVICE_RUN_SESSION_MUTATION, { deviceRunSessionId, remoteConfig })
.toPromise();
if (result.error) {
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
try {
await workerApi.startAsync({ remoteConfig });
} catch (err) {
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`
`Failed to start device run session ${deviceRunSessionId}: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
}
@@ -880,6 +781,58 @@ export function createPrivatePreviewUrlInputProvider(): ReturnType<
});
}
/**
* Origins that may embed the preview host in a frame, next to the website origins. The host
* drops values that are not an origin or a subdomain wildcard, so this parser only guards
* the input shape.
*/
export function createFrameAncestorsInputProvider(): ReturnType<
typeof BuildStepInput.createProvider
> {
return BuildStepInput.createProvider({
id: 'frame_ancestors',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
}
export function parseFrameAncestorsInput(value: unknown): string[] {
return parseOriginListInput(value, 'frame_ancestors', 'EAS_FRAME_ANCESTORS_INVALID_INPUT');
}
/**
* Origins whose pages may call the preview host's API cross-origin, such as a page that renders
* the device with @expo/hub-client. Framing alone does not need it. The host refuses values that
* are not an origin or a subdomain wildcard, so this parser only guards the input shape.
*/
export function createCorsOriginsInputProvider(): ReturnType<typeof BuildStepInput.createProvider> {
return BuildStepInput.createProvider({
id: 'cors_origins',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
});
}
export function parseCorsOriginsInput(value: unknown): string[] {
return parseOriginListInput(value, 'cors_origins', 'EAS_CORS_ORIGINS_INVALID_INPUT');
}
function parseOriginListInput(value: unknown, inputId: string, errorCode: string): string[] {
if (value === undefined) {
return [];
}
if (
!Array.isArray(value) ||
!value.every(origin => typeof origin === 'string' && origin.trim().length > 0)
) {
throw new UserError(
errorCode,
`Input "${inputId}" must be an array of non-empty strings, one origin each.`
);
}
return value;
}
export function parseServeSimLaunchInputs(
{
launchAppIdentifier: rawLaunchAppIdentifier,
@@ -154,8 +154,10 @@ export namespace AndroidEmulatorUtils {
try {
let configIniFileContent = await fs.promises.readFile(configIniFile, 'utf-8');
logger.info('Setting hw.ramSize to 2048.');
configIniFileContent = `${configIniFileContent}\nhw.ramSize=2048\n`;
// 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`;
const shouldResizeScreen =
env.ANDROID_EMULATOR_ADJUST_SCREEN === 'true' || env.ANDROID_EMULATOR_ADJUST_SCREEN === '1';
@@ -136,7 +136,10 @@ describe('AndroidEmulatorUtils', () => {
);
});
it('does not apply an LCD configuration when it is omitted', async () => {
it.each([
[undefined, 2048],
['device-run-session-id', 4096],
])('configures memory for session %s without an LCD override', async (sessionId, ramSize) => {
const deviceName = 'generic-emulator' as AndroidVirtualDeviceName;
const avdDirectory = `/home/expo/.android/avd/${deviceName}.avd`;
const initialConfig = ['skin.path=pixel_9', 'showDeviceFrame=yes'].join('\n');
@@ -156,12 +159,14 @@ describe('AndroidEmulatorUtils', () => {
env: {
HOME: '/home/expo',
ANDROID_EMULATOR_ADJUST_HEAP_SIZE: '0',
DEVICE_RUN_SESSION_ID: sessionId,
},
logger: createMockLogger(),
});
const config = await fs.promises.readFile(`${avdDirectory}/config.ini`, 'utf8');
expect(config).toContain(initialConfig);
expect(config).toContain(`hw.ramSize=${ramSize}\n`);
expect(config).not.toContain('hw.lcd.');
});
});
@@ -3,7 +3,13 @@ import promiseRetry from 'promise-retry';
export function promiseRetryWithCondition<TFn extends (...args: any[]) => Promise<any>>(
fn: TFn,
retryConditionFn: (error: any) => boolean,
options: { retries: number; factor?: number; minTimeout?: number; maxTimeout?: number } = {
options: {
retries: number;
factor?: number;
minTimeout?: number;
maxTimeout?: number;
randomize?: boolean;
} = {
retries: 3,
factor: 2,
},
@@ -1,5 +1,13 @@
# create-eas-build-function
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "25.0.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.0.0"
"@expo/steps": "25.1.1"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+8
View File
@@ -1,5 +1,13 @@
# @expo/downloader
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "25.0.0",
"version": "25.1.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,5 +1,13 @@
# eas-build-cache-provider
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "25.0.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",
+8
View File
@@ -1,5 +1,13 @@
# @expo/eas-build-job
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "25.0.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.0.0",
"@expo/logger": "25.1.1",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "25.0.0",
"@expo/turtle-spawn": "25.1.1",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+12
View File
@@ -1,5 +1,17 @@
# eas-cli
## 25.1.1
No changes in this release.
## 25.1.0
### Minor Changes
- Show EAS Simulator minutes in `eas account:usage`. ([#4583](https://github.com/expo/eas-cli/pull/4583) by [@LipLoody](https://github.com/LipLoody))
- Add `--cors-origin` to `eas simulator:start`, so a third party page can call the simulator preview API, for example with `@expo/hub-client`. ([#4658](https://github.com/expo/eas-cli/pull/4658) by [@krystofwoldrich](https://github.com/krystofwoldrich))
- Add `--frame-ancestor` to `eas simulator:start`, so a third party site can embed the simulator preview, and print the embeddable preview URL. ([#4656](https://github.com/expo/eas-cli/pull/4656) by [@krystofwoldrich](https://github.com/krystofwoldrich))
## 25.0.0
### Major Changes
+181 -148
View File
File diff suppressed because it is too large. Load diff
+40
View File
@@ -45707,6 +45707,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "corsOrigins",
"description": "Origins of pages that may call the session's preview host API cross-origin, for example to\nrender the device with @expo/hub-client. Framing the preview does not need this. Same value\nrules as frameAncestors. At most 10. The page still needs the preview token from remoteConfig.\nIf omitted, only expo.dev may call the preview API cross-origin.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "egress",
"description": "Where the virtual device's proxied network traffic exits to the internet. If\nomitted, all traffic exits from EAS infrastructure.",
@@ -45731,6 +45751,26 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "frameAncestors",
"description": "Origins of pages that may embed the session's preview host in a frame, next to expo.dev.\nEach value is an https origin such as https://app.example.com or a subdomain wildcard such\nas https://*.example.com; plain http is accepted for localhost only. At most 10. The\nembedding page still needs the preview token from remoteConfig, and whoever embeds the\npreview can also control the device. If omitted, only expo.dev may embed the preview.",
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "ios",
"description": "iOS Simulator options. Only supported when platform is IOS.",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "25.0.0",
"version": "25.1.1",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,11 +48,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "25.0.0",
"@expo/eas-json": "25.0.0",
"@expo/eas-build-job": "25.1.1",
"@expo/eas-json": "25.1.1",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "25.0.0",
"@expo/logger": "25.1.1",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.0.0",
"@expo/steps": "25.1.1",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -22,6 +22,15 @@ function createMockFullUsageData(
bandwidthLimit: number;
buildOverageCost: number;
updateOverageCost: number;
simulator: {
planValue: number;
limit: number;
iosMinutes: number;
androidMinutes: number;
overageMinutes: number;
overageCost: number;
jobTypeBreakdown?: { workflows: number; simulator: number; other: number };
};
}> = {}
): AccountFullUsageData {
const {
@@ -35,6 +44,7 @@ function createMockFullUsageData(
bandwidthLimit = 10,
buildOverageCost = 0,
updateOverageCost = 0,
simulator,
} = overrides;
const now = new Date();
@@ -167,6 +177,72 @@ function createMockFullUsageData(
: [],
totalCost: updateOverageCost,
},
EAS_SIMULATOR: {
unavailableServiceUsageMetrics: [],
__typename: 'UsageMetricTotal',
id: 'simulator-metric-id',
billingPeriod: {
__typename: 'BillingPeriod',
id: 'billing-period-id',
start: startOfMonth.toISOString(),
end: endOfMonth.toISOString(),
anchor: startOfMonth.toISOString(),
},
planMetrics: simulator
? [
{
__typename: 'EstimatedUsage',
id: 'simulator-plan-metric-id',
service: EasService.Simulator,
serviceMetric: EasServiceMetric.SimulatorUsage,
metricType: UsageMetricType.Minute,
value: simulator.planValue,
limit: simulator.limit,
jobTypeBreakdown: simulator.jobTypeBreakdown
? { __typename: 'EstimatedUsageJobTypeBreakdown', ...simulator.jobTypeBreakdown }
: null,
},
]
: [],
overageMetrics:
simulator && simulator.overageMinutes > 0
? [
{
__typename: 'EstimatedOverageAndCost',
id: 'simulator-overage-id',
service: EasService.Simulator,
serviceMetric: EasServiceMetric.SimulatorUsage,
metricType: UsageMetricType.Minute,
value: simulator.overageMinutes,
limit: simulator.limit,
totalCost: simulator.overageCost,
},
]
: [],
totalCost: simulator?.overageCost ?? 0,
},
IOS_SIMULATOR_MINUTES: simulator
? [
{
__typename: 'AccountUsageMetric',
id: 'ios-simulator-minutes-id',
serviceMetric: EasServiceMetric.SimulatorUsage,
metricType: UsageMetricType.Minute,
value: simulator.iosMinutes,
},
]
: [],
ANDROID_SIMULATOR_MINUTES: simulator
? [
{
__typename: 'AccountUsageMetric',
id: 'android-simulator-minutes-id',
serviceMetric: EasServiceMetric.SimulatorUsage,
metricType: UsageMetricType.Minute,
value: simulator.androidMinutes,
},
]
: [],
},
};
}
@@ -240,6 +316,14 @@ describe('calculateBillingPeriodDays', () => {
});
describe('extractUsageData', () => {
it('does not estimate a bill when Simulator usage is unavailable', () => {
const data = createMockFullUsageData();
data.usageMetrics.EAS_SIMULATOR.unavailableServiceUsageMetrics = [
{ service: EasService.Simulator },
];
expect(() => extractUsageData(data)).toThrow('Simulator usage is temporarily unavailable');
});
it('correctly extracts usage data from API response', () => {
const mockData = createMockFullUsageData({
buildValue: 25,
@@ -271,6 +355,71 @@ describe('extractUsageData', () => {
expect(result.totalOverageCostCents).toBe(2000);
});
it('omits simulator usage when the account has no simulator metric', () => {
const result = extractUsageData(createMockFullUsageData());
expect(result.simulator).toBeUndefined();
});
it('extracts simulator minutes and adds the overage to the total cost', () => {
const mockData = createMockFullUsageData({
buildValue: 60,
buildLimit: 50,
buildOverageCost: 2000,
simulator: {
planValue: 60,
limit: 60,
iosMinutes: 50.5,
androidMinutes: 14,
overageMinutes: 4.5,
overageCost: 68,
},
});
const result = extractUsageData(mockData);
expect(result.simulator).toEqual({
minutes: {
name: 'Simulator minutes',
planValue: 60,
limit: 60,
percentUsed: 100,
overageValue: 4.5,
overageCost: 68,
unit: 'minutes',
maxDecimals: 1,
},
iosMinutes: 50.5,
androidMinutes: 14,
jobTypeBreakdown: undefined,
overageCostCents: 68,
});
expect(result.totalOverageCostCents).toBe(2068);
expect(result.estimatedBillCents).toBe(1900 + 2068);
});
it('labels the Free plan minute pool and keeps the job type breakdown', () => {
const mockData = createMockFullUsageData({
subscriptionName: 'Free',
simulator: {
planValue: 45,
limit: 60,
iosMinutes: 5,
androidMinutes: 0,
overageMinutes: 0,
overageCost: 0,
jobTypeBreakdown: { workflows: 40, simulator: 5, other: 0 },
},
});
const result = extractUsageData(mockData);
expect(result.simulator?.minutes.name).toBe('CI/CD and Simulator minutes');
expect(result.simulator?.minutes.percentUsed).toBe(75);
expect(result.simulator?.jobTypeBreakdown).toEqual({ workflows: 40, simulator: 5, other: 0 });
expect(result.totalOverageCostCents).toBe(0);
});
it('handles Free plan', () => {
const mockData = createMockFullUsageData({
subscriptionName: 'Free',
@@ -1,6 +1,7 @@
import {
AppPlatform,
EasBuildBillingResourceClass,
EasService,
EasServiceMetric,
UsageMetricType,
} from '../graphql/generated';
@@ -15,6 +16,21 @@ export interface UsageMetricDisplay {
overageValue: number;
overageCost: number;
unit?: string;
maxDecimals?: number;
}
export interface SimulatorJobTypeBreakdown {
workflows: number;
simulator: number;
other: number;
}
export interface SimulatorUsageDisplay {
minutes: UsageMetricDisplay;
iosMinutes: number;
androidMinutes: number;
jobTypeBreakdown?: SimulatorJobTypeBreakdown;
overageCostCents: number;
}
export interface BuildOverageByWorkerSize {
@@ -50,6 +66,7 @@ export interface UsageDisplayData {
bandwidth: UsageMetricDisplay;
overageCostCents: number;
};
simulator?: SimulatorUsageDisplay;
totalOverageCostCents: number;
estimatedBillCents: number;
recurringCents: number | null;
@@ -108,6 +125,9 @@ export function extractUsageData(data: AccountFullUsageData): UsageDisplayData {
LARGE_ANDROID_BUILDS,
MEDIUM_IOS_BUILDS,
LARGE_IOS_BUILDS,
EAS_SIMULATOR,
IOS_SIMULATOR_MINUTES,
ANDROID_SIMULATOR_MINUTES,
} = usageMetrics;
// Find build metrics
@@ -201,9 +221,17 @@ export function extractUsageData(data: AccountFullUsageData): UsageDisplayData {
const bandwidthOverageValue = bandwidthOverage?.value ?? 0;
const bandwidthLimit = bandwidthMetric?.limit ?? 0;
const simulator = extractSimulatorUsage(
EAS_SIMULATOR,
IOS_SIMULATOR_MINUTES?.[0]?.value ?? 0,
ANDROID_SIMULATOR_MINUTES?.[0]?.value ?? 0
);
const buildOverageCostCents = EAS_BUILD.totalCost;
const updateOverageCostCents = EAS_UPDATE.totalCost;
const totalOverageCostCents = buildOverageCostCents + updateOverageCostCents;
const simulatorOverageCostCents = simulator?.overageCostCents ?? 0;
const totalOverageCostCents =
buildOverageCostCents + updateOverageCostCents + simulatorOverageCostCents;
// Calculate total overage count for display
const totalOverageBuilds = overagesByWorkerSize.reduce((sum, o) => sum + o.count, 0);
@@ -272,12 +300,64 @@ export function extractUsageData(data: AccountFullUsageData): UsageDisplayData {
},
overageCostCents: updateOverageCostCents,
},
simulator,
totalOverageCostCents,
estimatedBillCents: (subscription?.recurringCents ?? 0) + totalOverageCostCents,
recurringCents: subscription?.recurringCents ?? null,
};
}
// Accounts without EAS Simulator get no SIMULATOR_USAGE plan metric, so the section is hidden.
// On the Free plan the metric is the minute pool shared with Workflows, split by jobTypeBreakdown.
function extractSimulatorUsage(
simulatorTotal: AccountFullUsageData['usageMetrics']['EAS_SIMULATOR'],
iosMinutes: number,
androidMinutes: number
): SimulatorUsageDisplay | undefined {
if (
simulatorTotal.unavailableServiceUsageMetrics.some(
metric => metric.service === EasService.Simulator
)
) {
throw new Error(
'Simulator usage is temporarily unavailable, so the bill cannot be estimated. Try again in a few minutes.'
);
}
const planMetric = simulatorTotal.planMetrics.find(
m => m.serviceMetric === EasServiceMetric.SimulatorUsage
);
if (!planMetric) {
return undefined;
}
const overage = simulatorTotal.overageMetrics.find(
m => m.serviceMetric === EasServiceMetric.SimulatorUsage
);
const jobTypeBreakdown = planMetric.jobTypeBreakdown
? {
workflows: planMetric.jobTypeBreakdown.workflows,
simulator: planMetric.jobTypeBreakdown.simulator,
other: planMetric.jobTypeBreakdown.other,
}
: undefined;
return {
minutes: {
name: jobTypeBreakdown ? 'CI/CD and Simulator minutes' : 'Simulator minutes',
planValue: planMetric.value,
limit: planMetric.limit,
percentUsed: calculatePercentUsed(planMetric.value, planMetric.limit),
overageValue: overage?.value ?? 0,
overageCost: overage?.totalCost ?? 0,
unit: 'minutes',
maxDecimals: 1,
},
iosMinutes,
androidMinutes,
jobTypeBreakdown,
overageCostCents: simulatorTotal.totalCost,
};
}
export function calculateBillingPeriodInfo(data: UsageDisplayData): {
daysRemaining: number;
daysElapsed: number;
@@ -34,6 +34,15 @@ function createMockFullUsageData(
bandwidthLimit: number;
buildOverageCost: number;
updateOverageCost: number;
simulator: {
planValue: number;
limit: number;
iosMinutes: number;
androidMinutes: number;
overageMinutes?: number;
overageCost?: number;
jobTypeBreakdown?: { workflows: number; simulator: number; other: number };
};
}> = {}
): AccountFullUsageQueryType['account']['byId'] {
const {
@@ -47,6 +56,7 @@ function createMockFullUsageData(
bandwidthLimit = 10,
buildOverageCost = 0,
updateOverageCost = 0,
simulator,
} = overrides;
const now = new Date();
@@ -179,10 +189,88 @@ function createMockFullUsageData(
: [],
totalCost: updateOverageCost,
},
// Accounts without EAS Simulator get empty metrics from the server
EAS_SIMULATOR: {
unavailableServiceUsageMetrics: [],
__typename: 'UsageMetricTotal',
id: 'simulator-metric-id',
billingPeriod: {
__typename: 'BillingPeriod',
id: 'billing-period-id',
start: startOfMonth.toISOString(),
end: endOfMonth.toISOString(),
anchor: startOfMonth.toISOString(),
},
planMetrics: simulator
? [
{
__typename: 'EstimatedUsage',
id: 'simulator-plan-metric-id',
service: EasService.Simulator,
serviceMetric: EasServiceMetric.SimulatorUsage,
metricType: UsageMetricType.Minute,
value: simulator.planValue,
limit: simulator.limit,
jobTypeBreakdown: simulator.jobTypeBreakdown
? { __typename: 'EstimatedUsageJobTypeBreakdown', ...simulator.jobTypeBreakdown }
: null,
},
]
: [],
overageMetrics:
simulator && (simulator.overageMinutes ?? 0) > 0
? [
{
__typename: 'EstimatedOverageAndCost',
id: 'simulator-overage-id',
service: EasService.Simulator,
serviceMetric: EasServiceMetric.SimulatorUsage,
metricType: UsageMetricType.Minute,
value: simulator.overageMinutes ?? 0,
limit: simulator.limit,
totalCost: simulator.overageCost ?? 0,
},
]
: [],
totalCost: simulator?.overageCost ?? 0,
},
IOS_SIMULATOR_MINUTES: simulator
? [
{
__typename: 'AccountUsageMetric',
id: 'ios-simulator-minutes-id',
serviceMetric: EasServiceMetric.SimulatorUsage,
metricType: UsageMetricType.Minute,
value: simulator.iosMinutes,
},
]
: [],
ANDROID_SIMULATOR_MINUTES: simulator
? [
{
__typename: 'AccountUsageMetric',
id: 'android-simulator-minutes-id',
serviceMetric: EasServiceMetric.SimulatorUsage,
metricType: UsageMetricType.Minute,
value: simulator.androidMinutes,
},
]
: [],
},
};
}
function renderUsage(usageData: ReturnType<typeof createMockFullUsageData>): string {
const mockLog = jest.mocked(Log.log);
mockLog.mockClear();
displayUsage(extractUsageData(usageData), usageData);
// eslint-disable-next-line no-control-regex
return mockLog.mock.calls
.flat()
.join('\n')
.replace(/\u001b\[\d+m/g, '');
}
describe('AccountQuery', () => {
const mockGraphqlClient = {} as ExpoGraphqlClient;
const mockGetFullUsageAsync = jest.mocked(AccountQuery.getFullUsageAsync);
@@ -219,12 +307,6 @@ describe('AccountQuery', () => {
});
describe('displayUsage', () => {
const mockLog = jest.mocked(Log.log);
beforeEach(() => {
mockLog.mockClear();
});
it('labels usage beyond included credits as "additional usage"', () => {
const usageData = createMockFullUsageData({
buildValue: 55,
@@ -234,21 +316,71 @@ describe('displayUsage', () => {
buildOverageCost: 1500,
updateOverageCost: 250,
});
const displayData = extractUsageData(usageData);
const output = renderUsage(usageData);
displayUsage(displayData, usageData);
// eslint-disable-next-line no-control-regex
const output = mockLog.mock.calls
.flat()
.join('\n')
.replace(/\u001b\[\d+m/g, '');
expect(output).toContain('Unique Updaters (additional usage): 500 users ($2.50)');
expect(output).toContain('Additional usage: $17.50');
expect(output).toContain('Builds: $15.00');
expect(output).toContain('Updates: $2.50');
expect(output.toLowerCase()).not.toContain('overage');
});
it('hides the EAS Simulator section when the account has no simulator metric', () => {
const usageData = createMockFullUsageData();
const output = renderUsage(usageData);
expect(output).not.toContain('EAS Simulator');
expect(extractUsageData(usageData).simulator).toBeUndefined();
});
it('shows simulator minutes with the platform breakdown and additional usage', () => {
const usageData = createMockFullUsageData({
simulator: {
planValue: 60,
limit: 60,
iosMinutes: 50.25,
androidMinutes: 13.5,
overageMinutes: 3.75,
overageCost: 52,
},
});
const output = renderUsage(usageData);
expect(output).toContain('EAS Simulator');
expect(output).toContain('Simulator minutes (plan): 60/60 minutes');
expect(output).toContain('Simulator minutes (additional usage): 3.8 minutes ($0.52)');
expect(output).toContain('Simulator minutes by platform:');
expect(output).toContain('iOS: 50.3 minutes');
expect(output).toContain('Android: 13.5 minutes');
expect(output).not.toContain('Breakdown by job type');
expect(output).toContain('Simulator: $0.52');
expect(output).toContain('Additional usage: $0.52');
expect(output).toContain('Estimated bill: $19.52');
});
it('shows the shared minute pool by job type on the Free plan', () => {
const usageData = createMockFullUsageData({
subscriptionName: 'Free',
simulator: {
planValue: 50,
limit: 60,
iosMinutes: 10,
androidMinutes: 0,
jobTypeBreakdown: { workflows: 40, simulator: 10, other: 0 },
},
});
const output = renderUsage(usageData);
expect(output).toContain('CI/CD and Simulator minutes (plan): 50/60 minutes');
expect(output).toContain('Breakdown by job type:');
expect(output).toContain('Workflows: 40 minutes');
expect(output).toContain('Simulator: 10 minutes');
expect(output).not.toContain('Other jobs');
expect(output).toContain('iOS: 10 minutes');
});
});
describe('calculatePercentUsed', () => {
+65 -4
View File
@@ -4,6 +4,7 @@ import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
import {
SimulatorUsageDisplay,
UsageDisplayData,
UsageMetricDisplay,
calculateBillingPeriodInfo,
@@ -65,12 +66,22 @@ function displayBandwidthMetric(metric: UsageMetricDisplay, indent: string = '
}
}
function formatMetricValue(value: number, maxDecimals: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: maxDecimals });
}
function formatMinutes(minutes: number): string {
return `${formatMetricValue(minutes, 1)} minutes`;
}
function displayMetric(metric: UsageMetricDisplay, indent: string = ' '): void {
const progressBar = createProgressBar(metric.percentUsed, 20);
const percentStr = `${metric.percentUsed}%`;
const planUsageStr = `${formatNumber(metric.planValue)}/${formatNumber(metric.limit)} ${
metric.unit ?? ''
}`;
const maxDecimals = metric.maxDecimals ?? 0;
const planUsageStr = `${formatMetricValue(metric.planValue, maxDecimals)}/${formatMetricValue(
metric.limit,
maxDecimals
)} ${metric.unit ?? ''}`;
let color = chalk.green;
if (metric.percentUsed >= 100) {
@@ -85,12 +96,34 @@ function displayMetric(metric: UsageMetricDisplay, indent: string = ' '): void
if (metric.overageValue > 0) {
Log.log(
`${indent}${metric.name} (additional usage): ${chalk.red(
`${formatNumber(metric.overageValue)} ${metric.unit ?? ''}`
`${formatMetricValue(metric.overageValue, maxDecimals)} ${metric.unit ?? ''}`
)} (${formatCurrency(metric.overageCost)})`
);
}
}
function displaySimulatorUsage(simulator: SimulatorUsageDisplay): void {
Log.newLine();
Log.log(chalk.bold.underline('EAS Simulator'));
displayMetric(simulator.minutes);
if (simulator.jobTypeBreakdown) {
const { workflows, simulator: simulatorMinutes, other } = simulator.jobTypeBreakdown;
Log.log(' Breakdown by job type:');
Log.log(` Workflows: ${formatMinutes(workflows)}`);
Log.log(` Simulator: ${formatMinutes(simulatorMinutes)}`);
if (other > 0) {
Log.log(` Other jobs: ${formatMinutes(other)}`);
}
}
if (simulator.iosMinutes > 0 || simulator.androidMinutes > 0) {
Log.log(' Simulator minutes by platform:');
Log.log(` iOS: ${formatMinutes(simulator.iosMinutes)}`);
Log.log(` Android: ${formatMinutes(simulator.androidMinutes)}`);
}
}
function billingUrl(accountName: string): string {
return `https://expo.dev/accounts/${accountName}/settings/billing`;
}
@@ -141,6 +174,10 @@ export function displayUsage(data: UsageDisplayData, usageData: AccountFullUsage
displayMetric(data.updates.mau);
displayBandwidthMetric(data.updates.bandwidth);
if (data.simulator) {
displaySimulatorUsage(data.simulator);
}
Log.newLine();
Log.log(chalk.bold.underline('Billing'));
@@ -190,6 +227,9 @@ export function displayUsage(data: UsageDisplayData, usageData: AccountFullUsage
if (data.updates.overageCostCents > 0) {
Log.log(` Updates: ${formatCurrency(data.updates.overageCostCents)}`);
}
if (data.simulator && data.simulator.overageCostCents > 0) {
Log.log(` Simulator: ${formatCurrency(data.simulator.overageCostCents)}`);
}
}
Log.log(` Estimated bill: ${chalk.bold(formatCurrency(data.estimatedBillCents))}`);
@@ -387,6 +427,27 @@ export default class AccountUsage extends EasCommand {
},
overageCostCents: displayData.updates.overageCostCents,
},
simulator: displayData.simulator
? {
minutes: {
plan: {
used: displayData.simulator.minutes.planValue,
limit: displayData.simulator.minutes.limit,
percentUsed: displayData.simulator.minutes.percentUsed,
},
overage: {
count: displayData.simulator.minutes.overageValue,
costCents: displayData.simulator.minutes.overageCost,
},
},
byPlatform: {
ios: displayData.simulator.iosMinutes,
android: displayData.simulator.androidMinutes,
},
byJobType: displayData.simulator.jobTypeBreakdown ?? null,
overageCostCents: displayData.simulator.overageCostCents,
}
: null,
billing: {
addons: subscription?.addons ?? [],
upcomingInvoice: subscription?.upcomingInvoice ?? null,
@@ -1967,6 +1967,146 @@ describe(Simulator, () => {
);
});
it('sends frame ancestors and prints the embed URL', async () => {
const remoteConfig = {
__typename: 'AgentDeviceRunSessionRemoteConfig' as const,
agentDeviceRemoteSessionUrl: 'https://agent-device.example.test',
agentDeviceRemoteSessionToken: 'daemon-token',
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-123',
webPreviewToken: 'preview-secret',
previewApiUrl: 'https://preview-api.example.test',
};
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({ type: DeviceRunSessionType.AgentDevice, remoteConfig })
);
const frameAncestorFlags = [
'--frame-ancestor',
'https://Embed.example.test/',
'--frame-ancestor',
'https://*.example.test',
];
const { command: jsonCommand } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--json',
...frameAncestorFlags,
]);
await jsonCommand.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
publicPreview: false,
frameAncestors: ['https://embed.example.test', 'https://*.example.test'],
})
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
webPreviewUrl: deviceRunSessionPreviewUrl,
previewApiUrl: 'https://preview-api.example.test/?token=preview-secret',
}),
})
);
jest.clearAllMocks();
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({ type: DeviceRunSessionType.AgentDevice, remoteConfig })
);
const { command: humanCommand } = createCommand([
'--platform',
'ios',
'--non-interactive',
...frameAncestorFlags,
]);
await humanCommand.runAsync();
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('https://preview-api.example.test/?token=preview-secret')
);
});
it('sends CORS origins and prints the preview API URL', async () => {
const remoteConfig = {
__typename: 'AgentDeviceRunSessionRemoteConfig' as const,
agentDeviceRemoteSessionUrl: 'https://agent-device.example.test',
agentDeviceRemoteSessionToken: 'daemon-token',
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-123',
webPreviewToken: 'preview-secret',
previewApiUrl: 'https://preview-api.example.test',
};
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({ type: DeviceRunSessionType.AgentDevice, remoteConfig })
);
const { command: jsonCommand } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--json',
'--cors-origin',
'https://App.example.test/',
]);
await jsonCommand.runAsync();
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ corsOrigins: ['https://app.example.test'] })
);
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.not.objectContaining({ frameAncestors: expect.anything() })
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
remoteConfig: expect.objectContaining({
previewApiUrl: 'https://preview-api.example.test/?token=preview-secret',
}),
})
);
jest.clearAllMocks();
mockByIdAsync.mockResolvedValue(
makeDeviceRunSession({ type: DeviceRunSessionType.AgentDevice, remoteConfig })
);
const { command: humanCommand } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--cors-origin',
'https://app.example.test',
]);
await humanCommand.runAsync();
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('https://preview-api.example.test/?token=preview-secret')
);
});
it('rejects a CORS origin with a path before creating a session', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--cors-origin',
'https://app.example.test/app',
]);
await expect(command.runAsync()).rejects.toThrow(
'--cors-origin https://app.example.test/app must be an origin only'
);
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it('rejects a frame ancestor that is not an origin before creating a session', async () => {
const { command } = createCommand([
'--platform',
'ios',
'--non-interactive',
'--frame-ancestor',
'https://embed.example.test/embed',
]);
await expect(command.runAsync()).rejects.toThrow('must be an origin only');
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
});
it.each([false, true])(
'uses the %s public-link choice in start JSON and human output',
async publicLink => {
@@ -31,6 +31,7 @@ import Log, { link } from '../../log';
import { Ora, ora } from '../../ora';
import { promptAsync } from '../../prompts';
import { parseEgressAllowList, runLocalEgressAsync } from '../../simulator/egress';
import { parseCorsOriginList, parseFrameAncestorList } from '../../simulator/frameAncestors';
import { parseNetworkCaptureFields } from '../../simulator/networkCapture';
import {
EAS_SIMULATOR_SESSION_ID,
@@ -177,6 +178,16 @@ export default class Simulator extends EasCommand {
multiple: true,
dependsOn: ['egress'],
}),
'cors-origin': Flags.string({
description:
'Origin of a page that may call the simulator preview API cross-origin, for example to render the device with @expo/hub-client, such as https://app.example.com or https://*.example.com. Repeat for multiple origins. Embedding the preview in a frame does not need this; use --frame-ancestor for that. The page still needs the printed preview URL and its token.',
multiple: true,
}),
'frame-ancestor': Flags.string({
description:
'Origin of a page that may embed the simulator preview in a frame, such as https://app.example.com or https://*.example.com. Repeat for multiple origins. The embedding page loads the printed preview URL, which carries the session token, so anyone who can read that page can also control the simulator.',
multiple: true,
}),
force: Flags.boolean({
description:
'[default: true] Create a new simulator session even when an existing simulator session is present in the environment.',
@@ -286,6 +297,18 @@ export default class Simulator extends EasCommand {
} catch (err) {
throw new EasCommandError(err instanceof Error ? err.message : String(err));
}
let frameAncestors: string[] = [];
try {
frameAncestors = parseFrameAncestorList(flags['frame-ancestor'] ?? []);
} catch (err) {
throw new EasCommandError(err instanceof Error ? err.message : String(err));
}
let corsOrigins: string[] = [];
try {
corsOrigins = parseCorsOriginList(flags['cors-origin'] ?? []);
} catch (err) {
throw new EasCommandError(err instanceof Error ? err.message : String(err));
}
if (flags['network-capture'] && platform !== AppPlatform.Ios) {
throw new EasCommandError(
'Network capture is only supported on iOS simulator sessions. Re-run without --network-capture, or pass --platform ios.'
@@ -358,6 +381,8 @@ export default class Simulator extends EasCommand {
...(openUrl ? { openUrl } : {}),
...(resourceClass ? { resourceClass } : {}),
...(egress ? { egress } : {}),
...(frameAncestors.length ? { frameAncestors } : {}),
...(corsOrigins.length ? { corsOrigins } : {}),
requestOrigin: DeviceRunSessionRequestOrigin.EasCli,
...agentIdentity,
maxRunTimeMinutes: flags['max-duration-minutes'],
@@ -510,7 +535,8 @@ export default class Simulator extends EasCommand {
remoteConfig: sanitizeRemoteConfigForJson(
remoteConfig,
deviceRunSessionPreviewUrl,
flags['public-link']
flags['public-link'],
{ frameAncestors, corsOrigins }
),
});
return;
@@ -524,6 +550,8 @@ export default class Simulator extends EasCommand {
sessionUrl: deviceRunSessionUrl,
privatePreviewUrl: deviceRunSessionPreviewUrl,
publicLink: flags['public-link'],
frameAncestors,
corsOrigins,
platform,
})
);
+18 -3
View File
@@ -6418,6 +6418,14 @@ export type CreateDeviceRunSessionInput = {
* and applicationArchiveUrl.
*/
expoGo?: InputMaybe<Scalars['Boolean']['input']>;
/**
* Origins of pages that may embed the session's preview host in a frame, next to expo.dev.
* Each value is an https origin such as https://app.example.com or a subdomain wildcard such
* as https://*.example.com; plain http is accepted for localhost only. At most 10. The
* embedding page still needs the preview token from remoteConfig, and whoever embeds the
* preview can also control the device. If omitted, only expo.dev may embed the preview.
*/
frameAncestors?: InputMaybe<Array<Scalars['String']['input']>>;
/** iOS Simulator options. Only supported when platform is IOS. */
ios?: InputMaybe<CreateIosDeviceRunSessionInput>;
/**
@@ -6556,6 +6564,13 @@ export type CreateGitHubRepositorySettingsInput = {
};
export type CreateIosDeviceRunSessionInput = {
/**
* Origins of pages that may call the session's preview host API cross-origin, for example to
* render the device with @expo/hub-client. Framing the preview does not need this. Same value
* rules as frameAncestors. At most 10. The page still needs the preview token from remoteConfig.
* If omitted, only expo.dev may call the preview API cross-origin.
*/
corsOrigins?: InputMaybe<Array<Scalars['String']['input']>>;
/** Simulator device name or UDID to use for the session (e.g. "iPhone 16 Pro"). */
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
};
@@ -15661,7 +15676,7 @@ export type AccountFullUsageQueryVariables = Exact<{
}>;
export type AccountFullUsageQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, name: string, subscription?: { __typename?: 'SubscriptionDetails', id: string, name?: string | null, status?: string | null, nextInvoice?: any | null, nextInvoiceAmountDueCents?: number | null, recurringCents?: number | null, price: number, concurrencies?: { __typename?: 'Concurrencies', total: number, android: number, ios: number } | null, addons: Array<{ __typename?: 'AddonDetails', id: string, name: string, quantity?: number | null }>, upcomingInvoice?: { __typename?: 'Invoice', id: string, total: number, lineItems: Array<{ __typename?: 'InvoiceLineItem', id: string, description: string, amount: number, period: { __typename?: 'InvoicePeriod', start: any, end: any } }> } | null } | null, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, usageMetrics: { __typename?: 'AccountUsageMetrics', MEDIUM_ANDROID_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, LARGE_ANDROID_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, MEDIUM_IOS_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, LARGE_IOS_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, EAS_BUILD: { __typename?: 'UsageMetricTotal', id: string, totalCost: number, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | null, platform?: AppPlatform | null } | null }> }, EAS_UPDATE: { __typename?: 'UsageMetricTotal', id: string, totalCost: number, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | null, platform?: AppPlatform | null } | null }> } } } } };
export type AccountFullUsageQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, name: string, subscription?: { __typename?: 'SubscriptionDetails', id: string, name?: string | null, status?: string | null, nextInvoice?: any | null, nextInvoiceAmountDueCents?: number | null, recurringCents?: number | null, price: number, concurrencies?: { __typename?: 'Concurrencies', total: number, android: number, ios: number } | null, addons: Array<{ __typename?: 'AddonDetails', id: string, name: string, quantity?: number | null }>, upcomingInvoice?: { __typename?: 'Invoice', id: string, total: number, lineItems: Array<{ __typename?: 'InvoiceLineItem', id: string, description: string, amount: number, period: { __typename?: 'InvoicePeriod', start: any, end: any } }> } | null } | null, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, usageMetrics: { __typename?: 'AccountUsageMetrics', MEDIUM_ANDROID_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, LARGE_ANDROID_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, MEDIUM_IOS_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, LARGE_IOS_BUILDS: Array<{ __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number }>, EAS_BUILD: { __typename?: 'UsageMetricTotal', id: string, totalCost: number, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null, jobTypeBreakdown?: { __typename?: 'EstimatedUsageJobTypeBreakdown', workflows: number, simulator: number, other: number } | null }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | null, platform?: AppPlatform | null } | null }> }, EAS_UPDATE: { __typename?: 'UsageMetricTotal', id: string, totalCost: number, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null, jobTypeBreakdown?: { __typename?: 'EstimatedUsageJobTypeBreakdown', workflows: number, simulator: number, other: number } | null }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | null, platform?: AppPlatform | null } | null }> }, EAS_SIMULATOR: { __typename?: 'UsageMetricTotal', id: string, totalCost: number, unavailableServiceUsageMetrics: Array<{ __typename?: 'UnavailableServiceUsageMetric', service: EasService }>, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null, jobTypeBreakdown?: { __typename?: 'EstimatedUsageJobTypeBreakdown', workflows: number, simulator: number, other: number } | null }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | nulLine truncated
export type AccountUsageForOverageWarningQueryVariables = Exact<{
accountId: Scalars['String']['input'];
@@ -16625,11 +16640,11 @@ export type BillingPeriodFragment = { __typename?: 'BillingPeriod', id: string,
export type EstimatedUsagePlatformBreakdownFragment = { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } };
export type EstimatedUsageFragment = { __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null };
export type EstimatedUsageFragment = { __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null, jobTypeBreakdown?: { __typename?: 'EstimatedUsageJobTypeBreakdown', workflows: number, simulator: number, other: number } | null };
export type EstimatedOverageAndCostFragment = { __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | null, platform?: AppPlatform | null } | null };
export type UsageMetricTotalFragment = { __typename?: 'UsageMetricTotal', id: string, totalCost: number, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | null, platform?: AppPlatform | null } | null }> };
export type UsageMetricTotalFragment = { __typename?: 'UsageMetricTotal', id: string, totalCost: number, billingPeriod: { __typename?: 'BillingPeriod', id: string, start: any, end: any, anchor: any }, planMetrics: Array<{ __typename?: 'EstimatedUsage', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, platformBreakdown?: { __typename?: 'EstimatedUsagePlatformBreakdown', ios: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number }, android: { __typename?: 'EstimatedUsagePlatformDetail', value: number, limit: number } } | null, jobTypeBreakdown?: { __typename?: 'EstimatedUsageJobTypeBreakdown', workflows: number, simulator: number, other: number } | null }>, overageMetrics: Array<{ __typename?: 'EstimatedOverageAndCost', id: string, service: EasService, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number, limit: number, totalCost: number, metadata?: { __typename?: 'AccountUsageEASBuildMetadata', billingResourceClass?: EasBuildBillingResourceClass | null, platform?: AppPlatform | null } | null }> };
export type AccountUsageMetricFragment = { __typename?: 'AccountUsageMetric', id: string, serviceMetric: EasServiceMetric, metricType: UsageMetricType, value: number };
@@ -147,6 +147,31 @@ export const AccountQuery = {
id
...UsageMetricTotalFragment
}
EAS_SIMULATOR: byBillingPeriod(date: $currentDate, service: SIMULATOR) {
unavailableServiceUsageMetrics {
service
}
id
...UsageMetricTotalFragment
}
IOS_SIMULATOR_MINUTES: metricsForServiceMetric(
serviceMetric: SIMULATOR_USAGE
granularity: TOTAL
timespan: { start: $startDate, end: $endDate }
filterParams: { platform: "ios" }
) {
id
...AccountUsageMetricFragment
}
ANDROID_SIMULATOR_MINUTES: metricsForServiceMetric(
serviceMetric: SIMULATOR_USAGE
granularity: TOTAL
timespan: { start: $startDate, end: $endDate }
filterParams: { platform: "android" }
) {
id
...AccountUsageMetricFragment
}
}
}
}
@@ -117,6 +117,11 @@ export const EstimatedUsageFragmentNode = gql`
platformBreakdown {
...EstimatedUsagePlatformBreakdownFragment
}
jobTypeBreakdown {
workflows
simulator
other
}
}
`;
@@ -0,0 +1,57 @@
import { parseCorsOriginList, parseFrameAncestorList } from '../frameAncestors';
describe(parseFrameAncestorList, () => {
it('normalizes each value to its origin and drops duplicates', () => {
expect(
parseFrameAncestorList([
' https://Embed.Example.test/ ',
'https://embed.example.test',
'https://embed.example.test:8443',
'https://*.example.test',
'http://localhost:3000',
'https://[::1]:4443',
])
).toEqual([
'https://embed.example.test',
'https://embed.example.test:8443',
'https://*.example.test',
'http://localhost:3000',
'https://[::1]:4443',
]);
expect(parseFrameAncestorList([])).toEqual([]);
});
it.each([
['embed.example.test', 'is not a URL'],
['https://embed.example.test/embed', 'must be an origin only'],
['https://embed.example.test/?x=1', 'must be an origin only'],
['http://embed.example.test', 'must use https'],
['https://*.test', 'cannot allow'],
])('rejects %s', (value, message) => {
expect(() => parseFrameAncestorList([value])).toThrow(message);
});
it('rejects more than ten origins', () => {
const origins = Array.from({ length: 11 }, (_, i) => `https://embed-${i}.example.test`);
expect(() => parseFrameAncestorList(origins)).toThrow('at most 10 origins; received 11');
});
});
describe(parseCorsOriginList, () => {
it('normalizes each value to its origin and drops duplicates', () => {
expect(
parseCorsOriginList([
'https://App.example.test/',
'https://app.example.test',
'http://localhost:8081',
])
).toEqual(['https://app.example.test', 'http://localhost:8081']);
});
it('names the flag in its errors', () => {
expect(() => parseCorsOriginList(['https://app.example.test/path'])).toThrow(
'--cors-origin https://app.example.test/path must be an origin only'
);
expect(() => parseCorsOriginList(['http://app.example.test'])).toThrow('--cors-origin');
});
});
@@ -12,6 +12,7 @@ import {
EAS_SIMULATOR_WAITLIST_URL,
deviceRunSessionTypeToFlagValue,
deviceRunSessionTypesForFlagValue,
formatEmbedPreviewUrl,
formatLoopbackForwardNotice,
formatPreviewUrl,
formatRemoteSessionInstructions,
@@ -475,6 +476,46 @@ describe(sanitizeRemoteConfigForJson, () => {
expect(sanitized).toMatchObject({ webPreviewUrl: null });
});
it('includes the tokenized preview API address for a session with frame ancestors', () => {
const sanitized = sanitizeRemoteConfigForJson(
{
__typename: 'AgentDeviceRunSessionRemoteConfig' as const,
agentDeviceRemoteSessionUrl: 'https://agent-device.example.test',
agentDeviceRemoteSessionToken: 'daemon-token',
webPreviewUrl: PAGE_URL,
webPreviewToken: 'tok-1',
previewApiUrl: PREVIEW_URL,
},
PRIVATE_URL,
false,
{ frameAncestors: ['https://embed.example.test'] }
);
expect(sanitized).toMatchObject({
webPreviewUrl: PRIVATE_URL,
previewApiUrl: `${PREVIEW_URL}/?token=tok-1`,
});
});
it('includes the tokenized preview API address for a session with CORS origins', () => {
const sanitized = sanitizeRemoteConfigForJson(
{
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
previewUrl: PAGE_URL,
previewToken: 'tok-1',
previewApiUrl: PREVIEW_URL,
},
PRIVATE_URL,
false,
{ corsOrigins: ['https://app.example.test'] }
);
expect(sanitized).toMatchObject({
previewUrl: PRIVATE_URL,
previewApiUrl: `${PREVIEW_URL}/?token=tok-1`,
});
});
it('returns a fragment-bearing website link with --public-link', () => {
const sanitized = sanitizeRemoteConfigForJson(
{
@@ -795,3 +836,93 @@ describe.each(controllerConfigs)('$__typename local egress', remoteConfig => {
);
});
});
describe(formatEmbedPreviewUrl, () => {
it('is null until the worker reports the preview host', () => {
expect(formatEmbedPreviewUrl(agentDeviceConfig)).toBeNull();
expect(
formatEmbedPreviewUrl({
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
previewUrl: 'https://expo.dev/simulator-preview/abc',
previewToken: 'tok-2',
})
).toBeNull();
});
it('adds the preview token to the preview host address', () => {
expect(
formatEmbedPreviewUrl({
...agentDeviceConfig,
previewApiUrl: 'https://web-preview.example.test',
webPreviewToken: 'tok-1',
})
).toBe('https://web-preview.example.test/?token=tok-1');
expect(
formatEmbedPreviewUrl({
__typename: 'WebPreviewOnlyRunSessionRemoteConfig' as const,
previewUrl: 'https://expo.dev/simulator-preview/abc',
previewToken: 'tok-2',
previewApiUrl: 'https://web-preview.example.test',
})
).toBe('https://web-preview.example.test/?token=tok-2');
});
});
describe('embed instructions', () => {
const config = {
...agentDeviceConfig,
previewApiUrl: 'https://web-preview.example.test',
webPreviewToken: 'tok-1',
};
it('prints the embed URL and the allowed origins', () => {
const instructions = formatRemoteSessionInstructions(config, 'dotenv', {
frameAncestors: ['https://embed.example.test', 'https://*.example.test'],
});
expect(instructions).toContain(
'Pages on https://embed.example.test, https://*.example.test may embed the preview'
);
expect(instructions).toContain('https://web-preview.example.test/?token=tok-1');
});
it('says the host is not reported yet when the preview address is missing', () => {
const instructions = formatRemoteSessionInstructions(agentDeviceConfig, 'dotenv', {
frameAncestors: ['https://embed.example.test'],
});
expect(instructions).toContain('has not reported its address yet');
});
it('adds the embed URL before the local egress instructions', () => {
const instructions = formatRemoteSessionInstructions(
{ ...config, ...agentDeviceConfigWithEgress, previewApiUrl: config.previewApiUrl },
'dotenv',
{ frameAncestors: ['https://embed.example.test'] }
);
expect(instructions.indexOf('tok-1')).toBeLessThan(
instructions.indexOf('eas simulator:egress')
);
});
it('prints the API URL and the CORS origins', () => {
const instructions = formatRemoteSessionInstructions(config, 'dotenv', {
corsOrigins: ['https://app.example.test'],
});
expect(instructions).toContain('Pages on https://app.example.test may call the preview API');
expect(instructions).not.toContain('embed the preview in a frame');
expect(instructions).toContain('https://web-preview.example.test/?token=tok-1');
});
it('lists both grants when frame ancestors and CORS origins are set', () => {
const instructions = formatRemoteSessionInstructions(config, 'dotenv', {
frameAncestors: ['https://embed.example.test'],
corsOrigins: ['https://app.example.test'],
});
expect(instructions).toContain('may embed the preview in a frame.');
expect(instructions).toContain('may call the preview API');
});
it('says nothing about embedding without frame ancestors', () => {
expect(formatRemoteSessionInstructions(config, 'dotenv')).not.toContain('embed the preview');
expect(formatRemoteSessionInstructions(config, 'dotenv')).not.toContain('tok-1');
});
});
@@ -0,0 +1,82 @@
/**
* `--frame-ancestor` and `--cors-origin` values: origins of pages that may embed the simulator
* preview host in a frame, or call its API cross-origin. The server applies the same rules;
* checking here gives a fast, local error.
*/
export const MAX_FRAME_ANCESTORS = 10;
export const MAX_CORS_ORIGINS = 10;
// The shapes serve-sim and expo-device-hub accept: a host, an IPv6 literal, or a subdomain
// wildcard with at least two labels after the star.
const FRAMEABLE_HOSTNAME_PATTERN =
/^(?:\[[0-9a-f:.]+\]|[a-z0-9.-]+|\*\.[a-z0-9-]+(?:\.[a-z0-9-]+)+)$/i;
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
export function parseFrameAncestorList(entries: readonly string[]): string[] {
return parseOriginList(entries, {
flag: '--frame-ancestor',
maxCount: MAX_FRAME_ANCESTORS,
purpose: 'embeds the preview',
});
}
export function parseCorsOriginList(entries: readonly string[]): string[] {
return parseOriginList(entries, {
flag: '--cors-origin',
maxCount: MAX_CORS_ORIGINS,
purpose: 'calls the preview API',
});
}
function parseOriginList(
entries: readonly string[],
{ flag, maxCount, purpose }: { flag: string; maxCount: number; purpose: string }
): string[] {
if (entries.length > maxCount) {
throw new Error(
`${flag} accepts at most ${maxCount} origins; received ${entries.length}. ` +
`Keep only the origins of pages that ${purpose}.`
);
}
return [...new Set(entries.map(entry => parseOrigin(entry, flag, purpose)))];
}
function parseOrigin(entry: string, flag: string, purpose: string): string {
const value = entry.trim();
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(
`${flag} ${entry} is not a URL. Pass the origin of the page that ${purpose}, ` +
'such as https://app.example.com, or a subdomain wildcard such as https://*.example.com.'
);
}
if (
url.pathname !== '/' ||
url.search !== '' ||
url.hash !== '' ||
url.username !== '' ||
url.password !== ''
) {
throw new Error(
`${flag} ${entry} must be an origin only. Browsers match origins by scheme, host and port, ` +
`so drop the path, query, hash and credentials: ${url.origin}.`
);
}
const isLoopback = LOOPBACK_HOSTNAMES.has(url.hostname);
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)) {
throw new Error(
`${flag} ${entry} must use https. The preview is served over https, and browsers do not ` +
'let an http page use it. Plain http is accepted for localhost only.'
);
}
if (!FRAMEABLE_HOSTNAME_PATTERN.test(url.hostname)) {
throw new Error(
`${flag} ${entry} has a host the preview host cannot allow. Use a hostname, an IPv6 ` +
'literal, or a subdomain wildcard with at least two labels after the star, such as https://*.example.com.'
);
}
return url.origin;
}
+71 -7
View File
@@ -261,8 +261,14 @@ type RemoteSessionInstructionsConfigType = 'env' | 'dotenv';
type PreviewLinkOptions = {
privatePreviewUrl?: string;
publicLink?: boolean;
/** Origins the session allows to embed the preview host; the embed URL is printed for them. */
frameAncestors?: readonly string[];
/** Origins the session allows to call the preview host API; the embed URL is printed for them. */
corsOrigins?: readonly string[];
};
export type PreviewAccessOrigins = Pick<PreviewLinkOptions, 'frameAncestors' | 'corsOrigins'>;
function previewUrlIsWebsitePage(remoteConfig: DeviceRunSessionRemoteConfig): boolean {
return remoteConfig.previewApiUrl != null;
}
@@ -334,12 +340,16 @@ function formatBrowserPreviewInstructions(
/**
* Remote config for `--json`. Private sessions omit the direct preview API address and preview
* tokens. Public sessions include bearer preview links while preserving controller credentials.
* A session with frame ancestors or CORS origins also includes the preview API address, since
* the page on those origins needs it.
*/
export function sanitizeRemoteConfigForJson(
remoteConfig: DeviceRunSessionRemoteConfig,
privatePreviewUrl: string,
publicLink = false
publicLink = false,
{ frameAncestors = [], corsOrigins = [] }: PreviewAccessOrigins = {}
): DeviceRunSessionRemoteConfig {
const includePreviewApiUrl = publicLink || frameAncestors.length > 0 || corsOrigins.length > 0;
switch (remoteConfig.__typename) {
case 'ServeSimRunSessionRemoteConfig':
case 'WebPreviewOnlyRunSessionRemoteConfig': {
@@ -350,7 +360,7 @@ export function sanitizeRemoteConfigForJson(
privatePreviewUrl,
publicLink,
}),
previewApiUrl: publicLink
previewApiUrl: includePreviewApiUrl
? remoteConfig.previewApiUrl &&
formatPreviewApiUrl(remoteConfig.previewApiUrl, previewToken)
: null,
@@ -368,7 +378,7 @@ export function sanitizeRemoteConfigForJson(
publicLink,
})
: remoteConfig.webPreviewUrl,
previewApiUrl: publicLink
previewApiUrl: includePreviewApiUrl
? remoteConfig.previewApiUrl &&
formatPreviewApiUrl(remoteConfig.previewApiUrl, webPreviewToken)
: null,
@@ -377,6 +387,55 @@ export function sanitizeRemoteConfigForJson(
}
}
/**
* The preview host address with the session token, which an embedding page loads directly. The
* expo.dev preview page cannot be framed by other sites. Null until the worker reports the host.
*/
export function formatEmbedPreviewUrl(remoteConfig: DeviceRunSessionRemoteConfig): string | null {
switch (remoteConfig.__typename) {
case 'ServeSimRunSessionRemoteConfig':
case 'WebPreviewOnlyRunSessionRemoteConfig':
return remoteConfig.previewApiUrl
? formatPreviewApiUrl(remoteConfig.previewApiUrl, remoteConfig.previewToken)
: null;
case 'AgentDeviceRunSessionRemoteConfig':
case 'ArgentRunSessionRemoteConfig':
case 'AppiumRunSessionRemoteConfig':
return remoteConfig.previewApiUrl
? formatPreviewApiUrl(remoteConfig.previewApiUrl, remoteConfig.webPreviewToken)
: null;
}
}
function formatEmbedInstructions(
remoteConfig: DeviceRunSessionRemoteConfig,
{ frameAncestors = [], corsOrigins = [] }: PreviewAccessOrigins
): string[] {
if (frameAncestors.length === 0 && corsOrigins.length === 0) {
return [];
}
const embedUrl = formatEmbedPreviewUrl(remoteConfig);
const grants = [
...(frameAncestors.length > 0
? [`Pages on ${frameAncestors.join(', ')} may embed the preview in a frame.`]
: []),
...(corsOrigins.length > 0
? [
`Pages on ${corsOrigins.join(', ')} may call the preview API, for example with @expo/hub-client.`,
]
: []),
];
return [
'',
`🖼 ${grants.join(' ')} Use this URL there:`,
'',
embedUrl ?? 'The preview host has not reported its address yet.',
chalk.yellow(
'Warning: Anyone who can read this URL can view and control the simulator, including your app and logs that may contain sensitive data.'
),
];
}
export function formatRemoteSessionInstructions(
remoteConfig: DeviceRunSessionRemoteConfig,
configType: RemoteSessionInstructionsConfigType,
@@ -387,12 +446,17 @@ export function formatRemoteSessionInstructions(
platform,
privatePreviewUrl,
publicLink,
frameAncestors = [],
corsOrigins = [],
}: LocalEgressOptions & PreviewLinkOptions = {}
): string {
const instructions = formatControllerInstructions(remoteConfig, configType, {
privatePreviewUrl,
publicLink,
});
const instructions = [
formatControllerInstructions(remoteConfig, configType, {
privatePreviewUrl,
publicLink,
}),
...formatEmbedInstructions(remoteConfig, { frameAncestors, corsOrigins }),
].join('\n');
const egress = getLocalEgressConfig(remoteConfig, egressAllow, platform);
if (!egress) {
return instructions;
+8
View File
@@ -1,5 +1,13 @@
# @expo/eas-json
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "25.0.0",
"version": "25.1.1",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "25.0.0",
"@expo/eas-build-job": "25.1.1",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+8
View File
@@ -1,5 +1,13 @@
# eas-cli-local-build-plugin
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
No changes in this release.
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "25.0.0",
"version": "25.1.1",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,12 +26,12 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "25.0.0",
"@expo/build-tools": "25.1.1",
"@expo/bunyan": "^4.0.1",
"@expo/eas-build-job": "25.0.0",
"@expo/logger": "25.0.0",
"@expo/eas-build-job": "25.1.1",
"@expo/logger": "25.1.1",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "25.0.0",
"@expo/turtle-spawn": "25.1.1",
"chalk": "4.1.2",
"env-paths": "^2.2.1",
"fs-extra": "^11.2.0",
+8
View File
@@ -1,5 +1,13 @@
# @expo/logger
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/logger",
"version": "25.0.0",
"version": "25.1.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+8
View File
@@ -1,5 +1,13 @@
# @expo/steps
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "25.0.0",
"version": "25.1.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,8 +27,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "25.0.0",
"@expo/logger": "25.0.0",
"@expo/eas-build-job": "25.1.1",
"@expo/logger": "25.1.1",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
"fast-glob": "^3.3.2",
+8
View File
@@ -1,5 +1,13 @@
# @expo/template-file
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/template-file",
"version": "25.0.0",
"version": "25.1.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
+8
View File
@@ -1,5 +1,13 @@
# @expo/turtle-spawn
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
No changes in this release.
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/turtle-spawn",
"version": "25.0.0",
"version": "25.1.1",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -25,7 +25,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "25.0.0",
"@expo/logger": "25.1.1",
"@expo/spawn-async": "^1.7.2"
},
"devDependencies": {
+8
View File
@@ -1,5 +1,13 @@
# @expo/worker
## 25.1.1
No changes in this release.
## 25.1.0
No changes in this release.
## 25.0.0
### Patch Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "25.0.0",
"version": "25.1.1",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,17 +19,17 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "25.0.0",
"@expo/build-tools": "25.1.1",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/eas-build-job": "25.0.0",
"@expo/logger": "25.0.0",
"@expo/eas-build-job": "25.1.1",
"@expo/logger": "25.1.1",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "25.0.0",
"@expo/template-file": "25.0.0",
"@expo/turtle-spawn": "25.0.0",
"@expo/steps": "25.1.1",
"@expo/template-file": "25.1.1",
"@expo/turtle-spawn": "25.1.1",
"@hapi/boom": "10.0.1",
"check-disk-space": "3.4.0",
"fast-glob": "3.3.2",
Generated
+35 -35
View File
@@ -2515,24 +2515,24 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:25.0.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:25.1.1, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:25.0.0"
"@expo/eas-build-job": "npm:25.0.0"
"@expo/downloader": "npm:25.1.1"
"@expo/eas-build-job": "npm:25.1.1"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:25.0.0"
"@expo/logger": "npm:25.1.1"
"@expo/package-manager": "npm:1.9.10"
"@expo/plist": "npm:^0.3.5"
"@expo/repack-app": "npm:~0.9.0"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:25.0.0"
"@expo/template-file": "npm:25.0.0"
"@expo/turtle-spawn": "npm:25.0.0"
"@expo/steps": "npm:25.1.1"
"@expo/template-file": "npm:25.1.1"
"@expo/turtle-spawn": "npm:25.1.1"
"@expo/xcpretty": "npm:^4.3.1"
"@ngrok/ngrok": "npm:1.7.0"
"@sentry/node": "npm:7.77.0"
@@ -2768,7 +2768,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/downloader@npm:25.0.0, @expo/downloader@workspace:packages/downloader":
"@expo/downloader@npm:25.1.1, @expo/downloader@workspace:packages/downloader":
version: 0.0.0-use.local
resolution: "@expo/downloader@workspace:packages/downloader"
dependencies:
@@ -2784,13 +2784,13 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:25.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:25.1.1, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
"@expo/logger": "npm:25.0.0"
"@expo/logger": "npm:25.1.1"
"@expo/results": "npm:1.0.0"
"@expo/turtle-spawn": "npm:25.0.0"
"@expo/turtle-spawn": "npm:25.1.1"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:22.20.5"
jest: "npm:^29.7.0"
@@ -2803,12 +2803,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:25.0.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:25.1.1, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:25.0.0"
"@expo/eas-build-job": "npm:25.1.1"
"@tsconfig/node22": "npm:22.0.6"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -2922,7 +2922,7 @@ __metadata:
languageName: node
linkType: hard
"@expo/logger@npm:25.0.0, @expo/logger@workspace:packages/logger":
"@expo/logger@npm:25.1.1, @expo/logger@workspace:packages/logger":
version: 0.0.0-use.local
resolution: "@expo/logger@workspace:packages/logger"
dependencies:
@@ -3122,12 +3122,12 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:25.0.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:25.1.1, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:25.0.0"
"@expo/logger": "npm:25.0.0"
"@expo/eas-build-job": "npm:25.1.1"
"@expo/logger": "npm:25.1.1"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
@@ -3153,7 +3153,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/template-file@npm:25.0.0, @expo/template-file@workspace:packages/template-file":
"@expo/template-file@npm:25.1.1, @expo/template-file@workspace:packages/template-file":
version: 0.0.0-use.local
resolution: "@expo/template-file@workspace:packages/template-file"
dependencies:
@@ -3176,11 +3176,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/turtle-spawn@npm:25.0.0, @expo/turtle-spawn@workspace:packages/turtle-spawn":
"@expo/turtle-spawn@npm:25.1.1, @expo/turtle-spawn@workspace:packages/turtle-spawn":
version: 0.0.0-use.local
resolution: "@expo/turtle-spawn@workspace:packages/turtle-spawn"
dependencies:
"@expo/logger": "npm:25.0.0"
"@expo/logger": "npm:25.1.1"
"@expo/spawn-async": "npm:^1.7.2"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:22.20.5"
@@ -3196,17 +3196,17 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:25.0.0"
"@expo/build-tools": "npm:25.1.1"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/eas-build-job": "npm:25.0.0"
"@expo/logger": "npm:25.0.0"
"@expo/eas-build-job": "npm:25.1.1"
"@expo/logger": "npm:25.1.1"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:25.0.0"
"@expo/template-file": "npm:25.0.0"
"@expo/turtle-spawn": "npm:25.0.0"
"@expo/steps": "npm:25.1.1"
"@expo/template-file": "npm:25.1.1"
"@expo/turtle-spawn": "npm:25.1.1"
"@hapi/boom": "npm:10.0.1"
"@types/babel__core": "npm:7.20.5"
"@types/fs-extra": "npm:11.0.4"
@@ -10090,7 +10090,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:25.0.0"
"@expo/steps": "npm:25.1.1"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:22.20.5"
@@ -10674,12 +10674,12 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:25.0.0"
"@expo/build-tools": "npm:25.1.1"
"@expo/bunyan": "npm:^4.0.1"
"@expo/eas-build-job": "npm:25.0.0"
"@expo/logger": "npm:25.0.0"
"@expo/eas-build-job": "npm:25.1.1"
"@expo/logger": "npm:25.1.1"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:25.0.0"
"@expo/turtle-spawn": "npm:25.1.1"
"@types/fs-extra": "npm:^11.0.4"
"@types/hapi__joi": "npm:^17.1.14"
"@types/lodash": "npm:^4.17.4"
@@ -10733,11 +10733,11 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:25.0.0"
"@expo/eas-json": "npm:25.0.0"
"@expo/eas-build-job": "npm:25.1.1"
"@expo/eas-json": "npm:25.1.1"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:25.0.0"
"@expo/logger": "npm:25.1.1"
"@expo/multipart-body-parser": "npm:2.0.0"
"@expo/osascript": "npm:2.1.4"
"@expo/package-manager": "npm:1.9.10"
@@ -10749,7 +10749,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:25.0.0"
"@expo/steps": "npm:25.1.1"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:6.3.1"
"@graphql-codegen/introspection": "npm:5.0.2"