mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8aa5eb05d | ||
|
|
68596f8c0c | ||
|
|
1e38ada929 | ||
|
|
d52629e9f3 | ||
|
|
bed6354ef5 | ||
|
|
9aac1a0dff | ||
|
|
c1c2b748b4 | ||
|
|
7b39b92df8 | ||
|
|
929905ba7e | ||
|
|
ef7fceba86 | ||
|
|
c2dc766c38 |
No files matched your search
@@ -2,6 +2,23 @@
|
||||
|
||||
This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
## [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
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.14.0",
|
||||
"PRODUCTION": "24.14.0"
|
||||
"STAGING": "25.0.0",
|
||||
"PRODUCTION": "25.0.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,5 +1,19 @@
|
||||
# @expo/build-tools
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -41,17 +41,17 @@
|
||||
"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.0",
|
||||
"@expo/eas-build-job": "25.1.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "25.0.0",
|
||||
"@expo/logger": "25.1.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "25.0.0",
|
||||
"@expo/template-file": "25.0.0",
|
||||
"@expo/turtle-spawn": "25.0.0",
|
||||
"@expo/steps": "25.1.0",
|
||||
"@expo/template-file": "25.1.0",
|
||||
"@expo/turtle-spawn": "25.1.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
|
||||
+38
-2
@@ -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,
|
||||
|
||||
+48
@@ -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
|
||||
|
||||
+24
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -27,6 +27,8 @@ import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
|
||||
import { sleepAsync } from '../../../utils/retry';
|
||||
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
|
||||
import {
|
||||
createCorsOriginsInputProvider,
|
||||
createFrameAncestorsInputProvider,
|
||||
createIosSessionStartupInputProviders,
|
||||
createPrivatePreviewUrlInputProvider,
|
||||
createPublicPreviewInputProvider,
|
||||
@@ -34,6 +36,8 @@ import {
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
parseCorsOriginsInput,
|
||||
parseFrameAncestorsInput,
|
||||
parseServeSimLaunchInputs,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
@@ -44,8 +48,10 @@ import {
|
||||
|
||||
import {
|
||||
EXPO_DEVICE_HUB_STATE_DIR,
|
||||
corsOriginArgs,
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
frameAncestorArgs,
|
||||
simulatorPreviewPageUrl,
|
||||
startDeviceSessionHostAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
@@ -321,6 +327,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 +576,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 +793,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]'
|
||||
|
||||
@@ -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`, {})
|
||||
|
||||
+133
-4
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
@@ -880,6 +880,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,9 @@
|
||||
# create-eas-build-function
|
||||
|
||||
## 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.0",
|
||||
"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.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/downloader
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-build-cache-provider
|
||||
|
||||
## 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.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-build-job
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,9 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "25.0.0",
|
||||
"@expo/logger": "25.1.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "25.0.0",
|
||||
"@expo/turtle-spawn": "25.1.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# eas-cli
|
||||
|
||||
## 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
File diff suppressed because it is too large.
Load diff
Generated
+40
@@ -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.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,11 +48,11 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "25.0.0",
|
||||
"@expo/eas-json": "25.0.0",
|
||||
"@expo/eas-build-job": "25.1.0",
|
||||
"@expo/eas-json": "25.1.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "25.0.0",
|
||||
"@expo/logger": "25.1.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "25.0.0",
|
||||
"@expo/steps": "25.1.0",
|
||||
"@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', () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/eas-json
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.0",
|
||||
"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.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# eas-cli-local-build-plugin
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.0",
|
||||
"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.0",
|
||||
"@expo/bunyan": "^4.0.1",
|
||||
"@expo/eas-build-job": "25.0.0",
|
||||
"@expo/logger": "25.0.0",
|
||||
"@expo/eas-build-job": "25.1.0",
|
||||
"@expo/logger": "25.1.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "25.0.0",
|
||||
"@expo/turtle-spawn": "25.1.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-paths": "^2.2.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/logger
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/logger",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/steps
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.0",
|
||||
"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.0",
|
||||
"@expo/logger": "25.1.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/template-file
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/template-file",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/turtle-spawn
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
No changes in this release.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/turtle-spawn",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.0",
|
||||
"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.0",
|
||||
"@expo/spawn-async": "^1.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# @expo/worker
|
||||
|
||||
## 25.1.0
|
||||
|
||||
No changes in this release.
|
||||
|
||||
## 25.0.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "25.0.0",
|
||||
"version": "25.1.0",
|
||||
"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.0",
|
||||
"@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.0",
|
||||
"@expo/logger": "25.1.0",
|
||||
"@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.0",
|
||||
"@expo/template-file": "25.1.0",
|
||||
"@expo/turtle-spawn": "25.1.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
"check-disk-space": "3.4.0",
|
||||
"fast-glob": "3.3.2",
|
||||
|
||||
@@ -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.0, @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.0"
|
||||
"@expo/eas-build-job": "npm:25.1.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:25.0.0"
|
||||
"@expo/logger": "npm:25.1.0"
|
||||
"@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.0"
|
||||
"@expo/template-file": "npm:25.1.0"
|
||||
"@expo/turtle-spawn": "npm:25.1.0"
|
||||
"@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.0, @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.0, @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.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/turtle-spawn": "npm:25.0.0"
|
||||
"@expo/turtle-spawn": "npm:25.1.0"
|
||||
"@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.0, @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.0"
|
||||
"@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.0, @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.0, @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.0"
|
||||
"@expo/logger": "npm:25.1.0"
|
||||
"@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.0, @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.0, @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.0"
|
||||
"@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.0"
|
||||
"@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.0"
|
||||
"@expo/logger": "npm:25.1.0"
|
||||
"@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.0"
|
||||
"@expo/template-file": "npm:25.1.0"
|
||||
"@expo/turtle-spawn": "npm:25.1.0"
|
||||
"@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.0"
|
||||
"@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.0"
|
||||
"@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.0"
|
||||
"@expo/logger": "npm:25.1.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:25.0.0"
|
||||
"@expo/turtle-spawn": "npm:25.1.0"
|
||||
"@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.0"
|
||||
"@expo/eas-json": "npm:25.1.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:25.0.0"
|
||||
"@expo/logger": "npm:25.1.0"
|
||||
"@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.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:6.3.1"
|
||||
"@graphql-codegen/introspection": "npm:5.0.2"
|
||||
|
||||
Reference in new issue
Block a user