mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c45fdfd7e | ||
|
|
b024609a5e | ||
|
|
8afc461064 | ||
|
|
d2ed1feb67 | ||
|
|
cc49343b39 | ||
|
|
10f9edc910 | ||
|
|
9d1a6b7796 | ||
|
|
d8cca2c8e8 | ||
|
|
fb818fce54 | ||
|
|
11263c23a0 | ||
|
|
80baa13ffa | ||
|
|
58982ee212 | ||
|
|
33225d2b73 | ||
|
|
7a97f297ee | ||
|
|
5f952fd33b | ||
|
|
45354d12da | ||
|
|
598f0050be | ||
|
|
543261b68a | ||
|
|
e1148f2b00 | ||
|
|
2c1837f8ba | ||
|
|
7bdc009c5e | ||
|
|
b5592e16ad |
No files matched your search
+22
-2
@@ -6,6 +6,27 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [expo-cocoapods-proxy] Fix iOS worker tarball build failing on macOS Tahoe due to bundler incompatibility with RubyGems 4. ([#3824](https://github.com/expo/eas-cli/pull/3824) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [20.1.0](https://github.com/expo/eas-cli/releases/tag/v20.1.0) - 2026-06-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas update:embedded:view` command. ([#3810](https://github.com/expo/eas-cli/pull/3810) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas update:embedded:list` command. ([#3811](https://github.com/expo/eas-cli/pull/3811) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas update:embedded:delete` command. ([#3809](https://github.com/expo/eas-cli/pull/3809) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [20.0.0](https://github.com/expo/eas-cli/releases/tag/v20.0.0) - 2026-05-29
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Rename observe commands: `observe:logs` → `observe:events` (events emitted via `logEvent`), previous `observe:events` → `observe:metrics` (individual performance metric samples), previous `observe:metrics` → `observe:metrics-summary` (aggregated stats by app version). ([#3778](https://github.com/expo/eas-cli/pull/3778) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🎉 New features
|
||||
@@ -16,8 +37,7 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Fix `eas/start_ios_simulator` hanging on Xcode 26.4 by writing the readiness screenshot to a temp file instead of `/dev/null`. ([#3794](https://github.com/expo/eas-cli/pull/3794) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
- [build-tools] Disable `apsd` in the iOS Simulator after boot to stop a push-registration log storm that pegs `diagnosticd` CPU for the lifetime of the Simulator. ([#3795](https://github.com/expo/eas-cli/pull/3795) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [19.1.0](https://github.com/expo/eas-cli/releases/tag/v19.1.0) - 2026-05-25
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "20.0.0",
|
||||
"version": "20.1.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -3,4 +3,6 @@ _.path = "./tools/bin"
|
||||
MISE_NODE_COREPACK = "true"
|
||||
|
||||
[tools]
|
||||
gcloud = "570.0.0"
|
||||
jq = "1.8.1"
|
||||
node = "20.19.4"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "20.0.0",
|
||||
"version": "20.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -38,14 +38,14 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "20.0.0",
|
||||
"@expo/eas-build-job": "20.0.0",
|
||||
"@expo/eas-build-job": "20.1.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "20.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "20.0.0",
|
||||
"@expo/steps": "20.1.0",
|
||||
"@expo/template-file": "20.0.0",
|
||||
"@expo/turtle-spawn": "20.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
@@ -77,7 +77,7 @@
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/repack-app": "~0.4.1",
|
||||
"@expo/repack-app": "~0.6.1",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
|
||||
@@ -22,13 +22,13 @@ describe('Datadog singleton', () => {
|
||||
Datadog.setup(null);
|
||||
});
|
||||
|
||||
it('POSTs distribution metrics to the turtle build metrics endpoint', () => {
|
||||
it('POSTs distribution metrics to the turtle build metrics endpoint for build targets', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1234, {
|
||||
@@ -51,6 +51,7 @@ describe('Datadog singleton', () => {
|
||||
build_phase: 'install_dependencies',
|
||||
platform: 'ios',
|
||||
result: 'success',
|
||||
target: 'build',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -61,13 +62,49 @@ describe('Datadog singleton', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('POSTs logs to the turtle build logs endpoint', () => {
|
||||
it('POSTs distribution metrics to the turtle job run metrics endpoint for jobRun targets', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.maestro.install', 1, {
|
||||
maestro_version: '2.1.0',
|
||||
});
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'eas.maestro.install',
|
||||
type: 'distribution',
|
||||
value: 1,
|
||||
tags: {
|
||||
maestro_version: '2.1.0',
|
||||
target: 'job_run',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: { Authorization: 'Bearer token-abc' },
|
||||
retries: 2,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('POSTs logs to the turtle build logs endpoint with buildId for build targets', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
Datadog.log('artifact dry-run matched', {
|
||||
@@ -85,6 +122,38 @@ describe('Datadog singleton', () => {
|
||||
tags: {
|
||||
event: 'find_artifacts_absolute_path_dry_run',
|
||||
status: 'match',
|
||||
target: 'build',
|
||||
},
|
||||
},
|
||||
headers: { Authorization: 'Bearer token-abc' },
|
||||
shouldThrowOnNotOk: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('POSTs logs to the turtle job run logs endpoint with jobRunId for jobRun targets', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
|
||||
});
|
||||
|
||||
Datadog.log('Gradle cache restored (hit)', {
|
||||
cache_type: 'gradle',
|
||||
});
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/turtle-job-runs/logs',
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
jobRunId: 'job-run-id',
|
||||
message: 'Gradle cache restored (hit)',
|
||||
tags: {
|
||||
cache_type: 'gradle',
|
||||
target: 'job_run',
|
||||
},
|
||||
},
|
||||
headers: { Authorization: 'Bearer token-abc' },
|
||||
@@ -102,12 +171,12 @@ describe('Datadog singleton', () => {
|
||||
expect(turtleFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows upload failures', async () => {
|
||||
it('swallows metric upload failures for build targets', async () => {
|
||||
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
@@ -119,18 +188,54 @@ describe('Datadog singleton', () => {
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: {
|
||||
metrics: [{ name: 'eas.build.phase_duration', type: 'distribution', value: 1, tags: {} }],
|
||||
metrics: [
|
||||
{
|
||||
name: 'eas.build.phase_duration',
|
||||
type: 'distribution',
|
||||
value: 1,
|
||||
tags: { target: 'build' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows log upload failures', async () => {
|
||||
it('labels metric upload failures for jobRun targets with the jobRun kind', async () => {
|
||||
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.maestro.install', 1);
|
||||
await flushPromises();
|
||||
|
||||
expect(sentryCaptureMock).toHaveBeenCalledWith(
|
||||
'Failed to report turtle jobRun metric',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'eas.maestro.install',
|
||||
type: 'distribution',
|
||||
value: 1,
|
||||
tags: { target: 'job_run' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows log upload failures for build targets', async () => {
|
||||
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
Datadog.log('artifact dry-run matched');
|
||||
@@ -142,7 +247,37 @@ describe('Datadog singleton', () => {
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: {
|
||||
log: { buildId: 'build-id', message: 'artifact dry-run matched', tags: {} },
|
||||
log: {
|
||||
buildId: 'build-id',
|
||||
message: 'artifact dry-run matched',
|
||||
tags: { target: 'build' },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('labels log upload failures for jobRun targets with the jobRun kind', async () => {
|
||||
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
|
||||
});
|
||||
|
||||
Datadog.log('Gradle cache restored (hit)');
|
||||
await flushPromises();
|
||||
|
||||
expect(sentryCaptureMock).toHaveBeenCalledWith(
|
||||
'Failed to report turtle jobRun log',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: {
|
||||
log: {
|
||||
jobRunId: 'job-run-id',
|
||||
message: 'Gradle cache restored (hit)',
|
||||
tags: { target: 'job_run' },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -153,13 +288,13 @@ describe('Datadog singleton', () => {
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'first-build-id',
|
||||
robotAccessToken: 'first-token',
|
||||
target: { kind: 'build', turtleBuildId: 'first-build-id' },
|
||||
});
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'second-build-id',
|
||||
robotAccessToken: 'second-token',
|
||||
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
@@ -167,7 +302,7 @@ describe('Datadog singleton', () => {
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://api.expo.test/v2/turtle-builds/second-build-id/metrics',
|
||||
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer second-token' },
|
||||
@@ -175,7 +310,7 @@ describe('Datadog singleton', () => {
|
||||
);
|
||||
expect(turtleFetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://api.expo.test/v2/turtle-builds/logs',
|
||||
'https://api.expo.test/v2/turtle-job-runs/logs',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer second-token' },
|
||||
@@ -192,8 +327,8 @@ describe('Datadog singleton', () => {
|
||||
);
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
let flushed = false;
|
||||
@@ -219,8 +354,8 @@ describe('Datadog singleton', () => {
|
||||
);
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
let flushed = false;
|
||||
@@ -236,6 +371,35 @@ describe('Datadog singleton', () => {
|
||||
|
||||
expect(flushed).toBe(true);
|
||||
});
|
||||
|
||||
it('only awaits uploads enqueued since the previous flush', async () => {
|
||||
let resolveSecondUpload!: (response: Response) => void;
|
||||
turtleFetchMock.mockReturnValueOnce(new Promise<Response>(() => {})).mockReturnValueOnce(
|
||||
new Promise<Response>(resolve => {
|
||||
resolveSecondUpload = resolve;
|
||||
})
|
||||
);
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
|
||||
// The first upload never settles; the first flush rotates it out of the queue.
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
void Datadog.flushAsync();
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 2);
|
||||
let flushed = false;
|
||||
const flushPromise = Datadog.flushAsync().then(() => {
|
||||
flushed = true;
|
||||
});
|
||||
|
||||
resolveSecondUpload({} as Response);
|
||||
await flushPromise;
|
||||
|
||||
expect(flushed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
import { RuntimeSettings } from '../runtimeSettings';
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
jest.mock('node-fetch', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../sentry', () => ({
|
||||
Sentry: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('runtimeSettings', () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
};
|
||||
const originalCacheUrls = {
|
||||
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
|
||||
EAS_NODEJS_CACHE_URL: process.env.EAS_NODEJS_CACHE_URL,
|
||||
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
|
||||
EAS_COCOAPODS_CACHE_URL: process.env.EAS_COCOAPODS_CACHE_URL,
|
||||
};
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
jest.restoreAllMocks();
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
logger.info.mockReset();
|
||||
logger.warn.mockReset();
|
||||
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
|
||||
restoreEnv('EAS_NODEJS_CACHE_URL', originalCacheUrls.EAS_NODEJS_CACHE_URL);
|
||||
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
|
||||
restoreEnv('EAS_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_COCOAPODS_CACHE_URL);
|
||||
mockProcessPlatform(originalPlatform);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({}));
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
jest.mocked(fetch).mockReset();
|
||||
logger.info.mockReset();
|
||||
logger.warn.mockReset();
|
||||
});
|
||||
|
||||
it('fetches runtime settings from the staging and production buckets', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({}));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
},
|
||||
});
|
||||
await RuntimeSettings.loadAsync({ environment: 'production', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'test', logger: logger as any });
|
||||
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
|
||||
{
|
||||
signal: expect.anything(),
|
||||
}
|
||||
);
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
|
||||
{
|
||||
signal: expect.anything(),
|
||||
}
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not use caches by default when remote settings are unavailable', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({}, 404));
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith('Failed to fetch worker runtime settings', {
|
||||
extras: {
|
||||
status: 404,
|
||||
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
|
||||
},
|
||||
level: 'warning',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not override existing settings when remote settings are invalid', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest
|
||||
.mocked(fetch)
|
||||
.mockResolvedValueOnce(response({ caches: { linux: { npm: true } } }))
|
||||
.mockResolvedValueOnce(response({ iosPrecompiledModules: 'enabled' }));
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
|
||||
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to load worker runtime settings',
|
||||
expect.any(Error),
|
||||
{
|
||||
extras: {
|
||||
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
|
||||
},
|
||||
level: 'warning',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts partial runtime settings and ignores unknown fields', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
|
||||
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
|
||||
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { npm: false, nodejs: true, maven: true },
|
||||
darwin: { npm: true, maven: true, cocoapods: false },
|
||||
},
|
||||
iosPrecompiledModules: true,
|
||||
unknownFutureSetting: true,
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://node.example');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
|
||||
mockProcessPlatform('darwin');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
|
||||
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('uses Maven and CocoaPods caches when enabled for the current platform and their environment variables exist', async () => {
|
||||
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
|
||||
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { maven: true },
|
||||
darwin: { maven: true, cocoapods: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
|
||||
mockProcessPlatform('darwin');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://pods.example');
|
||||
});
|
||||
|
||||
it('uses cache URL fallbacks when enabled cache environment variables are not set', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { npm: true, nodejs: true, maven: true },
|
||||
darwin: { cocoapods: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
nodejs: 'https://fallback-node.example',
|
||||
maven: 'https://fallback-maven.example',
|
||||
cocoapods: 'https://fallback-pods.example',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://fallback-npm.example');
|
||||
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://fallback-node.example');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://fallback-maven.example');
|
||||
mockProcessPlatform('darwin');
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://fallback-pods.example');
|
||||
});
|
||||
|
||||
it('prefers cache URL environment variables over fallbacks', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { npm: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
|
||||
});
|
||||
|
||||
it('does not use cache URL fallbacks when runtime settings disable caches', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { npm: false },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('clears cache URL fallbacks when loading without them', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
},
|
||||
});
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not use caches when runtime settings are not loaded', () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
|
||||
jest.isolateModules(() => {
|
||||
const { RuntimeSettings } = require('../runtimeSettings');
|
||||
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports when loaded runtime settings omit the current platform cache settings', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
|
||||
mockProcessPlatform('darwin');
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Runtime settings are missing cache settings for platform "darwin"',
|
||||
}),
|
||||
{
|
||||
extras: {
|
||||
configuredPlatforms: ['linux'],
|
||||
platform: 'darwin',
|
||||
},
|
||||
level: 'error',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function mockProcessPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: platform,
|
||||
});
|
||||
}
|
||||
|
||||
function response(body: unknown, status = 200): any {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { restoreCredentials } from '../../android/credentials';
|
||||
import { uploadEmbeddedBundleAsync } from '../../utils/expoUpdatesEmbedded';
|
||||
import androidBuilder from '../android';
|
||||
import { runBuilderWithHooksAsync } from '../common';
|
||||
import {
|
||||
@@ -57,6 +58,9 @@ jest.mock('../../utils/expoUpdates', () => ({
|
||||
configureExpoUpdatesIfInstalledAsync: jest.fn(),
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
|
||||
}));
|
||||
jest.mock('../../utils/expoUpdatesEmbedded', () => ({
|
||||
uploadEmbeddedBundleAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/hooks', () => ({
|
||||
Hook: {
|
||||
POST_INSTALL: 'POST_INSTALL',
|
||||
@@ -269,4 +273,37 @@ describe(androidBuilder, () => {
|
||||
|
||||
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
|
||||
});
|
||||
|
||||
it('runs the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is set', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE: '1',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).toHaveBeenCalledWith(ctx);
|
||||
});
|
||||
|
||||
it('skips the embedded bundle upload phase when EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE is not set', async () => {
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: createMockLogger(),
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(uploadEmbeddedBundleAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
|
||||
@@ -208,6 +209,12 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
|
||||
if (ctx.isLocal) {
|
||||
ctx.logger.info('Local builds do not support saving cache.');
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync,
|
||||
} from '../utils/expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
|
||||
import { Hook, runHookIfPresent } from '../utils/hooks';
|
||||
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
|
||||
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
|
||||
@@ -209,6 +210,12 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
if (ctx.env.EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE) {
|
||||
await ctx.runBuildPhase(BuildPhase.UPLOAD_EMBEDDED_BUNDLE, async () => {
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => {
|
||||
if (ctx.isLocal) {
|
||||
ctx.logger.info('Local builds do not support saving cache.');
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { ExpoRunFormatter } from '@expo/xcpretty';
|
||||
import fg from 'fast-glob';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { XcodeBuildLogger } from '../xcpretty';
|
||||
|
||||
jest.mock('@expo/spawn-async');
|
||||
jest.mock('@expo/xcpretty');
|
||||
jest.mock('fast-glob');
|
||||
jest.mock('../../sentry', () => ({
|
||||
Sentry: {
|
||||
setup: jest.fn(),
|
||||
capture: jest.fn(),
|
||||
flush: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(XcodeBuildLogger, () => {
|
||||
let stdout: EventEmitter;
|
||||
let formatter: { pipe: jest.Mock; getBuildSummary: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
stdout = new EventEmitter();
|
||||
const spawnPromise = Object.assign(new Promise(() => {}), {
|
||||
child: { stdout, kill: jest.fn() },
|
||||
});
|
||||
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
|
||||
|
||||
formatter = {
|
||||
pipe: jest.fn(),
|
||||
getBuildSummary: jest.fn().mockReturnValue('summary'),
|
||||
};
|
||||
jest.mocked(ExpoRunFormatter.create).mockReturnValue(formatter as any);
|
||||
|
||||
jest.mocked(fg).mockResolvedValue(['build.log'] as any);
|
||||
});
|
||||
|
||||
async function startWatchingAsync(logger: ReturnType<typeof createMockLogger>): Promise<void> {
|
||||
const buildLogger = new XcodeBuildLogger(logger, '/project');
|
||||
await buildLogger.watchLogFiles('/logs');
|
||||
}
|
||||
|
||||
it('logs formatted lines', async () => {
|
||||
const logger = createMockLogger();
|
||||
await startWatchingAsync(logger);
|
||||
|
||||
formatter.pipe.mockReturnValue(['line1', 'line2']);
|
||||
stdout.emit('data', 'raw xcodebuild output');
|
||||
|
||||
expect(formatter.pipe).toHaveBeenCalledWith('raw xcodebuild output');
|
||||
expect(logger.info).toHaveBeenCalledWith('line1');
|
||||
expect(logger.info).toHaveBeenCalledWith('line2');
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to raw logs when the formatter throws', async () => {
|
||||
const logger = createMockLogger();
|
||||
await startWatchingAsync(logger);
|
||||
|
||||
formatter.pipe.mockImplementation(() => {
|
||||
throw new RangeError('Invalid array length');
|
||||
});
|
||||
stdout.emit('data', 'offending output');
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('offending output');
|
||||
});
|
||||
|
||||
it('reports the formatter error to Sentry only once', async () => {
|
||||
const logger = createMockLogger();
|
||||
await startWatchingAsync(logger);
|
||||
|
||||
const error = new RangeError('Invalid array length');
|
||||
formatter.pipe.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
stdout.emit('data', 'offending output');
|
||||
stdout.emit('data', 'more offending output');
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('offending output');
|
||||
expect(logger.info).toHaveBeenCalledWith('more offending output');
|
||||
expect(Sentry.capture).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'xcpretty formatter failed to parse xcodebuild logs',
|
||||
error,
|
||||
{ extras: { data: 'offending output' } }
|
||||
);
|
||||
});
|
||||
});
|
||||
+18
-3
@@ -6,11 +6,14 @@ import fg from 'fast-glob';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
const CHECK_FILE_INTERVAL_MS = 1000;
|
||||
|
||||
export class XcodeBuildLogger {
|
||||
private loggerError?: Error;
|
||||
private flushing: boolean = false;
|
||||
private reportedFormatterError: boolean = false;
|
||||
private logReaderPromise?: SpawnPromise<SpawnResult>;
|
||||
private logsPath?: string;
|
||||
|
||||
@@ -60,9 +63,21 @@ export class XcodeBuildLogger {
|
||||
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
|
||||
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
|
||||
this.logReaderPromise.child.stdout.on('data', (data: string) => {
|
||||
const lines = formatter.pipe(data.toString());
|
||||
for (const line of lines) {
|
||||
this.logger.info(line);
|
||||
try {
|
||||
const lines = formatter.pipe(data.toString());
|
||||
for (const line of lines) {
|
||||
this.logger.info(line);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// The formatter can throw on unexpected xcodebuild output. Fall back to
|
||||
// raw logs instead of crashing the process with an uncaught exception.
|
||||
if (!this.reportedFormatterError) {
|
||||
this.reportedFormatterError = true;
|
||||
Sentry.capture('xcpretty formatter failed to parse xcodebuild logs', err, {
|
||||
extras: { data: data.toString().slice(-2000) },
|
||||
});
|
||||
}
|
||||
this.logger.info(data.toString());
|
||||
}
|
||||
});
|
||||
await this.logReaderPromise;
|
||||
@@ -1,15 +1,26 @@
|
||||
import { Sentry } from './sentry';
|
||||
import { turtleFetch } from './utils/turtleFetch';
|
||||
|
||||
type MetricsTarget =
|
||||
| { kind: 'build'; turtleBuildId: string }
|
||||
| { kind: 'jobRun'; turtleJobRunId: string };
|
||||
|
||||
type DatadogSetupOptions = {
|
||||
expoApiV2BaseUrl: string;
|
||||
turtleBuildId: string;
|
||||
robotAccessToken: string;
|
||||
target: MetricsTarget;
|
||||
};
|
||||
|
||||
let setupOptions: DatadogSetupOptions | null = null;
|
||||
let pendingUploads: Promise<unknown>[] = [];
|
||||
|
||||
function withTargetTag(
|
||||
tags: Record<string, string>,
|
||||
target: MetricsTarget
|
||||
): Record<string, string> {
|
||||
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
|
||||
}
|
||||
|
||||
export const Datadog = {
|
||||
setup(opts: DatadogSetupOptions | null): void {
|
||||
setupOptions = opts;
|
||||
@@ -19,28 +30,29 @@ export const Datadog = {
|
||||
if (!setupOptions) {
|
||||
return;
|
||||
}
|
||||
const { expoApiV2BaseUrl, turtleBuildId, robotAccessToken } = setupOptions;
|
||||
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
|
||||
const metrics = [
|
||||
{
|
||||
name,
|
||||
type: 'distribution' as const,
|
||||
value,
|
||||
tags,
|
||||
tags: withTargetTag(tags, target),
|
||||
},
|
||||
];
|
||||
|
||||
const uploadPromise = turtleFetch(
|
||||
new URL(`turtle-builds/${turtleBuildId}/metrics`, expoApiV2BaseUrl).toString(),
|
||||
'POST',
|
||||
{
|
||||
json: { metrics },
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
}
|
||||
).catch(err => {
|
||||
Sentry.capture('Failed to report turtle build metric', err, {
|
||||
const metricsPath =
|
||||
target.kind === 'build'
|
||||
? `turtle-builds/${target.turtleBuildId}/metrics`
|
||||
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
|
||||
|
||||
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
|
||||
json: { metrics },
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
}).catch(err => {
|
||||
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
|
||||
extras: { metrics },
|
||||
});
|
||||
});
|
||||
@@ -52,25 +64,22 @@ export const Datadog = {
|
||||
if (!setupOptions) {
|
||||
return;
|
||||
}
|
||||
const { expoApiV2BaseUrl, turtleBuildId, robotAccessToken } = setupOptions;
|
||||
const log = {
|
||||
buildId: turtleBuildId,
|
||||
message,
|
||||
tags,
|
||||
};
|
||||
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
|
||||
const tagsWithTarget = withTargetTag(tags, target);
|
||||
const log =
|
||||
target.kind === 'build'
|
||||
? { buildId: target.turtleBuildId, message, tags: tagsWithTarget }
|
||||
: { jobRunId: target.turtleJobRunId, message, tags: tagsWithTarget };
|
||||
const logsPath = target.kind === 'build' ? 'turtle-builds/logs' : 'turtle-job-runs/logs';
|
||||
|
||||
const uploadPromise = turtleFetch(
|
||||
new URL('turtle-builds/logs', expoApiV2BaseUrl).toString(),
|
||||
'POST',
|
||||
{
|
||||
json: log,
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
shouldThrowOnNotOk: false,
|
||||
}
|
||||
).catch(err => {
|
||||
Sentry.capture('Failed to report turtle build log', err, {
|
||||
const uploadPromise = turtleFetch(new URL(logsPath, expoApiV2BaseUrl).toString(), 'POST', {
|
||||
json: log,
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
shouldThrowOnNotOk: false,
|
||||
}).catch(err => {
|
||||
Sentry.capture(`Failed to report turtle ${target.kind} log`, err, {
|
||||
extras: { log },
|
||||
});
|
||||
});
|
||||
@@ -79,6 +88,10 @@ export const Datadog = {
|
||||
},
|
||||
|
||||
async flushAsync(): Promise<void> {
|
||||
await Promise.allSettled(pendingUploads);
|
||||
// Rotate so each flush only awaits its own batch; uploads enqueued during the
|
||||
// await land in the fresh array for a later flush.
|
||||
const uploads = pendingUploads;
|
||||
pendingUploads = [];
|
||||
await Promise.allSettled(uploads);
|
||||
},
|
||||
};
|
||||
@@ -27,3 +27,4 @@ export * from './generic';
|
||||
|
||||
export { Datadog } from './datadog';
|
||||
export { Sentry } from './sentry';
|
||||
export * from './runtimeSettings';
|
||||
@@ -10,8 +10,8 @@ import type { Credentials } from './credentials/manager';
|
||||
import { createFastfileForResigningBuild } from './fastfile';
|
||||
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
|
||||
import { isTVOS } from './tvos';
|
||||
import { XcodeBuildLogger } from './xcpretty';
|
||||
import { COMMON_FASTLANE_ENV } from '../common/fastlane';
|
||||
import { XcodeBuildLogger } from '../common/xcpretty';
|
||||
import { BuildContext, SkipNativeBuildError } from '../context';
|
||||
|
||||
export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
|
||||
@@ -51,7 +51,7 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
|
||||
return {};
|
||||
}
|
||||
|
||||
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
|
||||
if (ctx.env.EAS_USE_PRECOMPILED_MODULES !== '1') {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fetch from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from './sentry';
|
||||
|
||||
const ENVIRONMENT_TO_RUNTIME_SETTINGS_URL: Record<string, string | undefined> = {
|
||||
staging: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
|
||||
production: 'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
|
||||
};
|
||||
|
||||
const RuntimeSettingsSchema = z
|
||||
.object({
|
||||
caches: z.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
npm: z.boolean(),
|
||||
nodejs: z.boolean(),
|
||||
maven: z.boolean(),
|
||||
cocoapods: z.boolean(),
|
||||
})
|
||||
.partial()
|
||||
),
|
||||
iosPrecompiledModules: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
|
||||
type CacheUrlFallbacks = {
|
||||
npm?: string | null;
|
||||
nodejs?: string | null;
|
||||
maven?: string | null;
|
||||
cocoapods?: string | null;
|
||||
};
|
||||
|
||||
let runtimeSettings: z.infer<typeof RuntimeSettingsSchema> = {};
|
||||
let cacheUrlFallbacks: CacheUrlFallbacks = {};
|
||||
|
||||
export namespace RuntimeSettings {
|
||||
export async function loadAsync({
|
||||
environment,
|
||||
logger,
|
||||
cacheUrlFallbacks: nextCacheUrlFallbacks = {},
|
||||
}: {
|
||||
environment: string;
|
||||
logger: bunyan;
|
||||
cacheUrlFallbacks?: CacheUrlFallbacks;
|
||||
}): Promise<void> {
|
||||
cacheUrlFallbacks = nextCacheUrlFallbacks;
|
||||
const url = ENVIRONMENT_TO_RUNTIME_SETTINGS_URL[environment];
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
{ url, status: response.status },
|
||||
'Failed to fetch worker runtime settings, using defaults'
|
||||
);
|
||||
Sentry.capture('Failed to fetch worker runtime settings', {
|
||||
extras: { url, status: response.status },
|
||||
level: 'warning',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeSettings = RuntimeSettingsSchema.parse(await response.json());
|
||||
if (runtimeSettings.caches && !runtimeSettings.caches[process.platform]) {
|
||||
Sentry.capture(
|
||||
new Error(
|
||||
`Runtime settings are missing cache settings for platform "${process.platform}"`
|
||||
),
|
||||
{
|
||||
extras: {
|
||||
configuredPlatforms: Object.keys(runtimeSettings.caches),
|
||||
platform: process.platform,
|
||||
},
|
||||
level: 'error',
|
||||
}
|
||||
);
|
||||
}
|
||||
logger.info({ url, settings: runtimeSettings }, 'Loaded worker runtime settings');
|
||||
} catch (err) {
|
||||
logger.warn({ err, url }, 'Failed to load worker runtime settings, using defaults');
|
||||
Sentry.capture(
|
||||
'Failed to load worker runtime settings',
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
{
|
||||
extras: { url },
|
||||
level: 'warning',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function isUsingIosPrecompiledModulesEnabled(): boolean {
|
||||
return runtimeSettings.iosPrecompiledModules ?? false;
|
||||
}
|
||||
|
||||
export function getNpmCacheUrl(): string | null {
|
||||
return runtimeSettings.caches?.[process.platform]?.npm
|
||||
? process.env.EAS_NPM_CACHE_URL || cacheUrlFallbacks.npm || null
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getNodeJsCacheUrl(): string | null {
|
||||
return runtimeSettings.caches?.[process.platform]?.nodejs
|
||||
? process.env.EAS_NODEJS_CACHE_URL || cacheUrlFallbacks.nodejs || null
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getMavenCacheUrl(): string | null {
|
||||
return runtimeSettings.caches?.[process.platform]?.maven
|
||||
? process.env.EAS_MAVEN_CACHE_URL || cacheUrlFallbacks.maven || null
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getCocoapodsCacheUrl(): string | null {
|
||||
return runtimeSettings.caches?.[process.platform]?.cocoapods
|
||||
? process.env.EAS_COCOAPODS_CACHE_URL || cacheUrlFallbacks.cocoapods || null
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { Datadog } from '../../../datadog';
|
||||
import { createInstallMaestroBuildFunction } from '../installMaestro';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../datadog', () => ({
|
||||
Datadog: {
|
||||
distribution: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
describe('createInstallMaestroBuildFunction', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// `maestro --version` reports an installed version; `java -version` succeeds.
|
||||
// With no requested version and Maestro present, the step skips installation.
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout: command === 'maestro' ? '1.41.0\n' : '',
|
||||
})) as any);
|
||||
});
|
||||
|
||||
it('reports the detected Maestro version to Datadog on EAS Build VMs', async () => {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('1.41.0');
|
||||
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
|
||||
maestro_version: '1.41.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not report to Datadog outside EAS Build VMs', async () => {
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(createGlobalContextMock(), {
|
||||
callInputs: {},
|
||||
});
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(Datadog.distribution).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts the version when `maestro --version` prints an analytics notice', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout:
|
||||
command === 'maestro'
|
||||
? 'Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS environment variable to any value before running Maestro.\n2.0.10\n'
|
||||
: '',
|
||||
})) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
|
||||
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
|
||||
maestro_version: '2.0.10',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the trailing version when the notice itself contains an earlier version-like string', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string) => ({
|
||||
stdout: command === 'maestro' ? 'Analytics schema v2.0.0 enabled.\n2.0.10\n' : '',
|
||||
})) as any);
|
||||
|
||||
const installMaestro = createInstallMaestroBuildFunction();
|
||||
const globalCtx = createGlobalContextMock();
|
||||
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
|
||||
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
|
||||
|
||||
await step.executeAsync();
|
||||
|
||||
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { createStartIosSimulatorBuildFunction } from '../startIosSimulator';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: {
|
||||
getAvailableDevicesAsync: jest.fn(),
|
||||
getDeviceAsync: jest.fn(),
|
||||
cloneAsync: jest.fn(),
|
||||
startAsync: jest.fn(),
|
||||
waitForReadyAsync: jest.fn(),
|
||||
disableApsdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedUtils = jest.mocked(IosSimulatorUtils);
|
||||
|
||||
function createStep(callInputs?: Record<string, unknown>) {
|
||||
const logger = createMockLogger();
|
||||
const fn = createStartIosSimulatorBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
const step = fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
|
||||
return Object.assign(step, { logger });
|
||||
}
|
||||
|
||||
describe(createStartIosSimulatorBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
mockedUtils.getDeviceAsync.mockResolvedValue(null);
|
||||
mockedUtils.cloneAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
|
||||
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('disables apsd on the main device and every clone', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-1' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-2' as any });
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
|
||||
udid: 'base',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
|
||||
udid: 'clone-1',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedUtils.disableApsdAsync).toHaveBeenCalledWith({
|
||||
udid: 'clone-2',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('continues when disabling apsd fails', async () => {
|
||||
mockedUtils.disableApsdAsync.mockRejectedValue(new Error('apsd disable failed'));
|
||||
|
||||
await createStep({ device_identifier: 'iPhone 15', count: 2 }).executeAsync();
|
||||
|
||||
// Startup is not aborted: readiness is still awaited for each device.
|
||||
expect(mockedUtils.waitForReadyAsync).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { Datadog } from '../../datadog';
|
||||
|
||||
export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
@@ -123,13 +125,24 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
|
||||
|
||||
logger.info(`Maestro ${maestroVersionResult.value} is ready.`);
|
||||
outputs.maestro_version.set(maestroVersionResult.value);
|
||||
|
||||
Datadog.distribution('eas.maestro.install', 1, {
|
||||
maestro_version: maestroVersionResult.value,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getMaestroVersion({ env }: { env: BuildStepEnv }): Promise<string> {
|
||||
const maestroVersion = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
|
||||
return maestroVersion.stdout.trim();
|
||||
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
|
||||
// `maestro --version` can print an analytics notice to stdout before the version,
|
||||
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
|
||||
// Take the last version-looking token: the real version is printed after the notice, so
|
||||
// this stays correct even if the notice itself contains a version-like string. Keeps the
|
||||
// step output and the eas.maestro.install metric tag clean. Best-effort only: fall back to
|
||||
// the raw output if none is found, so we never fail the build over a version string.
|
||||
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
|
||||
return versions?.at(-1) ?? stdout.trim();
|
||||
}
|
||||
|
||||
async function installMaestro({
|
||||
|
||||
@@ -57,6 +57,16 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ios_signing_use_source_app_entitlements',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ios_signing_app_entitlements_path',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'repack_version',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
@@ -138,6 +148,12 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
iosSigningOptions: await resolveIosSigningOptionsAsync({
|
||||
job: stepsCtx.global.staticContext.job,
|
||||
logger: stepsCtx.logger,
|
||||
useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value as
|
||||
| boolean
|
||||
| undefined,
|
||||
entitlementsPath: inputs.ios_signing_app_entitlements_path.value as
|
||||
| string
|
||||
| undefined,
|
||||
}),
|
||||
logger: stepsCtx.logger,
|
||||
spawnAsync: repackSpawnAsync,
|
||||
@@ -275,9 +291,13 @@ export async function resolveAndroidSigningOptionsAsync({
|
||||
export async function resolveIosSigningOptionsAsync({
|
||||
job,
|
||||
logger,
|
||||
useAppEntitlements,
|
||||
entitlementsPath,
|
||||
}: {
|
||||
job: Job;
|
||||
logger: bunyan;
|
||||
useAppEntitlements?: boolean;
|
||||
entitlementsPath?: string;
|
||||
}): Promise<IosSigningOptions | undefined> {
|
||||
const iosJob = job as Ios.Job;
|
||||
const buildCredentials = iosJob.secrets?.buildCredentials;
|
||||
@@ -295,5 +315,7 @@ export async function resolveIosSigningOptionsAsync({
|
||||
provisioningProfile,
|
||||
keychainPath: credentials.keychainPath,
|
||||
signingIdentity: credentials.applicationTargetProvisioningProfile.data.certificateCommonName,
|
||||
useAppEntitlements,
|
||||
entitlementsPath,
|
||||
};
|
||||
}
|
||||
@@ -141,6 +141,10 @@ export async function uploadCacheAsync({
|
||||
return;
|
||||
}
|
||||
const textResult = await asyncResult(response.text());
|
||||
const errorMessage = extractValidationErrorMessage(textResult.value);
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
|
||||
}
|
||||
|
||||
@@ -231,6 +235,19 @@ export async function uploadPublicCacheAsync({
|
||||
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
|
||||
}
|
||||
|
||||
function extractValidationErrorMessage(body: string | undefined): string | undefined {
|
||||
if (!body) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const { errors } = JSON.parse(body);
|
||||
if (errors?.[0]?.code === 'VALIDATION_ERROR') {
|
||||
return errors[0].message;
|
||||
}
|
||||
} catch {}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function compressCacheAsync({
|
||||
paths,
|
||||
workingDirectory,
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepEnv,
|
||||
type BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { type CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
@@ -23,6 +26,7 @@ import {
|
||||
waitForFileAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const AGENT_DEVICE_PACKAGE_NAME = 'agent-device';
|
||||
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
|
||||
const SRC_DIR = '/tmp/agent-device-src';
|
||||
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
|
||||
@@ -64,34 +68,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
}
|
||||
|
||||
logger.info(
|
||||
packageVersion
|
||||
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
||||
: `Cloning agent-device (latest) into ${SRC_DIR}.`
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
|
||||
logger.info('Installing agent-device dependencies.');
|
||||
await spawn('bun', ['install', '--production'], {
|
||||
cwd: SRC_DIR,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Launching agent-device daemon.');
|
||||
spawnDetached({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: SRC_DIR,
|
||||
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
|
||||
});
|
||||
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
|
||||
|
||||
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
|
||||
const { port: daemonPort, token: daemonToken } = await waitForFileAsync({
|
||||
filePath: DAEMON_JSON_PATH,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'agent-device daemon credentials',
|
||||
parse: parseDaemonInfo,
|
||||
const { port: daemonPort, token: daemonToken } = await waitForDaemonInfoAsync({
|
||||
daemonProcess,
|
||||
});
|
||||
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
|
||||
|
||||
@@ -137,6 +119,93 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
});
|
||||
}
|
||||
|
||||
async function startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
||||
try {
|
||||
logger.info(`Installing ${packageSpec} globally with Bun.`);
|
||||
await spawn('bun', ['add', '--global', packageSpec], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
|
||||
if (!fs.existsSync(daemonPath)) {
|
||||
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
|
||||
}
|
||||
|
||||
logger.info(`Launching daemon from ${daemonPath}.`);
|
||||
return spawnDetached({
|
||||
command: 'node',
|
||||
args: [daemonPath],
|
||||
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
|
||||
Sentry.capture(
|
||||
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
|
||||
error,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
phase: 'agent-device-daemon-start',
|
||||
fallback: 'git-clone',
|
||||
},
|
||||
extras: {
|
||||
packageSpec,
|
||||
packageVersion: packageVersion ?? 'latest',
|
||||
bunVersion,
|
||||
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
|
||||
},
|
||||
}
|
||||
);
|
||||
logger.warn(
|
||||
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
|
||||
);
|
||||
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
|
||||
}
|
||||
}
|
||||
|
||||
async function startAgentDeviceDaemonFromGitAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
logger.info(
|
||||
packageVersion
|
||||
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
||||
: `Cloning agent-device (latest) into ${SRC_DIR}.`
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
|
||||
logger.info('Installing agent-device dependencies.');
|
||||
await spawn('bun', ['install', '--production'], {
|
||||
cwd: SRC_DIR,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Launching daemon from cloned agent-device source.');
|
||||
return spawnDetached({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: SRC_DIR,
|
||||
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
|
||||
});
|
||||
}
|
||||
|
||||
async function cloneAgentDeviceAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
@@ -153,6 +222,63 @@ async function cloneAgentDeviceAsync({
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForDaemonInfoAsync({
|
||||
daemonProcess,
|
||||
}: {
|
||||
daemonProcess: DetachedProcessHandle;
|
||||
}): Promise<{ port: number; token: string }> {
|
||||
try {
|
||||
return await waitForFileAsync({
|
||||
filePath: DAEMON_JSON_PATH,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'agent-device daemon credentials',
|
||||
parse: parseDaemonInfo,
|
||||
});
|
||||
} catch (err) {
|
||||
const output = daemonProcess.getOutput();
|
||||
throw new SystemError(
|
||||
`${
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `Timed out waiting for agent-device daemon credentials.`
|
||||
}${output ? `\nagent-device daemon output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getBunVersionForDiagnosticsAsync(env: BuildStepEnv): Promise<string> {
|
||||
try {
|
||||
const result = await spawn('bun', ['--version'], { stdio: 'pipe', env, cwd: os.tmpdir() });
|
||||
return result.stdout.trim() || 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function createAgentDevicePackageSpec(packageVersion: string | undefined): string {
|
||||
const versionSpec = packageVersion ? packageVersion.replace(/^v(?=\d)/, '') : 'latest';
|
||||
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
|
||||
}
|
||||
|
||||
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
|
||||
return path.join(
|
||||
getBunInstallDirectory(env),
|
||||
'install',
|
||||
'global',
|
||||
'node_modules',
|
||||
AGENT_DEVICE_PACKAGE_NAME,
|
||||
'dist',
|
||||
'src',
|
||||
'internal',
|
||||
'daemon.js'
|
||||
);
|
||||
}
|
||||
|
||||
function getBunInstallDirectory(env: BuildStepEnv): string {
|
||||
const bunInstall = env.BUN_INSTALL?.trim();
|
||||
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
|
||||
}
|
||||
|
||||
function parseDaemonInfo(raw: string): { port: number; token: string } {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
|
||||
@@ -65,6 +65,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
env,
|
||||
});
|
||||
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({ udid, env });
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({ udid, env });
|
||||
|
||||
logger.info('');
|
||||
@@ -96,6 +102,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
env,
|
||||
});
|
||||
|
||||
try {
|
||||
await IosSimulatorUtils.disableApsdAsync({ udid: cloneUdid, env });
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to disable apsd in the Simulator.');
|
||||
}
|
||||
|
||||
await IosSimulatorUtils.waitForReadyAsync({
|
||||
udid: cloneUdid,
|
||||
env,
|
||||
|
||||
@@ -3,8 +3,8 @@ import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import path from 'path';
|
||||
|
||||
import { XcodeBuildLogger } from './xcpretty';
|
||||
import { COMMON_FASTLANE_ENV } from '../../../common/fastlane';
|
||||
import { XcodeBuildLogger } from '../../../common/xcpretty';
|
||||
|
||||
export async function runFastlaneGym({
|
||||
workingDir,
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawnAsync, { SpawnPromise, SpawnResult } from '@expo/spawn-async';
|
||||
import { ExpoRunFormatter } from '@expo/xcpretty';
|
||||
import assert from 'assert';
|
||||
import fg from 'fast-glob';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
const CHECK_FILE_INTERVAL_MS = 1000;
|
||||
|
||||
export class XcodeBuildLogger {
|
||||
private loggerError?: Error;
|
||||
private flushing: boolean = false;
|
||||
private logReaderPromise?: SpawnPromise<SpawnResult>;
|
||||
private logsPath?: string;
|
||||
|
||||
constructor(
|
||||
private readonly logger: bunyan,
|
||||
private readonly projectRoot: string
|
||||
) {}
|
||||
|
||||
public async watchLogFiles(logsDirectory: string): Promise<void> {
|
||||
while (!this.flushing) {
|
||||
const logsFilename = await this.getBuildLogFilename(logsDirectory);
|
||||
if (logsFilename) {
|
||||
this.logsPath = path.join(logsDirectory, logsFilename);
|
||||
void this.startBuildLogger(this.logsPath);
|
||||
return;
|
||||
}
|
||||
await new Promise(res => setTimeout(res, CHECK_FILE_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
public async flush(): Promise<void> {
|
||||
this.flushing = true;
|
||||
if (this.loggerError) {
|
||||
throw this.loggerError;
|
||||
}
|
||||
if (this.logReaderPromise) {
|
||||
this.logReaderPromise.child.kill('SIGINT');
|
||||
try {
|
||||
await this.logReaderPromise;
|
||||
} catch {}
|
||||
}
|
||||
if (this.logsPath) {
|
||||
await this.findBundlerErrors(this.logsPath);
|
||||
}
|
||||
}
|
||||
private async getBuildLogFilename(logsDirectory: string): Promise<string | undefined> {
|
||||
const paths = await fg('*.log', { cwd: logsDirectory });
|
||||
return paths.length >= 1 ? paths[0] : undefined;
|
||||
}
|
||||
|
||||
private async startBuildLogger(logsPath: string): Promise<void> {
|
||||
try {
|
||||
const formatter = ExpoRunFormatter.create(this.projectRoot, {
|
||||
// TODO: Can provide xcode project name for better parsing
|
||||
isDebug: false,
|
||||
});
|
||||
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
|
||||
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
|
||||
this.logReaderPromise.child.stdout.on('data', (data: string) => {
|
||||
const lines = formatter.pipe(data.toString());
|
||||
for (const line of lines) {
|
||||
this.logger.info(line);
|
||||
}
|
||||
});
|
||||
await this.logReaderPromise;
|
||||
|
||||
this.logger.info(formatter.getBuildSummary());
|
||||
} catch (err: any) {
|
||||
if (!this.flushing) {
|
||||
this.loggerError = err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async findBundlerErrors(logsPath: string): Promise<void> {
|
||||
try {
|
||||
const logFile = await fs.readFile(logsPath, 'utf-8');
|
||||
const match = logFile.match(
|
||||
/Welcome to Metro!\s* Fast - Scalable - Integrated\s*([\s\S]*)Run CLI with --verbose flag for more details.\nCommand PhaseScriptExecution failed with a nonzero exit code/
|
||||
);
|
||||
if (match) {
|
||||
this.logger.info(match[1]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({ err }, 'Failed to read Xcode logs');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ export async function startServeSimWithTunnelAsync({
|
||||
'--stream-quality',
|
||||
'0.55',
|
||||
'--codec',
|
||||
'h264',
|
||||
'webrtc',
|
||||
],
|
||||
env,
|
||||
});
|
||||
|
||||
@@ -157,6 +157,25 @@ export namespace IosSimulatorUtils {
|
||||
);
|
||||
}
|
||||
|
||||
export async function disableApsdAsync({
|
||||
udid,
|
||||
env,
|
||||
}: {
|
||||
udid: IosSimulatorUuid;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
await spawn(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', udid, 'launchctl', 'disable', 'system/com.apple.apsd'],
|
||||
{ env }
|
||||
);
|
||||
await spawn(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', udid, 'launchctl', 'bootout', 'system/com.apple.apsd'],
|
||||
{ env }
|
||||
);
|
||||
}
|
||||
|
||||
export async function collectLogsAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
|
||||
@@ -41,4 +41,24 @@ describe('IosSimulatorUtils', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.disableApsdAsync, () => {
|
||||
it('disables and boots out apsd in the simulator', async () => {
|
||||
await IosSimulatorUtils.disableApsdAsync({
|
||||
udid: 'test-udid' as any,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', 'test-udid', 'launchctl', 'disable', 'system/com.apple.apsd'],
|
||||
{ env: process.env }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'spawn', 'test-udid', 'launchctl', 'bootout', 'system/com.apple.apsd'],
|
||||
{ env: process.env }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
|
||||
import { BuildContext } from '../../context';
|
||||
import * as expoUpdates from '../expoUpdates';
|
||||
import { uploadEmbeddedBundleAsync } from '../expoUpdatesEmbedded';
|
||||
import * as easCli from '../easCli';
|
||||
import * as artifacts from '../artifacts';
|
||||
|
||||
jest.mock('../expoUpdates');
|
||||
jest.mock('../easCli');
|
||||
jest.mock('../artifacts');
|
||||
|
||||
const mockZipEntries = jest.fn();
|
||||
const mockZipExtract = jest.fn();
|
||||
const mockZipClose = jest.fn();
|
||||
|
||||
jest.mock('node-stream-zip', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
async: jest.fn(() => ({
|
||||
entries: mockZipEntries,
|
||||
extract: mockZipExtract,
|
||||
close: mockZipClose,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
function zipEntryMap(entries: Record<string, true>): Record<string, { name: string }> {
|
||||
return Object.fromEntries(Object.keys(entries).map(name => [name, { name }]));
|
||||
}
|
||||
|
||||
function makeCtx(overrides: {
|
||||
platform: Platform;
|
||||
simulator?: boolean;
|
||||
channel?: string;
|
||||
env?: Record<string, string>;
|
||||
}): BuildContext<any> {
|
||||
const job =
|
||||
overrides.platform === Platform.IOS
|
||||
? {
|
||||
platform: Platform.IOS,
|
||||
simulator: overrides.simulator ?? false,
|
||||
updates: overrides.channel ? { channel: overrides.channel } : undefined,
|
||||
}
|
||||
: {
|
||||
platform: Platform.ANDROID,
|
||||
updates: overrides.channel ? { channel: overrides.channel } : undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
job,
|
||||
env: overrides.env ?? {},
|
||||
appConfig: Promise.resolve({
|
||||
updates: { url: 'https://u.expo.dev/project-id' },
|
||||
}),
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
markBuildPhaseSkipped: jest.fn(),
|
||||
markBuildPhaseHasWarnings: jest.fn(),
|
||||
getReactNativeProjectDirectory: () => '/project',
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe('uploadEmbeddedBundleAsync', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(true);
|
||||
jest.mocked(easCli.runEasCliCommand).mockResolvedValue({} as any);
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
|
||||
mockZipEntries.mockResolvedValue({});
|
||||
mockZipExtract.mockResolvedValue(undefined);
|
||||
mockZipClose.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('skips when EAS Update is not configured', async () => {
|
||||
jest.mocked(expoUpdates.isEASUpdateConfigured).mockResolvedValue(false);
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
|
||||
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when no channel is configured and does not look for the archive', async () => {
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
'Skipping embedded bundle upload: no channel configured for this build profile.'
|
||||
);
|
||||
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
|
||||
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws for an unsupported platform', async () => {
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
(ctx.job as { platform: string }).platform = 'web';
|
||||
|
||||
await expect(uploadEmbeddedBundleAsync(ctx)).rejects.toThrow(
|
||||
'Uploading embedded updates is not supported for the web platform.'
|
||||
);
|
||||
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads from Android APK archives', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
|
||||
mockZipEntries.mockResolvedValue(
|
||||
zipEntryMap({
|
||||
'assets/index.android.bundle': true,
|
||||
'assets/app.manifest': true,
|
||||
})
|
||||
);
|
||||
const ctx = makeCtx({
|
||||
platform: Platform.ANDROID,
|
||||
channel: 'production',
|
||||
env: { EAS_BUILD_ID: 'build-123' },
|
||||
});
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(mockZipExtract).toHaveBeenCalledWith(
|
||||
'assets/index.android.bundle',
|
||||
expect.stringContaining('index.android.bundle')
|
||||
);
|
||||
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: expect.arrayContaining([
|
||||
'update:embedded:upload',
|
||||
'--platform',
|
||||
Platform.ANDROID,
|
||||
'--channel',
|
||||
'production',
|
||||
'--build-id',
|
||||
'build-123',
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads from Android AAB archives', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.aab']);
|
||||
mockZipEntries.mockResolvedValue(
|
||||
zipEntryMap({
|
||||
'base/assets/index.android.bundle': true,
|
||||
'base/assets/app.manifest': true,
|
||||
})
|
||||
);
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(mockZipExtract).toHaveBeenCalledWith(
|
||||
'base/assets/index.android.bundle',
|
||||
expect.stringContaining('index.android.bundle')
|
||||
);
|
||||
expect(easCli.runEasCliCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads from iOS IPA archives', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/App.ipa']);
|
||||
mockZipEntries.mockResolvedValue(
|
||||
zipEntryMap({
|
||||
'Payload/App.app/main.jsbundle': true,
|
||||
'Payload/App.app/EXUpdates.bundle/app.manifest': true,
|
||||
})
|
||||
);
|
||||
const ctx = makeCtx({ platform: Platform.IOS, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(easCli.runEasCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: expect.arrayContaining(['--platform', Platform.IOS]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('skips simulator builds', async () => {
|
||||
const ctx = makeCtx({ platform: Platform.IOS, simulator: true, channel: 'preview' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.markBuildPhaseSkipped).toHaveBeenCalled();
|
||||
expect(artifacts.findArtifacts).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when bundle or manifest is missing from the archive', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
|
||||
mockZipEntries.mockResolvedValue(
|
||||
zipEntryMap({
|
||||
'assets/app.manifest': true,
|
||||
})
|
||||
);
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
'Skipping embedded bundle upload: bundle or manifest not found in archive.'
|
||||
);
|
||||
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when build archive is not found', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue([]);
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
'Skipping embedded bundle upload: build archive not found.'
|
||||
);
|
||||
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
|
||||
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats findArtifacts errors as no archive found', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockRejectedValue(new Error('glob failed'));
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
'Skipping embedded bundle upload: build archive not found.'
|
||||
);
|
||||
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
|
||||
expect(easCli.runEasCliCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns and continues when CLI upload throws', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
|
||||
mockZipEntries.mockResolvedValue(
|
||||
zipEntryMap({
|
||||
'assets/index.android.bundle': true,
|
||||
'assets/app.manifest': true,
|
||||
})
|
||||
);
|
||||
jest.mocked(easCli.runEasCliCommand).mockRejectedValue(new Error('upload failed'));
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await uploadEmbeddedBundleAsync(ctx);
|
||||
|
||||
expect(ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to upload embedded bundle.'
|
||||
);
|
||||
expect(ctx.markBuildPhaseHasWarnings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows zip.close() failures so they do not mask the upload result', async () => {
|
||||
jest.mocked(artifacts.findArtifacts).mockResolvedValue(['/tmp/app-release.apk']);
|
||||
mockZipEntries.mockResolvedValue(
|
||||
zipEntryMap({
|
||||
'assets/index.android.bundle': true,
|
||||
'assets/app.manifest': true,
|
||||
})
|
||||
);
|
||||
mockZipClose.mockRejectedValue(new Error('close failed'));
|
||||
const ctx = makeCtx({ platform: Platform.ANDROID, channel: 'production' });
|
||||
|
||||
await expect(uploadEmbeddedBundleAsync(ctx)).resolves.toBeUndefined();
|
||||
expect(easCli.runEasCliCommand).toHaveBeenCalled();
|
||||
expect(mockZipClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Android, BuildJob, Ios, Platform } from '@expo/eas-build-job';
|
||||
import { PipeMode } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import StreamZip from 'node-stream-zip';
|
||||
|
||||
import { findArtifacts } from './artifacts';
|
||||
import { runEasCliCommand } from './easCli';
|
||||
import { resolveArtifactPath } from '../ios/resolve';
|
||||
import { BuildContext } from '../context';
|
||||
import { isEASUpdateConfigured } from './expoUpdates';
|
||||
|
||||
export async function uploadEmbeddedBundleAsync(ctx: BuildContext<BuildJob>): Promise<void> {
|
||||
if (!(await isEASUpdateConfigured(ctx))) {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
return;
|
||||
}
|
||||
|
||||
const { platform } = ctx.job;
|
||||
if (platform === Platform.IOS && (ctx.job as Ios.Job).simulator) {
|
||||
ctx.markBuildPhaseSkipped();
|
||||
return;
|
||||
}
|
||||
|
||||
const channel = ctx.job.updates?.channel;
|
||||
if (!channel) {
|
||||
ctx.logger.warn(
|
||||
'Skipping embedded bundle upload: no channel configured for this build profile.'
|
||||
);
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
return;
|
||||
}
|
||||
|
||||
const projectDir = ctx.getReactNativeProjectDirectory();
|
||||
|
||||
let archivePattern: string;
|
||||
if (platform === Platform.IOS) {
|
||||
archivePattern = resolveArtifactPath(ctx as BuildContext<Ios.Job>);
|
||||
} else if (platform === Platform.ANDROID) {
|
||||
archivePattern =
|
||||
(ctx as BuildContext<Android.Job>).job.applicationArchivePath ??
|
||||
'android/app/build/outputs/**/*.{apk,aab}';
|
||||
} else {
|
||||
throw new Error(`Uploading embedded updates is not supported for the ${platform} platform.`);
|
||||
}
|
||||
|
||||
const [archivePath] = await findArtifacts({
|
||||
rootDir: projectDir,
|
||||
patternOrPath: archivePattern,
|
||||
logger: null,
|
||||
}).catch(() => [] as string[]);
|
||||
|
||||
if (!archivePath) {
|
||||
ctx.logger.warn('Skipping embedded bundle upload: build archive not found.');
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
return;
|
||||
}
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-embedded-bundle-'));
|
||||
const bundleName = platform === Platform.IOS ? 'main.jsbundle' : 'index.android.bundle';
|
||||
const bundlePath = path.join(tmpDir, bundleName);
|
||||
const manifestPath = path.join(tmpDir, 'app.manifest');
|
||||
const zip = new StreamZip.async({ file: archivePath });
|
||||
try {
|
||||
const entries = Object.values(await zip.entries());
|
||||
const bundleEntry = entries.find(e =>
|
||||
platform === Platform.IOS
|
||||
? e.name.endsWith('/main.jsbundle')
|
||||
: e.name.endsWith('assets/index.android.bundle')
|
||||
);
|
||||
const manifestEntry = entries.find(e =>
|
||||
platform === Platform.IOS
|
||||
? e.name.includes('EXUpdates.bundle/app.manifest')
|
||||
: e.name.endsWith('assets/app.manifest')
|
||||
);
|
||||
|
||||
if (!bundleEntry || !manifestEntry) {
|
||||
ctx.logger.warn('Skipping embedded bundle upload: bundle or manifest not found in archive.');
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
return;
|
||||
}
|
||||
|
||||
await zip.extract(bundleEntry.name, bundlePath);
|
||||
await zip.extract(manifestEntry.name, manifestPath);
|
||||
|
||||
const args = [
|
||||
'update:embedded:upload',
|
||||
'--platform',
|
||||
platform,
|
||||
'--bundle',
|
||||
bundlePath,
|
||||
'--manifest',
|
||||
manifestPath,
|
||||
'--channel',
|
||||
channel,
|
||||
'--non-interactive',
|
||||
];
|
||||
if (ctx.env.EAS_BUILD_ID) {
|
||||
args.push('--build-id', ctx.env.EAS_BUILD_ID);
|
||||
}
|
||||
await runEasCliCommand({
|
||||
args,
|
||||
options: {
|
||||
cwd: projectDir,
|
||||
env: ctx.env,
|
||||
logger: ctx.logger,
|
||||
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
ctx.logger.warn({ err }, 'Failed to upload embedded bundle.');
|
||||
ctx.markBuildPhaseHasWarnings();
|
||||
} finally {
|
||||
await asyncResult(zip.close());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "20.0.0",
|
||||
"version": "20.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": "20.0.0"
|
||||
"@expo/steps": "20.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "20.0.0",
|
||||
"version": "20.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -27,6 +27,7 @@ export enum BuildPhase {
|
||||
*/
|
||||
UPLOAD_ARTIFACTS = 'UPLOAD_ARTIFACTS',
|
||||
UPLOAD_APPLICATION_ARCHIVE = 'UPLOAD_APPLICATION_ARCHIVE',
|
||||
UPLOAD_EMBEDDED_BUNDLE = 'UPLOAD_EMBEDDED_BUNDLE',
|
||||
UPLOAD_BUILD_ARTIFACTS = 'UPLOAD_BUILD_ARTIFACTS',
|
||||
PREPARE_ARTIFACTS = 'PREPARE_ARTIFACTS',
|
||||
CLEAN_UP_CREDENTIALS = 'CLEAN_UP_CREDENTIALS',
|
||||
@@ -91,6 +92,7 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
|
||||
[BuildPhase.CACHE_STATS]: 'Cache stats',
|
||||
[BuildPhase.UPLOAD_ARTIFACTS]: 'Upload artifacts',
|
||||
[BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'Upload application archive',
|
||||
[BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'Upload embedded bundle',
|
||||
[BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'Upload build artifacts',
|
||||
[BuildPhase.PREPARE_ARTIFACTS]: 'Prepare artifacts',
|
||||
[BuildPhase.CLEAN_UP_CREDENTIALS]: 'Clean up credentials',
|
||||
@@ -157,6 +159,7 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
|
||||
[BuildPhase.CACHE_STATS]: 'cache-stats',
|
||||
[BuildPhase.UPLOAD_ARTIFACTS]: 'upload-artifacts',
|
||||
[BuildPhase.UPLOAD_APPLICATION_ARCHIVE]: 'upload-application-archive',
|
||||
[BuildPhase.UPLOAD_EMBEDDED_BUNDLE]: 'upload-embedded-bundle',
|
||||
[BuildPhase.UPLOAD_BUILD_ARTIFACTS]: 'upload-build-artifacts',
|
||||
[BuildPhase.PREPARE_ARTIFACTS]: 'prepare-artifacts',
|
||||
[BuildPhase.CLEAN_UP_CREDENTIALS]: 'clean-up-credentials',
|
||||
|
||||
+176
-106
@@ -149,7 +149,10 @@ eas --help COMMAND
|
||||
* [`eas update:configure`](#eas-updateconfigure)
|
||||
* [`eas update:delete GROUPID`](#eas-updatedelete-groupid)
|
||||
* [`eas update:edit [GROUPID]`](#eas-updateedit-groupid)
|
||||
* [`eas update:embedded:delete ID`](#eas-updateembeddeddelete-id)
|
||||
* [`eas update:embedded:list`](#eas-updateembeddedlist)
|
||||
* [`eas update:embedded:upload`](#eas-updateembeddedupload)
|
||||
* [`eas update:embedded:view ID`](#eas-updateembeddedview-id)
|
||||
* [`eas update:insights GROUPID`](#eas-updateinsights-groupid)
|
||||
* [`eas update:list`](#eas-updatelist)
|
||||
* [`eas update:republish`](#eas-updaterepublish)
|
||||
@@ -196,7 +199,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -213,7 +216,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -235,7 +238,7 @@ DESCRIPTION
|
||||
view account usage and billing for the current cycle
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -252,7 +255,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -266,7 +269,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -318,7 +321,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -339,7 +342,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -359,7 +362,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -379,7 +382,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -402,7 +405,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -443,7 +446,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -463,7 +466,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -481,7 +484,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -501,7 +504,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -526,7 +529,7 @@ DESCRIPTION
|
||||
run dev client simulator/emulator build with matching fingerprint or create a new one
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -553,7 +556,7 @@ DESCRIPTION
|
||||
download a simulator/emulator build by build ID or fingerprint hash
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -591,7 +594,7 @@ DESCRIPTION
|
||||
inspect the state of the project at specific build stages, useful for troubleshooting
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -631,7 +634,7 @@ DESCRIPTION
|
||||
list all builds for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -661,7 +664,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -689,7 +692,7 @@ DESCRIPTION
|
||||
run simulator/emulator builds from eas-cli
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -741,7 +744,7 @@ DESCRIPTION
|
||||
get the latest version from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -759,7 +762,7 @@ DESCRIPTION
|
||||
update version of an app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -777,7 +780,7 @@ DESCRIPTION
|
||||
update a version in native code with a value stored on EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -794,7 +797,7 @@ DESCRIPTION
|
||||
view a build for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -815,7 +818,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -836,7 +839,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -858,7 +861,7 @@ DESCRIPTION
|
||||
point a channel at a new branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:insights`
|
||||
|
||||
@@ -882,7 +885,7 @@ DESCRIPTION
|
||||
display adoption, crash, and unique-user insights for a channel + runtime version
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/insights.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/insights.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -902,7 +905,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -924,7 +927,7 @@ DESCRIPTION
|
||||
pause a channel to stop it from sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -946,7 +949,7 @@ DESCRIPTION
|
||||
resume a channel to start sending updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -981,7 +984,7 @@ DESCRIPTION
|
||||
Roll a new branch out on a channel incrementally.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -1004,7 +1007,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -1025,7 +1028,7 @@ DESCRIPTION
|
||||
display project configuration (app.json + eas.json)
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -1042,7 +1045,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1060,7 +1063,7 @@ DESCRIPTION
|
||||
Set up credentials for building your project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1089,7 +1092,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1114,7 +1117,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1135,7 +1138,7 @@ ALIASES
|
||||
$ eas worker:alias:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1156,7 +1159,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1193,7 +1196,7 @@ DESCRIPTION
|
||||
register new Apple Devices to use for internal distribution
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1213,7 +1216,7 @@ DESCRIPTION
|
||||
remove a registered device from your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1234,7 +1237,7 @@ DESCRIPTION
|
||||
list all registered devices for your account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1255,7 +1258,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1269,7 +1272,7 @@ DESCRIPTION
|
||||
view a device for your project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1283,7 +1286,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1315,7 +1318,7 @@ DESCRIPTION
|
||||
create an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1341,7 +1344,7 @@ DESCRIPTION
|
||||
delete an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1363,7 +1366,7 @@ DESCRIPTION
|
||||
execute a command with environment variables from the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1391,7 +1394,7 @@ DESCRIPTION
|
||||
view an environment variable for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1419,7 +1422,7 @@ DESCRIPTION
|
||||
list environment variables for the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1442,7 +1445,7 @@ DESCRIPTION
|
||||
pull environment variables for the selected environment to .env file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1464,7 +1467,7 @@ DESCRIPTION
|
||||
push environment variables from .env file to the selected environment
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1498,7 +1501,7 @@ DESCRIPTION
|
||||
update an environment variable on the current project or account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1542,7 +1545,7 @@ EXAMPLES
|
||||
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1573,7 +1576,7 @@ EXAMPLES
|
||||
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1652,7 +1655,7 @@ DESCRIPTION
|
||||
connect a project to an App Store Connect app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
|
||||
|
||||
## `eas integrations:asc:disconnect`
|
||||
|
||||
@@ -1671,7 +1674,7 @@ DESCRIPTION
|
||||
disconnect the current project from its App Store Connect app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
|
||||
|
||||
## `eas integrations:asc:status`
|
||||
|
||||
@@ -1689,7 +1692,7 @@ DESCRIPTION
|
||||
show the App Store Connect app link status for the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
|
||||
|
||||
## `eas integrations:convex:connect`
|
||||
|
||||
@@ -1711,7 +1714,7 @@ DESCRIPTION
|
||||
connect Convex to your Expo project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
|
||||
|
||||
## `eas integrations:convex:dashboard`
|
||||
|
||||
@@ -1725,7 +1728,7 @@ DESCRIPTION
|
||||
open the Convex dashboard for the linked Convex project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
|
||||
|
||||
## `eas integrations:convex:project`
|
||||
|
||||
@@ -1739,7 +1742,7 @@ DESCRIPTION
|
||||
display the Convex project linked to the current Expo app
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/project.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/project.ts)_
|
||||
|
||||
## `eas integrations:convex:project:delete`
|
||||
|
||||
@@ -1757,7 +1760,7 @@ DESCRIPTION
|
||||
remove the Convex project link for the current Expo app from EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
|
||||
|
||||
## `eas integrations:convex:team`
|
||||
|
||||
@@ -1771,7 +1774,7 @@ DESCRIPTION
|
||||
display Convex teams linked to the current Expo app's owner account
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/team.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/team.ts)_
|
||||
|
||||
## `eas integrations:convex:team:delete [CONVEX_TEAM]`
|
||||
|
||||
@@ -1792,7 +1795,7 @@ DESCRIPTION
|
||||
remove a Convex team link from the current Expo app owner account's EAS servers
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
|
||||
|
||||
## `eas integrations:convex:team:invite [CONVEX_TEAM]`
|
||||
|
||||
@@ -1812,7 +1815,7 @@ DESCRIPTION
|
||||
send a Convex team invitation to your verified email address
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
|
||||
|
||||
## `eas login`
|
||||
|
||||
@@ -1864,7 +1867,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1882,7 +1885,7 @@ DESCRIPTION
|
||||
generate the local store configuration from the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1900,7 +1903,7 @@ DESCRIPTION
|
||||
sync the local store configuration to the app stores
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1959,7 +1962,7 @@ DESCRIPTION
|
||||
arguments, a list of the available event names and associated event counts is returned.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/observe/events.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/events.ts)_
|
||||
|
||||
## `eas observe:metrics [METRIC]`
|
||||
|
||||
@@ -1994,7 +1997,7 @@ DESCRIPTION
|
||||
display individual performance metric samples ordered by value
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/observe/metrics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/metrics.ts)_
|
||||
|
||||
## `eas observe:metrics-summary`
|
||||
|
||||
@@ -2025,7 +2028,7 @@ DESCRIPTION
|
||||
display aggregated performance metric statistics grouped by app version
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
|
||||
|
||||
## `eas observe:routes`
|
||||
|
||||
@@ -2062,7 +2065,7 @@ DESCRIPTION
|
||||
display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route name
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/observe/routes.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/routes.ts)_
|
||||
|
||||
## `eas observe:versions`
|
||||
|
||||
@@ -2087,7 +2090,7 @@ DESCRIPTION
|
||||
display app versions with build and update details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/observe/versions.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/versions.ts)_
|
||||
|
||||
## `eas onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -2117,7 +2120,7 @@ DESCRIPTION
|
||||
open the project page in a web browser
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -2131,7 +2134,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -2154,7 +2157,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -2178,7 +2181,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -2196,7 +2199,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -2229,7 +2232,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -2272,7 +2275,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -2293,7 +2296,7 @@ DESCRIPTION
|
||||
configure the project to support EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -2314,7 +2317,7 @@ DESCRIPTION
|
||||
delete all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -2339,7 +2342,54 @@ DESCRIPTION
|
||||
edit all the updates in an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:embedded:delete ID`
|
||||
|
||||
delete an embedded update registered with EAS Update
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas update:embedded:delete ID [--json] [--non-interactive]
|
||||
|
||||
ARGUMENTS
|
||||
ID The ID of the embedded update (manifest UUID from app.manifest).
|
||||
|
||||
FLAGS
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
|
||||
DESCRIPTION
|
||||
delete an embedded update registered with EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/embedded/delete.ts)_
|
||||
|
||||
## `eas update:embedded:list`
|
||||
|
||||
list embedded updates registered with EAS Update for this project
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas update:embedded:list [-p ios|android] [--runtime-version <value>] [--channel <value>] [--limit <value>]
|
||||
[--after-cursor <value>] [--json] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
-p, --platform=<option> Filter by platform
|
||||
<options: ios|android>
|
||||
--after-cursor=<value> Return items after this cursor (for pagination)
|
||||
--channel=<value> Filter by channel name (pass "all" to skip the channel prompt)
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies
|
||||
--non-interactive.
|
||||
--limit=<value> The number of items to fetch each query. Defaults to 25 and is capped at 50.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--runtime-version=<value> Filter by runtime version
|
||||
|
||||
DESCRIPTION
|
||||
list embedded updates registered with EAS Update for this project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/embedded/list.ts)_
|
||||
|
||||
## `eas update:embedded:upload`
|
||||
|
||||
@@ -2369,7 +2419,27 @@ EXAMPLES
|
||||
$ eas update:embedded:upload --platform android --bundle android/app/src/main/assets/index.android.bundle --manifest android/app/src/main/assets/app.manifest --channel production --build-id <BUILD-ID>
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/embedded/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/embedded/upload.ts)_
|
||||
|
||||
## `eas update:embedded:view ID`
|
||||
|
||||
view details of an embedded update registered with EAS Update
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas update:embedded:view ID [--json]
|
||||
|
||||
ARGUMENTS
|
||||
ID The ID of the embedded update (manifest UUID from app.manifest).
|
||||
|
||||
FLAGS
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
|
||||
DESCRIPTION
|
||||
view details of an embedded update registered with EAS Update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/embedded/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/embedded/view.ts)_
|
||||
|
||||
## `eas update:insights GROUPID`
|
||||
|
||||
@@ -2396,7 +2466,7 @@ DESCRIPTION
|
||||
display launch, crash, unique-user, and size insights for an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/insights.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/insights.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -2423,7 +2493,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -2461,7 +2531,7 @@ DESCRIPTION
|
||||
roll back to an existing update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2489,7 +2559,7 @@ DESCRIPTION
|
||||
revert a rollout update for a project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2519,7 +2589,7 @@ DESCRIPTION
|
||||
roll back to the embedded update
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2540,7 +2610,7 @@ DESCRIPTION
|
||||
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2564,7 +2634,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2585,7 +2655,7 @@ DESCRIPTION
|
||||
upload a local build and generate a sharable link
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2607,7 +2677,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2627,7 +2697,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2646,7 +2716,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2669,7 +2739,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2686,7 +2756,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2807,7 +2877,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2827,7 +2897,7 @@ DESCRIPTION
|
||||
create a new workflow configuration YAML file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2851,7 +2921,7 @@ DESCRIPTION
|
||||
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -2891,7 +2961,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2913,7 +2983,7 @@ DESCRIPTION
|
||||
list recent workflow runs for this project, with their IDs, statuses, and timestamps
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2941,7 +3011,7 @@ FLAG DESCRIPTIONS
|
||||
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2961,7 +3031,7 @@ DESCRIPTION
|
||||
validate a workflow configuration yaml file
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2983,5 +3053,5 @@ DESCRIPTION
|
||||
workflow runs for the current project.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
Generated
+2186
-13
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "20.0.0",
|
||||
"version": "20.1.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -47,8 +47,8 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "20.0.0",
|
||||
"@expo/eas-json": "20.0.0",
|
||||
"@expo/eas-build-job": "20.1.0",
|
||||
"@expo/eas-json": "20.1.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "20.0.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "20.0.0",
|
||||
"@expo/steps": "20.1.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { loadEnvFiles, loadProjectEnv } from '@expo/env';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import SimulatorExec from '../exec';
|
||||
|
||||
jest.mock('@expo/env', () => ({
|
||||
loadEnvFiles: jest.fn(),
|
||||
loadProjectEnv: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/spawn-async');
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({
|
||||
failures: [],
|
||||
successes: [],
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(SimulatorExec, () => {
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectDir = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(spawnAsync).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): {
|
||||
command: SimulatorExec;
|
||||
getContextAsync: jest.SpyInstance;
|
||||
} {
|
||||
const command = new SimulatorExec(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
projectDir,
|
||||
});
|
||||
return { command, getContextAsync };
|
||||
}
|
||||
|
||||
it('loads local env files and spawns the supplied command with inherited stdio', async () => {
|
||||
const { command, getContextAsync } = createCommand(['agent-device', 'touch', '@e2']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(getContextAsync).toHaveBeenCalledWith(SimulatorExec, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
expect(loadProjectEnv).toHaveBeenCalledWith(projectDir, { silent: true });
|
||||
expect(loadEnvFiles).toHaveBeenCalledWith([`${projectDir}/.env.eas-simulator`], {
|
||||
force: true,
|
||||
});
|
||||
expect(spawnAsync).toHaveBeenCalledWith('agent-device', ['touch', '@e2'], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes through command flags as args', async () => {
|
||||
const { command } = createCommand([
|
||||
'agent-device',
|
||||
'screenshot',
|
||||
'/test/path.png',
|
||||
'--format',
|
||||
'png',
|
||||
]);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(spawnAsync).toHaveBeenCalledWith(
|
||||
'agent-device',
|
||||
['screenshot', '/test/path.png', '--format', 'png'],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not emit an oclif unparsed arguments warning', async () => {
|
||||
const { command } = createCommand(['echo', '"test"']);
|
||||
const emitWarningSpy = jest.spyOn(process, 'emitWarning').mockImplementation();
|
||||
jest.spyOn(command, 'run').mockImplementation(async () => {
|
||||
await command.runAsync();
|
||||
});
|
||||
jest.spyOn(command, 'finally').mockResolvedValue(undefined);
|
||||
|
||||
try {
|
||||
// @ts-expect-error _run is internal to oclif, but it is where the unparsed warning is emitted.
|
||||
await command._run();
|
||||
|
||||
const emittedUnparsedCommandWarning = emitWarningSpy.mock.calls.some(([warning, options]) => {
|
||||
return (
|
||||
typeof warning === 'string' &&
|
||||
warning.includes('did not parse its arguments') &&
|
||||
typeof options === 'object' &&
|
||||
options !== null &&
|
||||
'code' in options &&
|
||||
options.code === 'UnparsedCommand'
|
||||
);
|
||||
});
|
||||
expect(emittedUnparsedCommandWarning).toBe(false);
|
||||
} finally {
|
||||
emitWarningSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('throws a helpful error when no command is provided', async () => {
|
||||
const { command, getContextAsync } = createCommand([]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'No command provided. Run `eas simulator:exec <command> [args...]`.'
|
||||
);
|
||||
expect(getContextAsync).not.toHaveBeenCalled();
|
||||
expect(loadProjectEnv).not.toHaveBeenCalled();
|
||||
expect(loadEnvFiles).not.toHaveBeenCalled();
|
||||
expect(spawnAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads simulator-specific env after regular env files', async () => {
|
||||
const { command } = createCommand(['agent-device', 'touch', '@e2']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(loadProjectEnv).toHaveBeenCalledWith(projectDir, { silent: true });
|
||||
expect(loadEnvFiles).toHaveBeenCalledWith([`${projectDir}/.env.eas-simulator`], {
|
||||
force: true,
|
||||
});
|
||||
expect(jest.mocked(loadProjectEnv).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(loadEnvFiles).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,20 @@ import {
|
||||
JobRunStatus,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorGet from '../get';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
const spinner = {
|
||||
@@ -30,6 +39,7 @@ type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId
|
||||
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
|
||||
@@ -71,9 +81,11 @@ function getMockOclifConfig(): Config {
|
||||
describe(SimulatorGet, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectDir = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): {
|
||||
@@ -84,6 +96,7 @@ describe(SimulatorGet, () => {
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir,
|
||||
});
|
||||
return { command, getContextAsync };
|
||||
}
|
||||
@@ -96,6 +109,7 @@ describe(SimulatorGet, () => {
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
|
||||
expect(getContextAsync).toHaveBeenCalledWith(SimulatorGet, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
@@ -108,4 +122,46 @@ describe(SimulatorGet, () => {
|
||||
remoteConfig: session.remoteConfig,
|
||||
});
|
||||
});
|
||||
|
||||
it(`uses ${EAS_SIMULATOR_SESSION_ID} from simulator env when --id is not passed`, async () => {
|
||||
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
const session = makeDeviceRunSession({ id: 'session-from-env' });
|
||||
mockByIdAsync.mockResolvedValue(session);
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-from-env';
|
||||
|
||||
try {
|
||||
const { command } = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-from-env');
|
||||
} finally {
|
||||
if (previousDeviceRunSessionId === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
} else {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('throws a helpful error when no simulator session ID is available', async () => {
|
||||
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
|
||||
try {
|
||||
const { command } = createCommand([]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
|
||||
);
|
||||
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
|
||||
expect(mockByIdAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previousDeviceRunSessionId === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
} else {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import { Config } from '@oclif/core';
|
||||
import * as fs from 'fs-extra';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppPlatform,
|
||||
CreateDeviceRunSessionMutation,
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
JobRunStatus,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionMutation } from '../../../graphql/mutations/DeviceRunSessionMutation';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_HEADER,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import SimulatorStart from '../start';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
debug: jest.fn(),
|
||||
log: jest.fn(),
|
||||
newLine: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
withTick: jest.fn(),
|
||||
},
|
||||
link: jest.fn((url: string) => url),
|
||||
}));
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
resetSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
const spinner = {
|
||||
fail: jest.fn(),
|
||||
start: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
};
|
||||
spinner.start.mockReturnValue(spinner);
|
||||
return spinner;
|
||||
}),
|
||||
}));
|
||||
|
||||
type CreatedDeviceRunSession =
|
||||
CreateDeviceRunSessionMutation['deviceRunSession']['createDeviceRunSession'];
|
||||
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const projectDir = '/test/project';
|
||||
const simulatorDotenvPath = `${projectDir}/.env.eas-simulator`;
|
||||
const jobRunUrl = 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123';
|
||||
|
||||
const mockCreateDeviceRunSessionAsync = jest.mocked(
|
||||
DeviceRunSessionMutation.createDeviceRunSessionAsync
|
||||
);
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockLoadSimulatorEnvAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockResetSimulatorEnvAsync = jest.mocked(resetSimulatorEnvAsync);
|
||||
const mockOra = jest.mocked(ora);
|
||||
|
||||
function makeCreatedDeviceRunSession(
|
||||
overrides: Partial<CreatedDeviceRunSession> = {}
|
||||
): CreatedDeviceRunSession {
|
||||
return {
|
||||
id: 'session-123',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
app: {
|
||||
id: 'app-123',
|
||||
slug: 'testapp',
|
||||
ownerAccount: {
|
||||
id: 'account-123',
|
||||
name: 'testuser',
|
||||
},
|
||||
},
|
||||
turtleJobRun: {
|
||||
id: 'job-123',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
|
||||
return {
|
||||
id: 'session-123',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
app: {
|
||||
id: 'app-123',
|
||||
slug: 'testapp',
|
||||
ownerAccount: {
|
||||
id: 'account-123',
|
||||
name: 'testuser',
|
||||
},
|
||||
},
|
||||
remoteConfig: {
|
||||
__typename: 'AgentDeviceRunSessionRemoteConfig',
|
||||
agentDeviceRemoteSessionUrl: 'https://agent.example.com',
|
||||
agentDeviceRemoteSessionToken: 'token-123',
|
||||
webPreviewUrl: 'https://preview.example.com',
|
||||
},
|
||||
turtleJobRun: {
|
||||
id: 'job-123',
|
||||
status: JobRunStatus.InProgress,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({
|
||||
failures: [],
|
||||
successes: [],
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(SimulatorStart, () => {
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
mockCreateDeviceRunSessionAsync.mockResolvedValue(makeCreatedDeviceRunSession());
|
||||
mockByIdAsync.mockResolvedValue(makeDeviceRunSession());
|
||||
mockLoadSimulatorEnvAsync.mockResolvedValue();
|
||||
mockResetSimulatorEnvAsync.mockResolvedValue();
|
||||
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (previousDeviceRunSessionId === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
} else {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
|
||||
}
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): {
|
||||
command: SimulatorStart;
|
||||
getContextAsync: jest.SpyInstance;
|
||||
} {
|
||||
const command = new SimulatorStart(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir,
|
||||
projectId: 'project-123',
|
||||
});
|
||||
return { command, getContextAsync };
|
||||
}
|
||||
|
||||
it('prints environment variables without saving when outputting env', async () => {
|
||||
const { command, getContextAsync } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--out-config-type',
|
||||
'env',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(getContextAsync).toHaveBeenCalledWith(SimulatorStart, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'project-123',
|
||||
packageVersion: undefined,
|
||||
platform: AppPlatform.Ios,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
});
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(mockOra.mock.results[0]?.value.succeed).toHaveBeenCalledWith(
|
||||
`Device run session created (id: session-123) ${jobRunUrl}`
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("export AGENT_DEVICE_DAEMON_BASE_URL='https://agent.example.com'")
|
||||
);
|
||||
});
|
||||
|
||||
it('writes .env.eas-simulator with the environment variables by default', async () => {
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockLoadSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
|
||||
expect(fs.writeFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER +
|
||||
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
|
||||
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
|
||||
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
|
||||
);
|
||||
expect(jest.mocked(fs.writeFile).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockByIdAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockOra.mock.results[0]?.value.succeed).toHaveBeenCalledWith(
|
||||
`Device run session created (id: session-123, saved to ${SIMULATOR_DOTENV_FILE_NAME}) ${jobRunUrl}`
|
||||
);
|
||||
expect(Log.withTick).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
[
|
||||
'🔑 Run the following to use agent-device with the simulator:',
|
||||
'',
|
||||
'eas simulator:exec agent-device <command>',
|
||||
'',
|
||||
'🌐 Open the following URL in your browser to preview the simulator:',
|
||||
'',
|
||||
'https://preview.example.com',
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('overwrites .env.eas-simulator when outputting dotenv and the file exists', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--out-config-type',
|
||||
'dotenv',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(fs.writeFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER + `${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
|
||||
);
|
||||
expect(fs.writeFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER +
|
||||
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
|
||||
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
|
||||
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
|
||||
);
|
||||
});
|
||||
|
||||
it(`warns and creates a new session when ${EAS_SIMULATOR_SESSION_ID} is already present by default`, async () => {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
' Overwriting previous simulator session (id: existing-session). ' +
|
||||
'The previous remote session will continue running until stopped. ' +
|
||||
'To stop it, run: eas simulator:stop --id existing-session'
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'project-123',
|
||||
packageVersion: undefined,
|
||||
platform: AppPlatform.Ios,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
});
|
||||
});
|
||||
|
||||
it(`creates a new session when ${EAS_SIMULATOR_SESSION_ID} is present with --force`, async () => {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--force']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
' Overwriting previous simulator session (id: existing-session). ' +
|
||||
'The previous remote session will continue running until stopped. ' +
|
||||
'To stop it, run: eas simulator:stop --id existing-session'
|
||||
);
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'project-123',
|
||||
packageVersion: undefined,
|
||||
platform: AppPlatform.Ios,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
});
|
||||
});
|
||||
|
||||
it(`throws when ${EAS_SIMULATOR_SESSION_ID} is already present with --no-force`, async () => {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'existing-session';
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--no-force']);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Existing simulator session in environment. Use --force to create a new device session.'
|
||||
);
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets .env.eas-simulator when the interactive wait observes the session end', async () => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(makeDeviceRunSession())
|
||||
.mockResolvedValueOnce(makeDeviceRunSession({ status: DeviceRunSessionStatus.Stopped }));
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockResetSimulatorEnvAsync).toHaveBeenCalledWith(projectDir);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
DeviceRunSessionStatus,
|
||||
EnsureDeviceRunSessionStoppedMutation,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionMutation } from '../../../graphql/mutations/DeviceRunSessionMutation';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorStop from '../stop';
|
||||
|
||||
jest.mock('../../../graphql/mutations/DeviceRunSessionMutation');
|
||||
jest.mock('../../../simulator/env', () => ({
|
||||
...jest.requireActual('../../../simulator/env'),
|
||||
loadSimulatorEnvAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../ora', () => ({
|
||||
ora: jest.fn(() => {
|
||||
const spinner = {
|
||||
fail: jest.fn(),
|
||||
start: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
};
|
||||
spinner.start.mockReturnValue(spinner);
|
||||
return spinner;
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
type StoppedDeviceRunSession =
|
||||
EnsureDeviceRunSessionStoppedMutation['deviceRunSession']['ensureDeviceRunSessionStopped'];
|
||||
|
||||
const mockEnsureDeviceRunSessionStoppedAsync = jest.mocked(
|
||||
DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync
|
||||
);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockLoadSimulatorEnvironmentVariablesAsync = jest.mocked(loadSimulatorEnvAsync);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeStoppedDeviceRunSession(
|
||||
overrides: Partial<StoppedDeviceRunSession> = {}
|
||||
): StoppedDeviceRunSession {
|
||||
return {
|
||||
id: 'session-123',
|
||||
status: DeviceRunSessionStatus.Stopped,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({
|
||||
failures: [],
|
||||
successes: [],
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(SimulatorStop, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectDir = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue(makeStoppedDeviceRunSession());
|
||||
mockLoadSimulatorEnvironmentVariablesAsync.mockResolvedValue();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): {
|
||||
command: SimulatorStop;
|
||||
getContextAsync: jest.SpyInstance;
|
||||
} {
|
||||
const command = new SimulatorStop(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
projectDir,
|
||||
});
|
||||
return { command, getContextAsync };
|
||||
}
|
||||
|
||||
it('emits JSON when --json is passed', async () => {
|
||||
const { command, getContextAsync } = createCommand(['--id', 'session-123', '--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
|
||||
expect(getContextAsync).toHaveBeenCalledWith(SimulatorStop, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
status: DeviceRunSessionStatus.Stopped,
|
||||
});
|
||||
});
|
||||
|
||||
it(`uses ${EAS_SIMULATOR_SESSION_ID} from simulator env when --id is not passed`, async () => {
|
||||
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = 'session-from-env';
|
||||
mockEnsureDeviceRunSessionStoppedAsync.mockResolvedValue(
|
||||
makeStoppedDeviceRunSession({ id: 'session-from-env' })
|
||||
);
|
||||
|
||||
try {
|
||||
const { command } = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-from-env'
|
||||
);
|
||||
} finally {
|
||||
if (previousDeviceRunSessionId === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
} else {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('throws a helpful error when no simulator session ID is available', async () => {
|
||||
const previousDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
|
||||
try {
|
||||
const { command } = createCommand([]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
|
||||
);
|
||||
expect(mockLoadSimulatorEnvironmentVariablesAsync).toHaveBeenCalledWith(projectDir);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previousDeviceRunSessionId === undefined) {
|
||||
delete process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
} else {
|
||||
process.env[EAS_SIMULATOR_SESSION_ID] = previousDeviceRunSessionId;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { SIMULATOR_DOTENV_FILE_NAME, loadSimulatorEnvAsync } from '../../simulator/env';
|
||||
|
||||
export default class SimulatorExec extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description = `[EXPERIMENTAL] execute a simulator command with ${SIMULATOR_DOTENV_FILE_NAME} environment loaded`;
|
||||
static override strict = false;
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
private isRunningSubprocess = false;
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const rawArgv = [...this.argv];
|
||||
// Required to avoid `Warning: Command exec did not parse its arguments. Did you forget to call 'this.parse'?`
|
||||
await this.parse(SimulatorExec, []);
|
||||
|
||||
const [command, ...args] = rawArgv;
|
||||
if (typeof command !== 'string' || command.length === 0) {
|
||||
throw new Error('No command provided. Run `eas simulator:exec <command> [args...]`.');
|
||||
}
|
||||
|
||||
const { projectDir } = await this.getContextAsync(SimulatorExec, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
|
||||
this.isRunningSubprocess = true;
|
||||
await spawnAsync(command, args, {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
protected override catch(err: Error): Promise<any> {
|
||||
// Propagate wrapped command from spawnAsync rejection
|
||||
if (this.isRunningSubprocess) {
|
||||
process.exitCode = process.exitCode ?? (err as any).status ?? 1;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return super.catch(err);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ import { DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import {
|
||||
deviceRunSessionTypeToFlagValue,
|
||||
formatRemoteSessionInstructions,
|
||||
@@ -23,14 +28,14 @@ export default class SimulatorGet extends EasCommand {
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: 'Device run session ID',
|
||||
required: true,
|
||||
description: `Device run session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
@@ -42,18 +47,27 @@ export default class SimulatorGet extends EasCommand {
|
||||
}
|
||||
|
||||
const {
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorGet, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
const fetchSpinner = ora(`Fetching device run session ${flags.id}`).start();
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const flagId = flags.id || process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (!flagId) {
|
||||
throw new Error(
|
||||
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
|
||||
);
|
||||
}
|
||||
|
||||
const fetchSpinner = ora(`Fetching device run session ${flagId}`).start();
|
||||
let session;
|
||||
try {
|
||||
session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, flags.id);
|
||||
session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, flagId);
|
||||
fetchSpinner.succeed(`Fetched device run session ${session.id}`);
|
||||
} catch (err) {
|
||||
fetchSpinner.fail(`Failed to fetch device run session ${flags.id}`);
|
||||
fetchSpinner.fail(`Failed to fetch device run session ${flagId}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -81,7 +95,7 @@ export default class SimulatorGet extends EasCommand {
|
||||
if (session.status === DeviceRunSessionStatus.InProgress) {
|
||||
Log.newLine();
|
||||
if (session.remoteConfig) {
|
||||
Log.log(formatRemoteSessionInstructions(session.remoteConfig));
|
||||
Log.log(formatRemoteSessionInstructions(session.remoteConfig, 'env'));
|
||||
} else {
|
||||
Log.log(
|
||||
'⏳ Session is starting up — remote config is not available yet. Re-run this command in a moment.'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { getBareJobRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
@@ -17,18 +18,29 @@ import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessi
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
resetSimulatorEnvAsync,
|
||||
writeSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
DeviceRunSessionRemoteConfig,
|
||||
formatRemoteSessionInstructions,
|
||||
getRemoteSessionEnvironmentVariables,
|
||||
} from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000; // 5 seconds
|
||||
const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes
|
||||
const OUT_CONFIG_TYPE_VALUES = {
|
||||
Env: 'env',
|
||||
Dotenv: 'dotenv',
|
||||
} as const;
|
||||
|
||||
export default class SimulatorStart extends EasCommand {
|
||||
static override hidden = true;
|
||||
@@ -50,11 +62,23 @@ export default class SimulatorStart extends EasCommand {
|
||||
description:
|
||||
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
description:
|
||||
'[default: true] Create a new device session even when an existing simulator session is present in the environment.',
|
||||
default: true,
|
||||
allowNo: true,
|
||||
}),
|
||||
'out-config-type': Flags.option({
|
||||
description: `How to output simulator connection configuration. Use "env" to print shell exports, or "dotenv" to write ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
options: Object.values(OUT_CONFIG_TYPE_VALUES),
|
||||
default: OUT_CONFIG_TYPE_VALUES.Dotenv,
|
||||
})(),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
@@ -68,11 +92,28 @@ export default class SimulatorStart extends EasCommand {
|
||||
|
||||
const {
|
||||
projectId,
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorStart, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const existingDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (existingDeviceRunSessionId && !flags.force) {
|
||||
throw new Error(
|
||||
`Existing simulator session in environment. Use --force to create a new device session.`
|
||||
);
|
||||
}
|
||||
if (existingDeviceRunSessionId) {
|
||||
Log.warn(
|
||||
` Overwriting previous simulator session (id: ${existingDeviceRunSessionId}). ` +
|
||||
`The previous remote session will continue running until stopped. ` +
|
||||
`To stop it, run: eas simulator:stop --id ${existingDeviceRunSessionId}`
|
||||
);
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios;
|
||||
|
||||
const createSpinner = ora('🚀 Creating device run session').start();
|
||||
@@ -88,8 +129,16 @@ export default class SimulatorStart extends EasCommand {
|
||||
deviceRunSessionId = session.id;
|
||||
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
|
||||
jobRunUrl = getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, jobRunId);
|
||||
const simulatorEnvWritten =
|
||||
!jsonFlag && flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv
|
||||
? await writeSimulatorEnvSafelyAsync(projectDir, {
|
||||
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
|
||||
})
|
||||
: false;
|
||||
createSpinner.succeed(
|
||||
`Device run session created (id: ${deviceRunSessionId}) ${link(jobRunUrl)}`
|
||||
`Device run session created (id: ${deviceRunSessionId}${
|
||||
simulatorEnvWritten ? `, saved to ${SIMULATOR_DOTENV_FILE_NAME}` : ''
|
||||
}) ${link(jobRunUrl)}`
|
||||
);
|
||||
} catch (err) {
|
||||
createSpinner.fail('Failed to create device run session');
|
||||
@@ -146,6 +195,13 @@ export default class SimulatorStart extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
if (flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv) {
|
||||
await writeSimulatorEnvSafelyAsync(projectDir, {
|
||||
...getRemoteSessionEnvironmentVariables(remoteConfig),
|
||||
[EAS_SIMULATOR_SESSION_ID]: deviceRunSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: deviceRunSessionId,
|
||||
@@ -157,7 +213,7 @@ export default class SimulatorStart extends EasCommand {
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(formatRemoteSessionInstructions(remoteConfig));
|
||||
Log.log(formatRemoteSessionInstructions(remoteConfig, flags['out-config-type']));
|
||||
Log.newLine();
|
||||
|
||||
if (nonInteractive) {
|
||||
@@ -171,18 +227,38 @@ export default class SimulatorStart extends EasCommand {
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
jobRunUrl,
|
||||
projectDir,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSimulatorEnvSafelyAsync(
|
||||
projectDir: string,
|
||||
environmentVariables: Record<string, string>
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await writeSimulatorEnvAsync(projectDir, environmentVariables);
|
||||
return true;
|
||||
} catch (err) {
|
||||
Log.warn(
|
||||
`Failed to write simulator environment variables to ${SIMULATOR_DOTENV_FILE_NAME}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSessionEndOrInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
jobRunUrl,
|
||||
projectDir,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
deviceRunSessionId: string;
|
||||
jobRunUrl: string;
|
||||
projectDir: string;
|
||||
}): Promise<void> {
|
||||
const spinner = ora(
|
||||
`Device run session active — press Ctrl+C to stop, or run \`eas simulator:stop --id ${deviceRunSessionId}\` from another shell`
|
||||
@@ -239,6 +315,7 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
jobRunStatus === JobRunStatus.Finished
|
||||
) {
|
||||
spinner.succeed(`Device run session ended. ${link(jobRunUrl)}`);
|
||||
await resetSimulatorEnvVerboseAsync(projectDir);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -252,6 +329,7 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
);
|
||||
if (stopped) {
|
||||
spinner.succeed('Device run session stopped');
|
||||
await resetSimulatorEnvVerboseAsync(projectDir);
|
||||
} else {
|
||||
spinner.fail(
|
||||
`Could not confirm the device run session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
|
||||
@@ -262,6 +340,15 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function resetSimulatorEnvVerboseAsync(projectDir: string): Promise<void> {
|
||||
try {
|
||||
await resetSimulatorEnvAsync(projectDir);
|
||||
} catch (err) {
|
||||
Log.error(`Failed to clean up ${SIMULATOR_DOTENV_FILE_NAME}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDeviceRunSessionStoppedSafelyAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionId: string
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
} from '../../commandUtils/flags';
|
||||
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_NAME,
|
||||
loadSimulatorEnvAsync,
|
||||
} from '../../simulator/env';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class SimulatorStop extends EasCommand {
|
||||
@@ -16,14 +21,14 @@ export default class SimulatorStop extends EasCommand {
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: 'Device run session ID',
|
||||
required: true,
|
||||
description: `Device run session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
...this.ContextOptions.ProjectDir,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
@@ -35,25 +40,35 @@ export default class SimulatorStop extends EasCommand {
|
||||
}
|
||||
|
||||
const {
|
||||
projectDir,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorStop, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
const stopSpinner = ora(`🛑 Stopping device run session ${flags.id}`).start();
|
||||
await loadSimulatorEnvAsync(projectDir);
|
||||
const flagId = flags.id || process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (!flagId) {
|
||||
throw new Error(
|
||||
`No simulator session ID provided. Pass --id, or run \`eas simulator:start\` first to write ${SIMULATOR_DOTENV_FILE_NAME}.`
|
||||
);
|
||||
}
|
||||
|
||||
const stopSpinner = ora(`🛑 Stopping device run session ${flagId}`).start();
|
||||
let session;
|
||||
try {
|
||||
const session = await DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync(
|
||||
session = await DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync(
|
||||
graphqlClient,
|
||||
flags.id
|
||||
flagId
|
||||
);
|
||||
stopSpinner.succeed(`🎉 Device run session ${session.id} is ${session.status.toLowerCase()}`);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ id: session.id, status: session.status });
|
||||
}
|
||||
} catch (err) {
|
||||
stopSpinner.fail(`Failed to stop device run session ${flags.id}`);
|
||||
stopSpinner.fail(`Failed to stop device run session ${flagId}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ id: session.id, status: session.status });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EmbeddedUpdateMutation } from '../../../../graphql/mutations/EmbeddedUpdateMutation';
|
||||
import Log from '../../../../log';
|
||||
import * as prompts from '../../../../prompts';
|
||||
import * as json from '../../../../utils/json';
|
||||
import UpdateEmbeddedDelete from '../delete';
|
||||
|
||||
jest.mock('../../../../graphql/mutations/EmbeddedUpdateMutation', () => ({
|
||||
EmbeddedUpdateMutation: { deleteEmbeddedUpdateAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../utils/json');
|
||||
jest.mock('../../../../prompts');
|
||||
|
||||
const mockDelete = jest.mocked(EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync);
|
||||
const mockToggleConfirm = jest.mocked(prompts.toggleConfirmAsync);
|
||||
const mockLogLog = jest.mocked(Log.log);
|
||||
const mockLogWithTick = jest.mocked(Log.withTick);
|
||||
const mockLogError = jest.mocked(Log.error);
|
||||
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
|
||||
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
|
||||
|
||||
const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';
|
||||
|
||||
const MOCK_CONTEXT = {
|
||||
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
|
||||
};
|
||||
|
||||
describe(UpdateEmbeddedDelete, () => {
|
||||
const mockConfig = getMockOclifConfig();
|
||||
let processExitSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDelete.mockResolvedValue({ id: VALID_UUID });
|
||||
mockToggleConfirm.mockResolvedValue(true);
|
||||
processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
|
||||
throw new Error('process.exit');
|
||||
}) as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): UpdateEmbeddedDelete {
|
||||
const command = new UpdateEmbeddedDelete(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
|
||||
return command;
|
||||
}
|
||||
|
||||
it('deletes when the user confirms', async () => {
|
||||
await createCommand([VALID_UUID]).run();
|
||||
|
||||
expect(mockToggleConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
|
||||
id: VALID_UUID,
|
||||
});
|
||||
expect(mockLogWithTick).toHaveBeenCalledWith(`Deleted embedded update ${VALID_UUID}`);
|
||||
});
|
||||
|
||||
it('skips confirmation in non-interactive mode', async () => {
|
||||
await createCommand([VALID_UUID, '--non-interactive']).run();
|
||||
|
||||
expect(mockToggleConfirm).not.toHaveBeenCalled();
|
||||
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
|
||||
id: VALID_UUID,
|
||||
});
|
||||
});
|
||||
|
||||
it('aborts (process.exit) without calling the mutation when the user declines', async () => {
|
||||
mockToggleConfirm.mockResolvedValue(false);
|
||||
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('process.exit');
|
||||
|
||||
expect(mockDelete).not.toHaveBeenCalled();
|
||||
expect(mockLogWithTick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('--json prints { id } and skips the human-readable success line', async () => {
|
||||
await createCommand([VALID_UUID, '--non-interactive', '--json']).run();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJson).toHaveBeenCalledWith({ id: VALID_UUID });
|
||||
expect(mockLogWithTick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rethrows unexpected mutation errors', async () => {
|
||||
const boom = new Error('boom');
|
||||
mockDelete.mockRejectedValue(boom);
|
||||
await expect(createCommand([VALID_UUID, '--non-interactive']).run()).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
it('succeeds for an unknown id (server best-effort)', async () => {
|
||||
// Server returns { id } even when nothing was deleted, so we should still tick.
|
||||
await createCommand([VALID_UUID, '--non-interactive']).run();
|
||||
expect(mockLogWithTick).toHaveBeenCalledWith(`Deleted embedded update ${VALID_UUID}`);
|
||||
});
|
||||
|
||||
it('prints an explanatory message before prompting (interactive)', async () => {
|
||||
await createCommand([VALID_UUID]).run();
|
||||
const lines = mockLogLog.mock.calls.map(c => String(c[0])).join('\n');
|
||||
expect(lines).toContain(`permanently delete embedded update: "${VALID_UUID}"`);
|
||||
expect(lines).toContain('Diff patches already generated');
|
||||
expect(lines).toContain('re-upload');
|
||||
});
|
||||
|
||||
it('logs a cancel message when the user declines', async () => {
|
||||
mockToggleConfirm.mockResolvedValue(false);
|
||||
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('process.exit');
|
||||
expect(mockLogError).toHaveBeenCalledWith(
|
||||
`Canceled deletion of embedded update: "${VALID_UUID}".`
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the id through to deleteEmbeddedUpdateAsync', async () => {
|
||||
await createCommand([VALID_UUID, '--non-interactive']).run();
|
||||
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
|
||||
id: VALID_UUID,
|
||||
});
|
||||
});
|
||||
|
||||
it('--json + --non-interactive: skips prompt, skips tick, prints { id }', async () => {
|
||||
await createCommand([VALID_UUID, '--non-interactive', '--json']).run();
|
||||
expect(mockToggleConfirm).not.toHaveBeenCalled();
|
||||
expect(mockLogWithTick).not.toHaveBeenCalled();
|
||||
expect(mockPrintJson).toHaveBeenCalledWith({ id: VALID_UUID });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppPlatform } from '../../../../graphql/generated';
|
||||
import { ChannelQuery } from '../../../../graphql/queries/ChannelQuery';
|
||||
import {
|
||||
EmbeddedUpdateFragment,
|
||||
EmbeddedUpdateQuery,
|
||||
} from '../../../../graphql/queries/EmbeddedUpdateQuery';
|
||||
import Log from '../../../../log';
|
||||
import { selectAsync } from '../../../../prompts';
|
||||
import * as json from '../../../../utils/json';
|
||||
import UpdateEmbeddedList from '../list';
|
||||
|
||||
jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({
|
||||
EmbeddedUpdateQuery: { viewPaginatedAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../../graphql/queries/ChannelQuery', () => ({
|
||||
ChannelQuery: { viewUpdateChannelsOnAppAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../utils/json');
|
||||
|
||||
const mockPaginated = jest.mocked(EmbeddedUpdateQuery.viewPaginatedAsync);
|
||||
const mockViewChannels = jest.mocked(ChannelQuery.viewUpdateChannelsOnAppAsync);
|
||||
const mockSelectAsync = jest.mocked(selectAsync);
|
||||
const mockLogLog = jest.mocked(Log.log);
|
||||
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
|
||||
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
|
||||
|
||||
const MOCK_CONTEXT = {
|
||||
projectId: 'project-123',
|
||||
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
|
||||
};
|
||||
|
||||
const ROW_A: EmbeddedUpdateFragment = {
|
||||
id: 'aaaaaaaa-1111-4000-8000-000000000001',
|
||||
platform: AppPlatform.Ios,
|
||||
runtimeVersion: '1.0.0',
|
||||
channel: 'production',
|
||||
createdAt: '2026-05-29T00:00:00Z',
|
||||
launchAsset: { id: 'asset-a', fileSize: 1024, finalFileSize: 768, fileSHA256: 'abc123' },
|
||||
};
|
||||
const ROW_B: EmbeddedUpdateFragment = {
|
||||
id: 'bbbbbbbb-2222-4000-8000-000000000002',
|
||||
platform: AppPlatform.Android,
|
||||
runtimeVersion: '1.0.1',
|
||||
channel: 'preview',
|
||||
createdAt: '2026-05-30T00:00:00Z',
|
||||
launchAsset: { id: 'asset-b', fileSize: 2048, finalFileSize: null, fileSHA256: 'def456' },
|
||||
};
|
||||
|
||||
function emptyConnection(): {
|
||||
edges: { cursor: string; node: EmbeddedUpdateFragment }[];
|
||||
pageInfo: {
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
startCursor: string | null;
|
||||
endCursor: string | null;
|
||||
};
|
||||
} {
|
||||
return {
|
||||
edges: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: null, endCursor: null },
|
||||
};
|
||||
}
|
||||
|
||||
describe(UpdateEmbeddedList, () => {
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Default: no channels on the project (skips the prompt).
|
||||
mockViewChannels.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): UpdateEmbeddedList {
|
||||
const command = new UpdateEmbeddedList(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
|
||||
return command;
|
||||
}
|
||||
|
||||
it('prints each row when results exist', async () => {
|
||||
mockPaginated.mockResolvedValue({
|
||||
edges: [
|
||||
{ cursor: 'c1', node: ROW_A },
|
||||
{ cursor: 'c2', node: ROW_B },
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c2' },
|
||||
});
|
||||
await createCommand(['--non-interactive']).run();
|
||||
|
||||
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
|
||||
appId: MOCK_CONTEXT.projectId,
|
||||
filter: undefined,
|
||||
first: 25,
|
||||
after: undefined,
|
||||
});
|
||||
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_A.id))).toBe(true);
|
||||
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_B.id))).toBe(true);
|
||||
});
|
||||
|
||||
it('prints empty message when no results', async () => {
|
||||
mockPaginated.mockResolvedValue(emptyConnection());
|
||||
await createCommand(['--non-interactive']).run();
|
||||
expect(mockLogLog).toHaveBeenCalledWith('No embedded updates found.');
|
||||
});
|
||||
|
||||
it('--json prints connection payload and skips formatted output', async () => {
|
||||
mockPaginated.mockResolvedValue({
|
||||
edges: [{ cursor: 'c1', node: ROW_A }],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
|
||||
});
|
||||
await createCommand(['--json', '--non-interactive']).run();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJson).toHaveBeenCalledWith({
|
||||
embeddedUpdates: [ROW_A],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
|
||||
});
|
||||
expect(mockLogLog.mock.calls.every(c => !String(c[0]).includes(ROW_A.id))).toBe(true);
|
||||
});
|
||||
|
||||
it('passes filter when flags supplied', async () => {
|
||||
mockPaginated.mockResolvedValue(emptyConnection());
|
||||
await createCommand([
|
||||
'--non-interactive',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--runtime-version',
|
||||
'1.2.0',
|
||||
'--channel',
|
||||
'preview',
|
||||
]).run();
|
||||
|
||||
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
|
||||
appId: MOCK_CONTEXT.projectId,
|
||||
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.2.0', channel: 'preview' },
|
||||
first: 25,
|
||||
after: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows next-page hint when hasNextPage', async () => {
|
||||
mockPaginated.mockResolvedValue({
|
||||
edges: [{ cursor: 'c1', node: ROW_A }],
|
||||
pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
|
||||
});
|
||||
await createCommand(['--non-interactive']).run();
|
||||
|
||||
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('--after-cursor c1'))).toBe(true);
|
||||
});
|
||||
|
||||
it('prompts for channel in interactive mode and applies the selected channel', async () => {
|
||||
mockViewChannels.mockResolvedValue([
|
||||
{ id: 'ch1', name: 'production' } as any,
|
||||
{ id: 'ch2', name: 'preview' } as any,
|
||||
]);
|
||||
mockSelectAsync.mockResolvedValue('preview');
|
||||
mockPaginated.mockResolvedValue(emptyConnection());
|
||||
|
||||
await createCommand([]).run();
|
||||
|
||||
expect(mockSelectAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
|
||||
appId: MOCK_CONTEXT.projectId,
|
||||
filter: { platform: undefined, runtimeVersion: undefined, channel: 'preview' },
|
||||
first: 25,
|
||||
after: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips channel filter when "All channels" is selected', async () => {
|
||||
mockViewChannels.mockResolvedValue([{ id: 'ch1', name: 'production' } as any]);
|
||||
// The sentinel value returned by selectAsync for the "All channels" option.
|
||||
mockSelectAsync.mockResolvedValue('__embedded_update_list__all_channels__');
|
||||
mockPaginated.mockResolvedValue(emptyConnection());
|
||||
|
||||
await createCommand([]).run();
|
||||
|
||||
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
|
||||
appId: MOCK_CONTEXT.projectId,
|
||||
filter: undefined,
|
||||
first: 25,
|
||||
after: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the channel prompt when --channel is supplied', async () => {
|
||||
mockPaginated.mockResolvedValue(emptyConnection());
|
||||
await createCommand(['--channel', 'preview']).run();
|
||||
expect(mockSelectAsync).not.toHaveBeenCalled();
|
||||
expect(mockViewChannels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats --channel all as no channel filter', async () => {
|
||||
mockPaginated.mockResolvedValue(emptyConnection());
|
||||
await createCommand(['--channel', 'all']).run();
|
||||
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
|
||||
appId: MOCK_CONTEXT.projectId,
|
||||
filter: undefined,
|
||||
first: 25,
|
||||
after: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the channel prompt when the project has no channels', async () => {
|
||||
mockViewChannels.mockResolvedValue([]);
|
||||
mockPaginated.mockResolvedValue(emptyConnection());
|
||||
await createCommand([]).run();
|
||||
expect(mockSelectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes --after-cursor through to viewPaginatedAsync', async () => {
|
||||
mockPaginated.mockResolvedValue(emptyConnection());
|
||||
await createCommand(['--non-interactive', '--after-cursor', 'cursor-123']).run();
|
||||
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
|
||||
appId: MOCK_CONTEXT.projectId,
|
||||
filter: undefined,
|
||||
first: 25,
|
||||
after: 'cursor-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('honors --limit', async () => {
|
||||
mockPaginated.mockResolvedValue(emptyConnection());
|
||||
await createCommand(['--non-interactive', '--limit', '5']).run();
|
||||
expect(mockPaginated).toHaveBeenCalledWith(
|
||||
MOCK_CONTEXT.loggedIn.graphqlClient,
|
||||
expect.objectContaining({ first: 5 })
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the section header with row count', async () => {
|
||||
mockPaginated.mockResolvedValue({
|
||||
edges: [{ cursor: 'c1', node: ROW_A }],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
|
||||
});
|
||||
await createCommand(['--non-interactive']).run();
|
||||
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('Embedded updates (1)'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('suffixes the section header count with "+" when hasNextPage', async () => {
|
||||
mockPaginated.mockResolvedValue({
|
||||
edges: [{ cursor: 'c1', node: ROW_A }],
|
||||
pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
|
||||
});
|
||||
await createCommand(['--non-interactive']).run();
|
||||
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('Embedded updates (1+)'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('shows relative-time "ago" hints in formatted rows', async () => {
|
||||
mockPaginated.mockResolvedValue({
|
||||
edges: [{ cursor: 'c1', node: ROW_A }],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
|
||||
});
|
||||
await createCommand(['--non-interactive']).run();
|
||||
expect(mockLogLog.mock.calls.some(c => /ago/.test(String(c[0])))).toBe(true);
|
||||
});
|
||||
|
||||
it('puts "All channels" first in the prompt, then channels in order', async () => {
|
||||
mockViewChannels.mockResolvedValue([
|
||||
{ id: 'ch1', name: 'production' } as any,
|
||||
{ id: 'ch2', name: 'preview' } as any,
|
||||
]);
|
||||
mockSelectAsync.mockResolvedValue('production');
|
||||
mockPaginated.mockResolvedValue(emptyConnection());
|
||||
|
||||
await createCommand([]).run();
|
||||
|
||||
const [, choices] = mockSelectAsync.mock.calls[0];
|
||||
const titles = (choices as any[]).map(c => c.title);
|
||||
expect(titles).toEqual(['All channels', 'production', 'preview']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppPlatform } from '../../../../graphql/generated';
|
||||
import {
|
||||
EmbeddedUpdateFragment,
|
||||
EmbeddedUpdateQuery,
|
||||
isEmbeddedUpdateNotFoundError,
|
||||
} from '../../../../graphql/queries/EmbeddedUpdateQuery';
|
||||
import Log from '../../../../log';
|
||||
import * as json from '../../../../utils/json';
|
||||
import UpdateEmbeddedView from '../view';
|
||||
|
||||
jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({
|
||||
EmbeddedUpdateQuery: { viewByIdAsync: jest.fn() },
|
||||
isEmbeddedUpdateNotFoundError: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../utils/json');
|
||||
|
||||
const mockView = jest.mocked(EmbeddedUpdateQuery.viewByIdAsync);
|
||||
const mockIsNotFound = jest.mocked(isEmbeddedUpdateNotFoundError);
|
||||
const mockLogLog = jest.mocked(Log.log);
|
||||
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
|
||||
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
|
||||
|
||||
const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';
|
||||
|
||||
const MOCK_CONTEXT = {
|
||||
projectId: 'project-123',
|
||||
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
|
||||
};
|
||||
|
||||
const MOCK_EMBEDDED_UPDATE: EmbeddedUpdateFragment = {
|
||||
id: VALID_UUID,
|
||||
platform: AppPlatform.Ios,
|
||||
runtimeVersion: '1.0.0',
|
||||
channel: 'production',
|
||||
createdAt: '2026-05-29T00:00:00Z',
|
||||
launchAsset: {
|
||||
id: 'asset-1',
|
||||
fileSize: 1024,
|
||||
finalFileSize: 768,
|
||||
fileSHA256: 'abc123',
|
||||
},
|
||||
};
|
||||
|
||||
describe(UpdateEmbeddedView, () => {
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockView.mockResolvedValue(MOCK_EMBEDDED_UPDATE);
|
||||
mockIsNotFound.mockReturnValue(false);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): UpdateEmbeddedView {
|
||||
const command = new UpdateEmbeddedView(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
|
||||
return command;
|
||||
}
|
||||
|
||||
it('prints formatted details on success', async () => {
|
||||
await createCommand([VALID_UUID]).run();
|
||||
|
||||
expect(mockView).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
|
||||
embeddedUpdateId: VALID_UUID,
|
||||
appId: MOCK_CONTEXT.projectId,
|
||||
});
|
||||
// Header line + body line
|
||||
expect(mockLogLog).toHaveBeenCalledTimes(2);
|
||||
expect(mockLogLog.mock.calls[0][0]).toContain('Embedded update');
|
||||
expect(mockLogLog.mock.calls[1][0]).toContain(VALID_UUID);
|
||||
expect(mockLogLog.mock.calls[1][0]).toContain('Bundle size');
|
||||
expect(mockLogLog.mock.calls[1][0]).toContain('Bundle SHA-256');
|
||||
});
|
||||
|
||||
it('--json prints the raw embedded update and skips formatted output', async () => {
|
||||
await createCommand([VALID_UUID, '--json']).run();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJson).toHaveBeenCalledWith(MOCK_EMBEDDED_UPDATE);
|
||||
expect(mockLogLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits with a friendly message when the server returns NOT_FOUND', async () => {
|
||||
const notFound = new CombinedError({ graphQLErrors: [] });
|
||||
mockView.mockRejectedValue(notFound);
|
||||
mockIsNotFound.mockReturnValue(true);
|
||||
|
||||
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow();
|
||||
expect(mockIsNotFound).toHaveBeenCalledWith(notFound);
|
||||
});
|
||||
|
||||
it('rethrows unexpected errors', async () => {
|
||||
const boom = new Error('boom');
|
||||
mockView.mockRejectedValue(boom);
|
||||
mockIsNotFound.mockReturnValue(false);
|
||||
|
||||
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
it('renders bundle size from finalFileSize when present', async () => {
|
||||
mockView.mockResolvedValue({
|
||||
...MOCK_EMBEDDED_UPDATE,
|
||||
launchAsset: { id: 'asset-x', fileSize: 2048, finalFileSize: 768, fileSHA256: 'abc' },
|
||||
});
|
||||
await createCommand([VALID_UUID]).run();
|
||||
// 768 B; not 2.0 KB (which would be fileSize).
|
||||
expect(mockLogLog.mock.calls[1][0]).toContain('768 B');
|
||||
});
|
||||
|
||||
it('falls back to fileSize when finalFileSize is null', async () => {
|
||||
mockView.mockResolvedValue({
|
||||
...MOCK_EMBEDDED_UPDATE,
|
||||
launchAsset: { id: 'asset-y', fileSize: 2048, finalFileSize: null, fileSHA256: 'abc' },
|
||||
});
|
||||
await createCommand([VALID_UUID]).run();
|
||||
expect(mockLogLog.mock.calls[1][0]).toContain('2.0 KB');
|
||||
});
|
||||
|
||||
it('shows runtime version, channel, and SHA-256 in the body', async () => {
|
||||
await createCommand([VALID_UUID]).run();
|
||||
const body = String(mockLogLog.mock.calls[1][0]);
|
||||
expect(body).toContain('1.0.0');
|
||||
expect(body).toContain('production');
|
||||
expect(body).toContain('abc123');
|
||||
});
|
||||
|
||||
it('shows a relative-time hint in the Created at row', async () => {
|
||||
await createCommand([VALID_UUID]).run();
|
||||
expect(mockLogLog.mock.calls[1][0]).toMatch(/\(.+ago\)/);
|
||||
});
|
||||
|
||||
it('passes the project id and uuid through to viewByIdAsync', async () => {
|
||||
await createCommand([VALID_UUID]).run();
|
||||
expect(mockView).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
|
||||
embeddedUpdateId: VALID_UUID,
|
||||
appId: MOCK_CONTEXT.projectId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Args } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import { EmbeddedUpdateMutation } from '../../../graphql/mutations/EmbeddedUpdateMutation';
|
||||
import Log from '../../../log';
|
||||
import { toggleConfirmAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class UpdateEmbeddedDelete extends EasCommand {
|
||||
static override description = 'delete an embedded update registered with EAS Update';
|
||||
|
||||
static override args = {
|
||||
id: Args.string({
|
||||
required: true,
|
||||
description: 'The ID of the embedded update (manifest UUID from app.manifest).',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { id: embeddedUpdateId },
|
||||
flags,
|
||||
} = await this.parse(UpdateEmbeddedDelete);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(UpdateEmbeddedDelete, { nonInteractive });
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (!nonInteractive) {
|
||||
Log.log(
|
||||
`You are about to permanently delete embedded update: "${embeddedUpdateId}". ` +
|
||||
`Diff patches already generated against this bundle keep serving, but new diffs ` +
|
||||
`can't be generated until you re-upload it.`
|
||||
);
|
||||
Log.newLine();
|
||||
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
|
||||
if (!confirmed) {
|
||||
Log.error(`Canceled deletion of embedded update: "${embeddedUpdateId}".`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort delete on the server: deleting an unknown id succeeds (idempotent),
|
||||
// so we don't need a not-found branch here.
|
||||
await EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync(graphqlClient, {
|
||||
id: embeddedUpdateId,
|
||||
});
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ id: embeddedUpdateId });
|
||||
return;
|
||||
}
|
||||
Log.withTick(`Deleted embedded update ${embeddedUpdateId}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../../commandUtils/pagination';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
|
||||
import {
|
||||
EmbeddedUpdateFragment,
|
||||
EmbeddedUpdateQuery,
|
||||
} from '../../../graphql/queries/EmbeddedUpdateQuery';
|
||||
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
|
||||
import Log from '../../../log';
|
||||
import { selectAsync } from '../../../prompts';
|
||||
import { fromNow } from '../../../utils/date';
|
||||
import formatFields from '../../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
const DEFAULT_LIMIT = 25;
|
||||
const MAX_LIMIT = 50;
|
||||
const CHANNELS_LIMIT = 50;
|
||||
|
||||
export default class UpdateEmbeddedList extends EasCommand {
|
||||
static override description = 'list embedded updates registered with EAS Update for this project';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
description: 'Filter by platform',
|
||||
options: [Platform.IOS, Platform.ANDROID] as const,
|
||||
})(),
|
||||
'runtime-version': Flags.string({
|
||||
description: 'Filter by runtime version',
|
||||
}),
|
||||
channel: Flags.string({
|
||||
description: 'Filter by channel name (pass "all" to skip the channel prompt)',
|
||||
}),
|
||||
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
|
||||
'after-cursor': Flags.string({
|
||||
description: 'Return items after this cursor (for pagination)',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(UpdateEmbeddedList);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(UpdateEmbeddedList, { nonInteractive });
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const platform: AppPlatform | undefined = flags.platform
|
||||
? toAppPlatform(flags.platform)
|
||||
: undefined;
|
||||
|
||||
// Resolve channel filter:
|
||||
// - `--channel <name>`: use it
|
||||
// - `--channel all` (or no flag in non-interactive / json): no channel filter
|
||||
// - no flag in interactive: prompt with the project's channels + "All channels"
|
||||
let channel: string | undefined;
|
||||
if (flags.channel) {
|
||||
channel = flags.channel.toLowerCase() === 'all' ? undefined : flags.channel;
|
||||
} else if (!nonInteractive && !jsonFlag) {
|
||||
channel = await promptForChannelAsync(graphqlClient, projectId);
|
||||
}
|
||||
|
||||
const filter =
|
||||
platform || flags['runtime-version'] || channel
|
||||
? {
|
||||
platform,
|
||||
runtimeVersion: flags['runtime-version'],
|
||||
channel,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const limit = flags.limit ?? DEFAULT_LIMIT;
|
||||
const connection = await EmbeddedUpdateQuery.viewPaginatedAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
filter,
|
||||
first: limit,
|
||||
after: flags['after-cursor'],
|
||||
});
|
||||
|
||||
const embeddedUpdates = connection.edges.map(e => e.node);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
embeddedUpdates,
|
||||
pageInfo: connection.pageInfo,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (embeddedUpdates.length === 0) {
|
||||
Log.log('No embedded updates found.');
|
||||
return;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
chalk.bold(
|
||||
`Embedded updates (${embeddedUpdates.length}${connection.pageInfo.hasNextPage ? '+' : ''}):`
|
||||
)
|
||||
);
|
||||
Log.newLine();
|
||||
Log.log(embeddedUpdates.map(formatEmbeddedUpdateRow).join(`\n\n${chalk.dim('———')}\n\n`));
|
||||
|
||||
if (connection.pageInfo.hasNextPage && connection.pageInfo.endCursor) {
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
chalk.dim(
|
||||
`Showing ${embeddedUpdates.length}. For the next page, run with --after-cursor ${connection.pageInfo.endCursor}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sentinel for the "All channels" option. We can't use `undefined` here because
|
||||
// the underlying `prompts` library substitutes a choice's index when its value
|
||||
// is undefined, which then leaks into the GraphQL filter.
|
||||
const ALL_CHANNELS = '__embedded_update_list__all_channels__';
|
||||
|
||||
async function promptForChannelAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string
|
||||
): Promise<string | undefined> {
|
||||
const channels = await ChannelQuery.viewUpdateChannelsOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
offset: 0,
|
||||
limit: CHANNELS_LIMIT,
|
||||
});
|
||||
|
||||
if (channels.length === 0) {
|
||||
// Nothing to choose from — fall back to listing everything.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selected = await selectAsync<string>('Filter embedded updates by which channel?', [
|
||||
{ title: 'All channels', value: ALL_CHANNELS },
|
||||
...channels.map(c => ({ title: c.name, value: c.name })),
|
||||
]);
|
||||
return selected === ALL_CHANNELS ? undefined : selected;
|
||||
}
|
||||
|
||||
function formatEmbeddedUpdateRow(embeddedUpdate: EmbeddedUpdateFragment): string {
|
||||
const createdAt = new Date(embeddedUpdate.createdAt);
|
||||
return formatFields([
|
||||
{ label: 'ID', value: embeddedUpdate.id },
|
||||
{ label: 'Platform', value: embeddedUpdate.platform.toLowerCase() },
|
||||
{ label: 'Runtime version', value: embeddedUpdate.runtimeVersion },
|
||||
{ label: 'Channel', value: embeddedUpdate.channel },
|
||||
{ label: 'Created', value: `${fromNow(createdAt)} ago` },
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Args, Errors } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { EasJsonOnlyFlag } from '../../../commandUtils/flags';
|
||||
import {
|
||||
EmbeddedUpdateFragment,
|
||||
EmbeddedUpdateQuery,
|
||||
isEmbeddedUpdateNotFoundError,
|
||||
} from '../../../graphql/queries/EmbeddedUpdateQuery';
|
||||
import Log from '../../../log';
|
||||
import { fromNow } from '../../../utils/date';
|
||||
import { formatBytes } from '../../../utils/files';
|
||||
import formatFields from '../../../utils/formatFields';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class UpdateEmbeddedView extends EasCommand {
|
||||
static override description = 'view details of an embedded update registered with EAS Update';
|
||||
|
||||
static override args = {
|
||||
id: Args.string({
|
||||
required: true,
|
||||
description: 'The ID of the embedded update (manifest UUID from app.manifest).',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...EasJsonOnlyFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { id: embeddedUpdateId },
|
||||
flags: { json: jsonFlag },
|
||||
} = await this.parse(UpdateEmbeddedView);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(UpdateEmbeddedView, { nonInteractive: true });
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
let embeddedUpdate;
|
||||
try {
|
||||
embeddedUpdate = await EmbeddedUpdateQuery.viewByIdAsync(graphqlClient, {
|
||||
embeddedUpdateId,
|
||||
appId: projectId,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
if (isEmbeddedUpdateNotFoundError(e)) {
|
||||
Errors.error(
|
||||
`No embedded update found with id "${embeddedUpdateId}" for this project. ` +
|
||||
`Run "eas update:embedded:list" to see the embedded updates registered for this app.`,
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(embeddedUpdate);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.bold('Embedded update:'));
|
||||
Log.log(formatEmbeddedUpdate(embeddedUpdate));
|
||||
}
|
||||
}
|
||||
|
||||
export function formatEmbeddedUpdate(embeddedUpdate: EmbeddedUpdateFragment): string {
|
||||
const createdAt = new Date(embeddedUpdate.createdAt);
|
||||
const bundleSize =
|
||||
embeddedUpdate.launchAsset.finalFileSize ?? embeddedUpdate.launchAsset.fileSize;
|
||||
return formatFields([
|
||||
{ label: 'ID', value: embeddedUpdate.id },
|
||||
{ label: 'Platform', value: embeddedUpdate.platform.toLowerCase() },
|
||||
{ label: 'Runtime version', value: embeddedUpdate.runtimeVersion },
|
||||
{ label: 'Channel', value: embeddedUpdate.channel },
|
||||
{ label: 'Bundle size', value: formatBytes(bundleSize) },
|
||||
{ label: 'Bundle SHA-256', value: embeddedUpdate.launchAsset.fileSHA256 },
|
||||
{
|
||||
label: 'Created at',
|
||||
value: `${createdAt.toLocaleString()} (${fromNow(createdAt)} ago)`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
+354
-4
@@ -1459,6 +1459,7 @@ export type App = Project & {
|
||||
description: Scalars['String']['output'];
|
||||
devDomainName?: Maybe<AppDevDomainName>;
|
||||
deviceRunSessionsPaginated: AppDeviceRunSessionsConnection;
|
||||
embeddedUpdatesPaginated: AppEmbeddedUpdatesConnection;
|
||||
/** Environment secrets for an app */
|
||||
environmentSecrets: Array<EnvironmentSecret>;
|
||||
environmentVariableEnvironments: Array<Scalars['EnvironmentVariableEnvironment']['output']>;
|
||||
@@ -1607,6 +1608,8 @@ export type App = Project & {
|
||||
workerDeploymentsCrashes?: Maybe<WorkerDeploymentCrashes>;
|
||||
workerDeploymentsRequest: WorkerDeploymentRequestEdge;
|
||||
workerDeploymentsRequests?: Maybe<WorkerDeploymentRequests>;
|
||||
workflowDeviceTestCaseHistory: WorkflowDeviceTestCaseHistory;
|
||||
workflowDeviceTestCaseInsights: WorkflowDeviceTestCaseInsights;
|
||||
workflowRunGitBranchesPaginated: AppWorkflowRunGitBranchesConnection;
|
||||
workflowRunsPaginated: AppWorkflowRunsConnection;
|
||||
workflows: Array<Workflow>;
|
||||
@@ -1697,6 +1700,16 @@ export type AppDeviceRunSessionsPaginatedArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppEmbeddedUpdatesPaginatedArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<EmbeddedUpdateFilterInput>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppEnvironmentSecretsArgs = {
|
||||
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
@@ -1921,6 +1934,21 @@ export type AppWorkerDeploymentsRequestsArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppWorkflowDeviceTestCaseHistoryArgs = {
|
||||
filters?: InputMaybe<WorkflowDeviceTestCaseHistoryFiltersInput>;
|
||||
path: Scalars['String']['input'];
|
||||
timespan: WorkflowDeviceTestCaseInsightsTimespanInput;
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppWorkflowDeviceTestCaseInsightsArgs = {
|
||||
filters?: InputMaybe<WorkflowDeviceTestCaseInsightsFiltersInput>;
|
||||
timespan: WorkflowDeviceTestCaseInsightsTimespanInput;
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppWorkflowRunGitBranchesPaginatedArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -2027,6 +2055,18 @@ export type AppDeviceRunSessionsConnection = {
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type AppEmbeddedUpdateEdge = {
|
||||
__typename?: 'AppEmbeddedUpdateEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: EmbeddedUpdate;
|
||||
};
|
||||
|
||||
export type AppEmbeddedUpdatesConnection = {
|
||||
__typename?: 'AppEmbeddedUpdatesConnection';
|
||||
edges: Array<AppEmbeddedUpdateEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type AppFingerprintEdge = {
|
||||
__typename?: 'AppFingerprintEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
@@ -2366,6 +2406,7 @@ export type AppObserveCustomEvent = {
|
||||
eventName: Scalars['String']['output'];
|
||||
expoSdkVersion?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
ingestedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
properties: Array<AppObserveEventProperty>;
|
||||
reactNativeVersion?: Maybe<Scalars['String']['output']>;
|
||||
sessionId?: Maybe<Scalars['String']['output']>;
|
||||
@@ -2463,6 +2504,7 @@ export type AppObserveCustomEventPropertyFilter = {
|
||||
export type AppObserveEvent = {
|
||||
__typename?: 'AppObserveEvent';
|
||||
appBuildNumber: Scalars['String']['output'];
|
||||
appEasBuildId?: Maybe<Scalars['String']['output']>;
|
||||
appIdentifier: Scalars['String']['output'];
|
||||
appName: Scalars['String']['output'];
|
||||
appUpdateId?: Maybe<Scalars['String']['output']>;
|
||||
@@ -5063,6 +5105,11 @@ export type DeleteDiscordUserResult = {
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type DeleteEmbeddedUpdateResult = {
|
||||
__typename?: 'DeleteEmbeddedUpdateResult';
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type DeleteEnvironmentSecretResult = {
|
||||
__typename?: 'DeleteEnvironmentSecretResult';
|
||||
id: Scalars['ID']['output'];
|
||||
@@ -6176,8 +6223,20 @@ export type EmbeddedUpdateAssetUploadSpec = {
|
||||
storageKey: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type EmbeddedUpdateFilterInput = {
|
||||
channel?: InputMaybe<Scalars['String']['input']>;
|
||||
platform?: InputMaybe<AppPlatform>;
|
||||
runtimeVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type EmbeddedUpdateMutation = {
|
||||
__typename?: 'EmbeddedUpdateMutation';
|
||||
/**
|
||||
* Delete an embedded update by id. Best-effort: deleting an unknown id succeeds
|
||||
* (mirrors background deletion jobs). The linked asset row and underlying GCS
|
||||
* object are cleaned up via the entity's afterDelete trigger chain.
|
||||
*/
|
||||
deleteEmbeddedUpdate: DeleteEmbeddedUpdateResult;
|
||||
/**
|
||||
* Register an embedded bundle as the launch asset for a given app/platform/channel.
|
||||
* Returns EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE if the asset has not been finalized yet,
|
||||
@@ -6187,10 +6246,31 @@ export type EmbeddedUpdateMutation = {
|
||||
};
|
||||
|
||||
|
||||
export type EmbeddedUpdateMutationDeleteEmbeddedUpdateArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type EmbeddedUpdateMutationUploadEmbeddedUpdateArgs = {
|
||||
input: UploadEmbeddedUpdateInput;
|
||||
};
|
||||
|
||||
export type EmbeddedUpdateQuery = {
|
||||
__typename?: 'EmbeddedUpdateQuery';
|
||||
/**
|
||||
* Look up an embedded update by its id and the owning app's id.
|
||||
* Throws EMBEDDED_UPDATE_NOT_FOUND when no embedded update with this id exists on
|
||||
* the given app within the caller's account.
|
||||
*/
|
||||
byId: EmbeddedUpdate;
|
||||
};
|
||||
|
||||
|
||||
export type EmbeddedUpdateQueryByIdArgs = {
|
||||
appId: Scalars['ID']['input'];
|
||||
embeddedUpdateId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum EntityTypeName {
|
||||
AccountEntity = 'AccountEntity',
|
||||
AccountSsoConfigurationEntity = 'AccountSSOConfigurationEntity',
|
||||
@@ -7476,7 +7556,7 @@ export type JobRun = {
|
||||
isWaived: Scalars['Boolean']['output'];
|
||||
logFileUrls: Array<Scalars['String']['output']>;
|
||||
/** Max run time in seconds for this job run. */
|
||||
maxRunTimeSeconds?: Maybe<Scalars['Int']['output']>;
|
||||
maxRunTimeSeconds: Scalars['Int']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
priority: JobRunPriority;
|
||||
/** String describing the worker profile used to run this job run. */
|
||||
@@ -8520,6 +8600,7 @@ export type RootQuery = {
|
||||
echoProject: EchoProjectQuery;
|
||||
/** Top-level query object for querying Echo versions. */
|
||||
echoVersion: EchoVersionQuery;
|
||||
embeddedUpdates: EmbeddedUpdateQuery;
|
||||
/** Top-level query object for querying Experimentation configuration. */
|
||||
experimentation: ExperimentationQuery;
|
||||
expoGoBuild: ExpoGoBuildQuery;
|
||||
@@ -8776,7 +8857,7 @@ export type SsoUser = Actor & UserActor & {
|
||||
preferences: UserPreferences;
|
||||
/** Associated accounts */
|
||||
primaryAccount: Account;
|
||||
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
|
||||
primaryAccountProfileImageUrl: Scalars['String']['output'];
|
||||
/** @deprecated Use primaryAccountProfileImageUrl instead */
|
||||
profilePhoto: Scalars['String']['output'];
|
||||
/** Snacks associated with this account */
|
||||
@@ -10062,7 +10143,7 @@ export type User = Actor & UserActor & {
|
||||
preferences: UserPreferences;
|
||||
/** Associated accounts */
|
||||
primaryAccount: Account;
|
||||
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
|
||||
primaryAccountProfileImageUrl: Scalars['String']['output'];
|
||||
/** @deprecated Use primaryAccountProfileImageUrl instead */
|
||||
profilePhoto: Scalars['String']['output'];
|
||||
/** Get all certified second factor authentication methods */
|
||||
@@ -10166,7 +10247,7 @@ export type UserActor = {
|
||||
preferences: UserPreferences;
|
||||
/** Associated accounts */
|
||||
primaryAccount: Account;
|
||||
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
|
||||
primaryAccountProfileImageUrl: Scalars['String']['output'];
|
||||
/** @deprecated Use primaryAccountProfileImageUrl instead */
|
||||
profilePhoto: Scalars['String']['output'];
|
||||
/** Snacks associated with this user's personal account */
|
||||
@@ -10221,6 +10302,8 @@ export type UserActorPublicData = {
|
||||
firstName?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
lastName?: Maybe<Scalars['String']['output']>;
|
||||
primaryAccountProfileImageUrl: Scalars['String']['output'];
|
||||
/** @deprecated Use primaryAccountProfileImageUrl instead */
|
||||
profilePhoto: Scalars['String']['output'];
|
||||
/** Snacks associated with this user's personal account */
|
||||
snacks: Array<Snack>;
|
||||
@@ -11367,6 +11450,187 @@ export enum WorkflowArtifactStorageType {
|
||||
R2 = 'R2'
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouping key from the [shard N] prefix-stripped error_message. `count` is
|
||||
* computed with uniqExact(test_case_result_id) so RMT pre-merge duplicates do
|
||||
* not inflate it. Scope: scans ALL status='failed' rows (NOT just
|
||||
* is_final_attempt=1) so failures from runs that ultimately passed on retry
|
||||
* still surface.
|
||||
*/
|
||||
export type WorkflowDeviceTestCaseErrorPattern = {
|
||||
__typename?: 'WorkflowDeviceTestCaseErrorPattern';
|
||||
count: Scalars['Int']['output'];
|
||||
lastSeenAt: Scalars['DateTime']['output'];
|
||||
patternKey: Scalars['String']['output'];
|
||||
sampleMessage: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type WorkflowDeviceTestCaseHistory = {
|
||||
__typename?: 'WorkflowDeviceTestCaseHistory';
|
||||
errorPatterns: Array<WorkflowDeviceTestCaseErrorPattern>;
|
||||
recentRuns: WorkflowDeviceTestCaseRecentRunConnection;
|
||||
timeSeries: Array<WorkflowDeviceTestCaseInsightsBucket>;
|
||||
totals: WorkflowDeviceTestCaseInsightsTotals;
|
||||
};
|
||||
|
||||
|
||||
export type WorkflowDeviceTestCaseHistoryErrorPatternsArgs = {
|
||||
first: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type WorkflowDeviceTestCaseHistoryRecentRunsArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
first: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type WorkflowDeviceTestCaseHistoryTimeSeriesArgs = {
|
||||
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
|
||||
};
|
||||
|
||||
export type WorkflowDeviceTestCaseHistoryFiltersInput = {
|
||||
gitRefs?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
workflowIds?: InputMaybe<Array<Scalars['ID']['input']>>;
|
||||
};
|
||||
|
||||
export type WorkflowDeviceTestCaseInsights = {
|
||||
__typename?: 'WorkflowDeviceTestCaseInsights';
|
||||
facets: WorkflowDeviceTestCaseInsightsFacets;
|
||||
tests: WorkflowDeviceTestCaseStatConnection;
|
||||
timeSeries: Array<WorkflowDeviceTestCaseInsightsBucket>;
|
||||
totals: WorkflowDeviceTestCaseInsightsTotals;
|
||||
};
|
||||
|
||||
|
||||
export type WorkflowDeviceTestCaseInsightsTestsArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
first: Scalars['Int']['input'];
|
||||
search?: InputMaybe<Scalars['String']['input']>;
|
||||
sortDirection?: InputMaybe<WorkflowDeviceTestCaseSortDirection>;
|
||||
sortField?: InputMaybe<WorkflowDeviceTestCaseStatSortField>;
|
||||
};
|
||||
|
||||
|
||||
export type WorkflowDeviceTestCaseInsightsTimeSeriesArgs = {
|
||||
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutually exclusive bucket counts (passedClean + flaky + failed = totalRuns for the bucket).
|
||||
* Buckets are aligned to the UTC start of the requested granularity interval
|
||||
* (minute / hour / day).
|
||||
*/
|
||||
export type WorkflowDeviceTestCaseInsightsBucket = {
|
||||
__typename?: 'WorkflowDeviceTestCaseInsightsBucket';
|
||||
bucketStartAt: Scalars['DateTime']['output'];
|
||||
failed: Scalars['Int']['output'];
|
||||
flaky: Scalars['Int']['output'];
|
||||
passedClean: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type WorkflowDeviceTestCaseInsightsFacets = {
|
||||
__typename?: 'WorkflowDeviceTestCaseInsightsFacets';
|
||||
gitRefs: Array<Scalars['String']['output']>;
|
||||
tags: Array<Scalars['String']['output']>;
|
||||
workflows: Array<WorkflowDeviceTestCaseWorkflowFacet>;
|
||||
};
|
||||
|
||||
export type WorkflowDeviceTestCaseInsightsFiltersInput = {
|
||||
gitRefs?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
statuses?: InputMaybe<Array<WorkflowDeviceTestCaseStatusFilter>>;
|
||||
tags?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
workflowIds?: InputMaybe<Array<Scalars['ID']['input']>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* A count metric returned for the current window AND the equivalent prior window
|
||||
* (start − duration → start). The frontend uses (current, previous) to compute
|
||||
* trend deltas. Trend ratios are NOT pre-computed server-side — see
|
||||
* WorkflowsInsightsMetric for the equivalent convention.
|
||||
*
|
||||
* Float (not Int) to match WorkflowsInsightsMetric and avoid the GraphQL Int32
|
||||
* ceiling: 90-day uniqExact counts on a high-volume project can plausibly
|
||||
* exceed 2.1B. Values are integer-valued; the frontend reads them as JS numbers.
|
||||
*/
|
||||
export type WorkflowDeviceTestCaseInsightsMetric = {
|
||||
__typename?: 'WorkflowDeviceTestCaseInsightsMetric';
|
||||
currentValue: Scalars['Float']['output'];
|
||||
previousValue: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Same shape as WorkflowDeviceTestCaseInsightsMetric but nullable on both sides —
|
||||
* used for metrics like avgDurationMs / p90DurationMs where "no data in the
|
||||
* window" is meaningful and must not collapse to 0.
|
||||
*/
|
||||
export type WorkflowDeviceTestCaseInsightsNullableMetric = {
|
||||
__typename?: 'WorkflowDeviceTestCaseInsightsNullableMetric';
|
||||
currentValue?: Maybe<Scalars['Float']['output']>;
|
||||
previousValue?: Maybe<Scalars['Float']['output']>;
|
||||
};
|
||||
|
||||
export enum WorkflowDeviceTestCaseInsightsTimeSeriesGranularity {
|
||||
Day = 'DAY',
|
||||
Hour = 'HOUR',
|
||||
Minute = 'MINUTE'
|
||||
}
|
||||
|
||||
export type WorkflowDeviceTestCaseInsightsTimespanInput = {
|
||||
end: Scalars['DateTime']['input'];
|
||||
start: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
/**
|
||||
* Raw counts only (paired with previous-window values). Pass rate, flake rate,
|
||||
* and trends are computed in the frontend from the (currentValue, previousValue)
|
||||
* pair on each metric.
|
||||
*
|
||||
* totalRuns = passedCleanCount + flakyCount + failedCount (over is_final_attempt=1 rows).
|
||||
* distinctFlakyTestCount cannot be derived in the frontend from per-test rows
|
||||
* because pagination means the frontend doesn't see all test paths.
|
||||
*/
|
||||
export type WorkflowDeviceTestCaseInsightsTotals = {
|
||||
__typename?: 'WorkflowDeviceTestCaseInsightsTotals';
|
||||
avgDurationMs: WorkflowDeviceTestCaseInsightsNullableMetric;
|
||||
distinctFlakyTestCount: WorkflowDeviceTestCaseInsightsMetric;
|
||||
failedCount: WorkflowDeviceTestCaseInsightsMetric;
|
||||
flakyCount: WorkflowDeviceTestCaseInsightsMetric;
|
||||
p90DurationMs: WorkflowDeviceTestCaseInsightsNullableMetric;
|
||||
passedCleanCount: WorkflowDeviceTestCaseInsightsMetric;
|
||||
totalRuns: WorkflowDeviceTestCaseInsightsMetric;
|
||||
};
|
||||
|
||||
/**
|
||||
* One row per execution (is_final_attempt=1). The is_flaky boolean drives the
|
||||
* FLAKY pill — no per-attempt timeline is exposed in v1.
|
||||
*/
|
||||
export type WorkflowDeviceTestCaseRecentRun = {
|
||||
__typename?: 'WorkflowDeviceTestCaseRecentRun';
|
||||
commitSha?: Maybe<Scalars['String']['output']>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
durationMs?: Maybe<Scalars['Int']['output']>;
|
||||
gitRef?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
isFlaky: Scalars['Boolean']['output'];
|
||||
status: WorkflowDeviceTestCaseStatus;
|
||||
workflowRunId: Scalars['ID']['output'];
|
||||
workflowRunName: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type WorkflowDeviceTestCaseRecentRunConnection = {
|
||||
__typename?: 'WorkflowDeviceTestCaseRecentRunConnection';
|
||||
edges: Array<WorkflowDeviceTestCaseRecentRunEdge>;
|
||||
pageInfo: PageInfo;
|
||||
totalCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type WorkflowDeviceTestCaseRecentRunEdge = {
|
||||
__typename?: 'WorkflowDeviceTestCaseRecentRunEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: WorkflowDeviceTestCaseRecentRun;
|
||||
};
|
||||
|
||||
/** A device test case result from a Maestro test execution. */
|
||||
export type WorkflowDeviceTestCaseResult = {
|
||||
__typename?: 'WorkflowDeviceTestCaseResult';
|
||||
@@ -11420,12 +11684,73 @@ export type WorkflowDeviceTestCaseResultMutationCreateWorkflowDeviceTestCaseResu
|
||||
input: CreateWorkflowDeviceTestCaseResultsInput;
|
||||
};
|
||||
|
||||
export enum WorkflowDeviceTestCaseSortDirection {
|
||||
Asc = 'ASC',
|
||||
Desc = 'DESC'
|
||||
}
|
||||
|
||||
export type WorkflowDeviceTestCaseStat = {
|
||||
__typename?: 'WorkflowDeviceTestCaseStat';
|
||||
avgDurationMs?: Maybe<Scalars['Int']['output']>;
|
||||
failedCount: Scalars['Int']['output'];
|
||||
flakyCount: Scalars['Int']['output'];
|
||||
lastRunAt: Scalars['DateTime']['output'];
|
||||
lastRunIsFlaky: Scalars['Boolean']['output'];
|
||||
lastRunStatus: WorkflowDeviceTestCaseStatus;
|
||||
name: Scalars['String']['output'];
|
||||
p90DurationMs?: Maybe<Scalars['Int']['output']>;
|
||||
passedCleanCount: Scalars['Int']['output'];
|
||||
path: Scalars['String']['output'];
|
||||
totalRuns: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type WorkflowDeviceTestCaseStatConnection = {
|
||||
__typename?: 'WorkflowDeviceTestCaseStatConnection';
|
||||
edges: Array<WorkflowDeviceTestCaseStatEdge>;
|
||||
pageInfo: PageInfo;
|
||||
totalCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type WorkflowDeviceTestCaseStatEdge = {
|
||||
__typename?: 'WorkflowDeviceTestCaseStatEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: WorkflowDeviceTestCaseStat;
|
||||
};
|
||||
|
||||
export enum WorkflowDeviceTestCaseStatSortField {
|
||||
Fails = 'FAILS',
|
||||
Flakes = 'FLAKES',
|
||||
FlakeRate = 'FLAKE_RATE',
|
||||
LastRun = 'LAST_RUN',
|
||||
P90Duration = 'P90_DURATION',
|
||||
PassRate = 'PASS_RATE',
|
||||
Runs = 'RUNS'
|
||||
}
|
||||
|
||||
/** Status of a device test case execution. */
|
||||
export enum WorkflowDeviceTestCaseStatus {
|
||||
Failed = 'FAILED',
|
||||
Passed = 'PASSED'
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutually exclusive — matches the chart bucket semantics (passedClean / flaky / failed).
|
||||
* PASSED_CLEAN = is_final_attempt=1 AND status=passed AND is_flaky=0
|
||||
* FLAKY = is_final_attempt=1 AND is_flaky=1 (still a pass, just with retries)
|
||||
* FAILED = is_final_attempt=1 AND status=failed
|
||||
*/
|
||||
export enum WorkflowDeviceTestCaseStatusFilter {
|
||||
Failed = 'FAILED',
|
||||
Flaky = 'FLAKY',
|
||||
PassedClean = 'PASSED_CLEAN'
|
||||
}
|
||||
|
||||
export type WorkflowDeviceTestCaseWorkflowFacet = {
|
||||
__typename?: 'WorkflowDeviceTestCaseWorkflowFacet';
|
||||
id: Scalars['ID']['output'];
|
||||
name: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type WorkflowJob = {
|
||||
__typename?: 'WorkflowJob';
|
||||
allDeviceTestCaseResults: Array<WorkflowDeviceTestCaseResult>;
|
||||
@@ -12530,6 +12855,13 @@ export type UploadEmbeddedUpdateMutationVariables = Exact<{
|
||||
|
||||
export type UploadEmbeddedUpdateMutation = { __typename?: 'RootMutation', embeddedUpdate: { __typename?: 'EmbeddedUpdateMutation', uploadEmbeddedUpdate: { __typename?: 'EmbeddedUpdate', id: string, platform: AppPlatform, runtimeVersion: string, channel: string, createdAt: any } } };
|
||||
|
||||
export type DeleteEmbeddedUpdateMutationVariables = Exact<{
|
||||
id: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type DeleteEmbeddedUpdateMutation = { __typename?: 'RootMutation', embeddedUpdate: { __typename?: 'EmbeddedUpdateMutation', deleteEmbeddedUpdate: { __typename?: 'DeleteEmbeddedUpdateResult', id: string } } };
|
||||
|
||||
export type CreateEnvironmentSecretForAccountMutationVariables = Exact<{
|
||||
input: CreateEnvironmentSecretInput;
|
||||
accountId: Scalars['String']['input'];
|
||||
@@ -13029,6 +13361,24 @@ export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
|
||||
|
||||
export type DeviceRunSessionsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, deviceRunSessionsPaginated: { __typename?: 'AppDeviceRunSessionsConnection', edges: Array<{ __typename?: 'AppDeviceRunSessionEdge', cursor: string, node: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
|
||||
|
||||
export type ViewEmbeddedUpdateByIdQueryVariables = Exact<{
|
||||
embeddedUpdateId: Scalars['ID']['input'];
|
||||
appId: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type ViewEmbeddedUpdateByIdQuery = { __typename?: 'RootQuery', embeddedUpdates: { __typename?: 'EmbeddedUpdateQuery', byId: { __typename?: 'EmbeddedUpdate', id: string, platform: AppPlatform, runtimeVersion: string, channel: string, createdAt: any, launchAsset: { __typename?: 'EmbeddedUpdateAsset', id: string, fileSize: number, finalFileSize?: number | null, fileSHA256: string } } } };
|
||||
|
||||
export type ViewEmbeddedUpdatesPaginatedQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
first: Scalars['Int']['input'];
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<EmbeddedUpdateFilterInput>;
|
||||
}>;
|
||||
|
||||
|
||||
export type ViewEmbeddedUpdatesPaginatedQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, embeddedUpdatesPaginated: { __typename?: 'AppEmbeddedUpdatesConnection', edges: Array<{ __typename?: 'AppEmbeddedUpdateEdge', cursor: string, node: { __typename?: 'EmbeddedUpdate', id: string, platform: AppPlatform, runtimeVersion: string, channel: string, createdAt: any, launchAsset: { __typename?: 'EmbeddedUpdateAsset', id: string, fileSize: number, finalFileSize?: number | null, fileSHA256: string } } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
|
||||
|
||||
export type EnvironmentSecretsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
@@ -4,6 +4,8 @@ import gql from 'graphql-tag';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeleteEmbeddedUpdateMutation,
|
||||
DeleteEmbeddedUpdateMutationVariables,
|
||||
UploadEmbeddedUpdateInput,
|
||||
UploadEmbeddedUpdateMutation,
|
||||
UploadEmbeddedUpdateMutationVariables,
|
||||
@@ -61,4 +63,27 @@ export const EmbeddedUpdateMutation = {
|
||||
);
|
||||
return data.embeddedUpdate.uploadEmbeddedUpdate;
|
||||
},
|
||||
|
||||
async deleteEmbeddedUpdateAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ id }: { id: string }
|
||||
): Promise<{ id: string }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<DeleteEmbeddedUpdateMutation, DeleteEmbeddedUpdateMutationVariables>(
|
||||
gql`
|
||||
mutation DeleteEmbeddedUpdate($id: ID!) {
|
||||
embeddedUpdate {
|
||||
deleteEmbeddedUpdate(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.embeddedUpdate.deleteEmbeddedUpdate;
|
||||
},
|
||||
};
|
||||
@@ -97,3 +97,27 @@ describe('EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync', () => {
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync', () => {
|
||||
it('returns the deleted id from the GraphQL response', async () => {
|
||||
const client = makeGraphqlClient({
|
||||
embeddedUpdate: { deleteEmbeddedUpdate: { id: 'update-1' } },
|
||||
});
|
||||
|
||||
const result = await EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync(client, {
|
||||
id: 'update-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: 'update-1' });
|
||||
});
|
||||
|
||||
it('passes the id through to the mutation variables', async () => {
|
||||
const client = makeGraphqlClient({
|
||||
embeddedUpdate: { deleteEmbeddedUpdate: { id: 'update-2' } },
|
||||
});
|
||||
|
||||
await EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync(client, { id: 'update-2' });
|
||||
|
||||
expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { id: 'update-2' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { Connection } from '../../utils/relay';
|
||||
import {
|
||||
EmbeddedUpdateFilterInput,
|
||||
ViewEmbeddedUpdateByIdQuery,
|
||||
ViewEmbeddedUpdateByIdQueryVariables,
|
||||
ViewEmbeddedUpdatesPaginatedQuery,
|
||||
ViewEmbeddedUpdatesPaginatedQueryVariables,
|
||||
} from '../generated';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
|
||||
export function isEmbeddedUpdateNotFoundError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof CombinedError &&
|
||||
error.graphQLErrors.some(e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_NOT_FOUND')
|
||||
);
|
||||
}
|
||||
|
||||
export type EmbeddedUpdateFragment = ViewEmbeddedUpdateByIdQuery['embeddedUpdates']['byId'];
|
||||
|
||||
export type EmbeddedUpdateFilter = EmbeddedUpdateFilterInput;
|
||||
|
||||
export const EmbeddedUpdateQuery = {
|
||||
async viewByIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ embeddedUpdateId, appId }: { embeddedUpdateId: string; appId: string }
|
||||
): Promise<EmbeddedUpdateFragment> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<ViewEmbeddedUpdateByIdQuery, ViewEmbeddedUpdateByIdQueryVariables>(
|
||||
gql`
|
||||
query ViewEmbeddedUpdateById($embeddedUpdateId: ID!, $appId: ID!) {
|
||||
embeddedUpdates {
|
||||
byId(embeddedUpdateId: $embeddedUpdateId, appId: $appId) {
|
||||
id
|
||||
platform
|
||||
runtimeVersion
|
||||
channel
|
||||
createdAt
|
||||
launchAsset {
|
||||
id
|
||||
fileSize
|
||||
finalFileSize
|
||||
fileSHA256
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ embeddedUpdateId, appId },
|
||||
{ additionalTypenames: ['EmbeddedUpdate'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.embeddedUpdates.byId;
|
||||
},
|
||||
|
||||
async viewPaginatedAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
filter,
|
||||
first,
|
||||
after,
|
||||
}: { appId: string; filter?: EmbeddedUpdateFilter; first: number; after?: string }
|
||||
): Promise<Connection<EmbeddedUpdateFragment>> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<ViewEmbeddedUpdatesPaginatedQuery, ViewEmbeddedUpdatesPaginatedQueryVariables>(
|
||||
gql`
|
||||
query ViewEmbeddedUpdatesPaginated(
|
||||
$appId: String!
|
||||
$first: Int!
|
||||
$after: String
|
||||
$filter: EmbeddedUpdateFilterInput
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
embeddedUpdatesPaginated(first: $first, after: $after, filter: $filter) {
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
platform
|
||||
runtimeVersion
|
||||
channel
|
||||
createdAt
|
||||
launchAsset {
|
||||
id
|
||||
fileSize
|
||||
finalFileSize
|
||||
fileSHA256
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId, first, after, filter },
|
||||
{ additionalTypenames: ['EmbeddedUpdate'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.app.byId.embeddedUpdatesPaginated;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppPlatform } from '../../generated';
|
||||
import { EmbeddedUpdateQuery, isEmbeddedUpdateNotFoundError } from '../EmbeddedUpdateQuery';
|
||||
|
||||
function makeGraphqlClient(data: unknown): ExpoGraphqlClient {
|
||||
return {
|
||||
query: jest.fn().mockReturnValue({
|
||||
toPromise: jest.fn().mockResolvedValue({ data }),
|
||||
}),
|
||||
} as unknown as ExpoGraphqlClient;
|
||||
}
|
||||
|
||||
function makeCombinedError(errorCode: string): CombinedError {
|
||||
return new CombinedError({
|
||||
graphQLErrors: [{ message: 'error', extensions: { errorCode } }],
|
||||
});
|
||||
}
|
||||
|
||||
describe('isEmbeddedUpdateNotFoundError', () => {
|
||||
it('returns true for CombinedError with EMBEDDED_UPDATE_NOT_FOUND errorCode', () => {
|
||||
expect(isEmbeddedUpdateNotFoundError(makeCombinedError('EMBEDDED_UPDATE_NOT_FOUND'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false for CombinedError with a different errorCode', () => {
|
||||
expect(isEmbeddedUpdateNotFoundError(makeCombinedError('SOME_OTHER_ERROR'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for CombinedError with no extensions', () => {
|
||||
expect(
|
||||
isEmbeddedUpdateNotFoundError(new CombinedError({ graphQLErrors: [{ message: 'oops' }] }))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a plain Error', () => {
|
||||
expect(isEmbeddedUpdateNotFoundError(new Error('plain error'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null and non-error values', () => {
|
||||
expect(isEmbeddedUpdateNotFoundError(null)).toBe(false);
|
||||
expect(isEmbeddedUpdateNotFoundError('string')).toBe(false);
|
||||
expect(isEmbeddedUpdateNotFoundError(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmbeddedUpdateQuery.viewByIdAsync', () => {
|
||||
it('returns the embedded update from the GraphQL response', async () => {
|
||||
const expected = {
|
||||
id: 'update-1',
|
||||
platform: AppPlatform.Ios,
|
||||
runtimeVersion: '1.0.0',
|
||||
channel: 'production',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
launchAsset: {
|
||||
id: 'asset-1',
|
||||
fileSize: 1024,
|
||||
finalFileSize: 768,
|
||||
fileSHA256: 'abc123',
|
||||
},
|
||||
};
|
||||
const client = makeGraphqlClient({
|
||||
embeddedUpdates: { byId: expected },
|
||||
});
|
||||
|
||||
const result = await EmbeddedUpdateQuery.viewByIdAsync(client, {
|
||||
embeddedUpdateId: 'update-1',
|
||||
appId: 'app-1',
|
||||
});
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('passes the embeddedUpdateId and appId to the query', async () => {
|
||||
const client = makeGraphqlClient({
|
||||
embeddedUpdates: {
|
||||
byId: {
|
||||
id: 'update-1',
|
||||
platform: AppPlatform.Ios,
|
||||
runtimeVersion: '1.0.0',
|
||||
channel: 'production',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
launchAsset: {
|
||||
id: 'asset-1',
|
||||
fileSize: 1024,
|
||||
finalFileSize: null,
|
||||
fileSHA256: 'abc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await EmbeddedUpdateQuery.viewByIdAsync(client, {
|
||||
embeddedUpdateId: 'update-1',
|
||||
appId: 'app-1',
|
||||
});
|
||||
|
||||
expect(client.query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ embeddedUpdateId: 'update-1', appId: 'app-1' },
|
||||
expect.objectContaining({ additionalTypenames: ['EmbeddedUpdate'] })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmbeddedUpdateQuery.viewPaginatedAsync', () => {
|
||||
function makeConnection(): {
|
||||
edges: { cursor: string; node: any }[];
|
||||
pageInfo: {
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
startCursor: string | null;
|
||||
endCursor: string | null;
|
||||
};
|
||||
} {
|
||||
return {
|
||||
edges: [
|
||||
{
|
||||
cursor: 'c1',
|
||||
node: {
|
||||
id: 'update-1',
|
||||
platform: AppPlatform.Ios,
|
||||
runtimeVersion: '1.0.0',
|
||||
channel: 'production',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
launchAsset: {
|
||||
id: 'asset-1',
|
||||
fileSize: 1024,
|
||||
finalFileSize: null,
|
||||
fileSHA256: 'abc',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
|
||||
};
|
||||
}
|
||||
|
||||
it('returns the connection from the GraphQL response', async () => {
|
||||
const connection = makeConnection();
|
||||
const client = makeGraphqlClient({
|
||||
app: { byId: { id: 'app-1', embeddedUpdatesPaginated: connection } },
|
||||
});
|
||||
|
||||
const result = await EmbeddedUpdateQuery.viewPaginatedAsync(client, {
|
||||
appId: 'app-1',
|
||||
first: 25,
|
||||
});
|
||||
|
||||
expect(result).toEqual(connection);
|
||||
});
|
||||
|
||||
it('forwards filter / first / after to the query variables', async () => {
|
||||
const client = makeGraphqlClient({
|
||||
app: { byId: { id: 'app-1', embeddedUpdatesPaginated: makeConnection() } },
|
||||
});
|
||||
|
||||
await EmbeddedUpdateQuery.viewPaginatedAsync(client, {
|
||||
appId: 'app-1',
|
||||
first: 10,
|
||||
after: 'cursor-99',
|
||||
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.0.0', channel: 'production' },
|
||||
});
|
||||
|
||||
expect(client.query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{
|
||||
appId: 'app-1',
|
||||
first: 10,
|
||||
after: 'cursor-99',
|
||||
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.0.0', channel: 'production' },
|
||||
},
|
||||
expect.objectContaining({ additionalTypenames: ['EmbeddedUpdate'] })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as fs from 'fs-extra';
|
||||
|
||||
import {
|
||||
EAS_SIMULATOR_SESSION_ID,
|
||||
SIMULATOR_DOTENV_FILE_HEADER,
|
||||
getSimulatorEnvFilePath,
|
||||
resetSimulatorEnvAsync,
|
||||
writeSimulatorEnvAsync,
|
||||
} from '../env';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
describe(resetSimulatorEnvAsync, () => {
|
||||
const projectDir = '/test/project';
|
||||
const simulatorDotenvPath = getSimulatorEnvFilePath(projectDir);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
|
||||
jest.mocked(fs.truncate).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
it('overwrites the simulator dotenv file with the header only', async () => {
|
||||
await resetSimulatorEnvAsync(projectDir);
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(simulatorDotenvPath, SIMULATOR_DOTENV_FILE_HEADER, {
|
||||
flag: 'r+',
|
||||
});
|
||||
expect(fs.truncate).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
Buffer.byteLength(SIMULATOR_DOTENV_FILE_HEADER)
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a missing simulator dotenv file', async () => {
|
||||
const err = Object.assign(new Error('missing file'), { code: 'ENOENT' });
|
||||
jest.mocked(fs.writeFile).mockRejectedValue(err as never);
|
||||
|
||||
await expect(resetSimulatorEnvAsync(projectDir)).resolves.toBeUndefined();
|
||||
|
||||
expect(fs.truncate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rethrows non-missing-file errors', async () => {
|
||||
const err = Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
jest.mocked(fs.writeFile).mockRejectedValue(err as never);
|
||||
|
||||
await expect(resetSimulatorEnvAsync(projectDir)).rejects.toThrow('permission denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe(writeSimulatorEnvAsync, () => {
|
||||
const projectDir = '/test/project';
|
||||
const simulatorDotenvPath = getSimulatorEnvFilePath(projectDir);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
it('writes the simulator dotenv file with the header and environment variables', async () => {
|
||||
await writeSimulatorEnvAsync(projectDir, {
|
||||
AGENT_DEVICE_DAEMON_BASE_URL: 'https://agent.example.com',
|
||||
AGENT_DEVICE_DAEMON_AUTH_TOKEN: 'token-123',
|
||||
[EAS_SIMULATOR_SESSION_ID]: 'session-123',
|
||||
});
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledWith(
|
||||
simulatorDotenvPath,
|
||||
SIMULATOR_DOTENV_FILE_HEADER +
|
||||
'AGENT_DEVICE_DAEMON_BASE_URL="https://agent.example.com"\n' +
|
||||
'AGENT_DEVICE_DAEMON_AUTH_TOKEN="token-123"\n' +
|
||||
`${EAS_SIMULATOR_SESSION_ID}="session-123"\n`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { loadEnvFiles, loadProjectEnv } from '@expo/env';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
export const SIMULATOR_DOTENV_FILE_NAME = '.env.eas-simulator';
|
||||
export const EAS_SIMULATOR_SESSION_ID = 'EAS_SIMULATOR_SESSION_ID';
|
||||
export const SIMULATOR_DOTENV_FILE_HEADER =
|
||||
'# Do not commit this file.\n# Do not modify these values manually. They are managed by eas-cli.\n# It holds configuration only for the current simulator session.\n\n';
|
||||
|
||||
export function getSimulatorEnvFilePath(projectDir: string): string {
|
||||
return path.join(projectDir, SIMULATOR_DOTENV_FILE_NAME);
|
||||
}
|
||||
|
||||
export async function loadSimulatorEnvAsync(projectDir: string): Promise<void> {
|
||||
const simulatorDotenvFilePath = getSimulatorEnvFilePath(projectDir);
|
||||
|
||||
loadProjectEnv(projectDir, { silent: true });
|
||||
loadEnvFiles([simulatorDotenvFilePath], { force: true });
|
||||
}
|
||||
|
||||
export async function writeSimulatorEnvAsync(
|
||||
projectDir: string,
|
||||
environmentVariables: Record<string, string>
|
||||
): Promise<void> {
|
||||
const simulatorDotenvFilePath = getSimulatorEnvFilePath(projectDir);
|
||||
const simulatorDotenvContent =
|
||||
SIMULATOR_DOTENV_FILE_HEADER +
|
||||
Object.entries(environmentVariables)
|
||||
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
|
||||
.join('\n') +
|
||||
'\n';
|
||||
|
||||
await fs.writeFile(simulatorDotenvFilePath, simulatorDotenvContent);
|
||||
}
|
||||
|
||||
export async function resetSimulatorEnvAsync(projectDir: string): Promise<void> {
|
||||
const simulatorDotenvFilePath = getSimulatorEnvFilePath(projectDir);
|
||||
|
||||
try {
|
||||
await fs.writeFile(simulatorDotenvFilePath, SIMULATOR_DOTENV_FILE_HEADER, { flag: 'r+' });
|
||||
await fs.truncate(simulatorDotenvFilePath, Buffer.byteLength(SIMULATOR_DOTENV_FILE_HEADER));
|
||||
} catch (err) {
|
||||
if (typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -21,17 +21,44 @@ export function deviceRunSessionTypeToFlagValue(type: DeviceRunSessionType): str
|
||||
return DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[type];
|
||||
}
|
||||
|
||||
export function formatRemoteSessionInstructions(
|
||||
export function getRemoteSessionEnvironmentVariables(
|
||||
remoteConfig: DeviceRunSessionRemoteConfig
|
||||
): Record<string, string> {
|
||||
switch (remoteConfig.__typename) {
|
||||
case 'AgentDeviceRunSessionRemoteConfig':
|
||||
return {
|
||||
AGENT_DEVICE_DAEMON_BASE_URL: remoteConfig.agentDeviceRemoteSessionUrl,
|
||||
AGENT_DEVICE_DAEMON_AUTH_TOKEN: remoteConfig.agentDeviceRemoteSessionToken,
|
||||
};
|
||||
case 'ArgentRunSessionRemoteConfig':
|
||||
case 'ServeSimRunSessionRemoteConfig':
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
type RemoteSessionInstructionsConfigType = 'env' | 'dotenv';
|
||||
|
||||
export function formatRemoteSessionInstructions(
|
||||
remoteConfig: DeviceRunSessionRemoteConfig,
|
||||
configType: RemoteSessionInstructionsConfigType
|
||||
): string {
|
||||
switch (remoteConfig.__typename) {
|
||||
case 'AgentDeviceRunSessionRemoteConfig': {
|
||||
const lines = [
|
||||
'🔑 Run the following in your shell to attach to the agent-device daemon:',
|
||||
'',
|
||||
`export AGENT_DEVICE_DAEMON_BASE_URL='${remoteConfig.agentDeviceRemoteSessionUrl}'`,
|
||||
`export AGENT_DEVICE_DAEMON_AUTH_TOKEN='${remoteConfig.agentDeviceRemoteSessionToken}'`,
|
||||
];
|
||||
const environmentVariables = getRemoteSessionEnvironmentVariables(remoteConfig);
|
||||
const lines =
|
||||
configType === 'dotenv'
|
||||
? [
|
||||
'🔑 Run the following to use agent-device with the simulator:',
|
||||
'',
|
||||
'eas simulator:exec agent-device <command>',
|
||||
]
|
||||
: [
|
||||
'🔑 Run the following in your shell to attach to the agent-device daemon:',
|
||||
'',
|
||||
...Object.entries(environmentVariables).map(
|
||||
([key, value]) => `export ${key}='${value}'`
|
||||
),
|
||||
];
|
||||
if (remoteConfig.webPreviewUrl) {
|
||||
lines.push(
|
||||
'',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "20.0.0",
|
||||
"version": "20.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": "20.0.0",
|
||||
"@expo/eas-build-job": "20.1.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -131,7 +131,7 @@ PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
bundler (~> 1.17)
|
||||
bundler (>= 2.4)
|
||||
cocoapods (~> 1.12)
|
||||
expo-cocoapods-proxy!
|
||||
rake (~> 10.0)
|
||||
@@ -139,4 +139,4 @@ DEPENDENCIES
|
||||
rubocop (~> 1.48)
|
||||
|
||||
BUNDLED WITH
|
||||
2.1.4
|
||||
4.0.13
|
||||
@@ -32,7 +32,7 @@ Gem::Specification.new do |spec|
|
||||
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
||||
spec.require_paths = ['lib']
|
||||
|
||||
spec.add_development_dependency 'bundler', '~> 1.17'
|
||||
spec.add_development_dependency 'bundler', '>= 2.4'
|
||||
spec.add_development_dependency 'rake', '~> 10.0'
|
||||
spec.add_development_dependency 'rspec', '~> 3.0'
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "20.0.0",
|
||||
"version": "20.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,8 +26,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "20.0.0",
|
||||
"@expo/eas-build-job": "20.0.0",
|
||||
"@expo/build-tools": "20.1.0",
|
||||
"@expo/eas-build-job": "20.1.0",
|
||||
"@expo/logger": "20.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "20.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "20.0.0",
|
||||
"version": "20.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,7 +27,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "20.0.0",
|
||||
"@expo/eas-build-job": "20.1.0",
|
||||
"@expo/logger": "20.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "20.0.0",
|
||||
"version": "20.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,16 +19,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "20.0.0",
|
||||
"@expo/build-tools": "20.1.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "20.0.0",
|
||||
"@expo/eas-build-job": "20.0.0",
|
||||
"@expo/eas-build-job": "20.1.0",
|
||||
"@expo/logger": "20.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "20.0.0",
|
||||
"@expo/steps": "20.1.0",
|
||||
"@expo/template-file": "20.0.0",
|
||||
"@expo/turtle-spawn": "20.0.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
describe('config', () => {
|
||||
const originalEnvironment = process.env.ENVIRONMENT;
|
||||
const originalWorkerRuntimeConfigBase64 = process.env.WORKER_RUNTIME_CONFIG_BASE64;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv('ENVIRONMENT', originalEnvironment);
|
||||
restoreEnv('WORKER_RUNTIME_CONFIG_BASE64', originalWorkerRuntimeConfigBase64);
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('parses cache URL fallbacks from worker runtime config', () => {
|
||||
process.env.ENVIRONMENT = 'staging';
|
||||
process.env.WORKER_RUNTIME_CONFIG_BASE64 = Buffer.from(
|
||||
JSON.stringify({
|
||||
buildId: 'build-id',
|
||||
npmCacheUrl: 'https://npm-cache.example',
|
||||
nodeJsCacheUrl: 'https://node-cache.example',
|
||||
mavenCacheUrl: 'https://maven-cache.example',
|
||||
cocoapodsCacheUrl: 'https://cocoapods-cache.example',
|
||||
})
|
||||
).toString('base64');
|
||||
|
||||
jest.isolateModules(() => {
|
||||
const config = require('../config').default;
|
||||
|
||||
expect(config.npmCacheUrl).toBe('https://npm-cache.example');
|
||||
expect(config.nodeJsCacheUrl).toBe('https://node-cache.example');
|
||||
expect(config.mavenCacheUrl).toBe('https://maven-cache.example');
|
||||
expect(config.cocoapodsCacheUrl).toBe('https://cocoapods-cache.example');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RuntimeSettings } from '@expo/build-tools';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import os from 'os';
|
||||
|
||||
@@ -6,9 +7,44 @@ import { getBuildEnv, getGradleMemoryOptions } from '../env';
|
||||
|
||||
describe(getBuildEnv.name, () => {
|
||||
const originalSentryDsn = config.sentry.dsn;
|
||||
const originalPlatform = process.platform;
|
||||
const originalCacheUrls = {
|
||||
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
|
||||
EAS_NODEJS_CACHE_URL: process.env.EAS_NODEJS_CACHE_URL,
|
||||
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
|
||||
EAS_COCOAPODS_CACHE_URL: process.env.EAS_COCOAPODS_CACHE_URL,
|
||||
EAS_BUILD_NPM_CACHE_URL: process.env.EAS_BUILD_NPM_CACHE_URL,
|
||||
NPM_CACHE_URL: process.env.NPM_CACHE_URL,
|
||||
NVM_NODEJS_ORG_MIRROR: process.env.NVM_NODEJS_ORG_MIRROR,
|
||||
EAS_BUILD_MAVEN_CACHE_URL: process.env.EAS_BUILD_MAVEN_CACHE_URL,
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: process.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.EAS_BUILD_NPM_CACHE_URL;
|
||||
delete process.env.NPM_CACHE_URL;
|
||||
delete process.env.NVM_NODEJS_ORG_MIRROR;
|
||||
delete process.env.EAS_BUILD_MAVEN_CACHE_URL;
|
||||
delete process.env.EAS_BUILD_COCOAPODS_CACHE_URL;
|
||||
jest.spyOn(RuntimeSettings, 'getNpmCacheUrl').mockReturnValue(null);
|
||||
jest.spyOn(RuntimeSettings, 'getNodeJsCacheUrl').mockReturnValue(null);
|
||||
jest.spyOn(RuntimeSettings, 'getMavenCacheUrl').mockReturnValue(null);
|
||||
jest.spyOn(RuntimeSettings, 'getCocoapodsCacheUrl').mockReturnValue(null);
|
||||
jest.spyOn(RuntimeSettings, 'isUsingIosPrecompiledModulesEnabled').mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.sentry.dsn = originalSentryDsn;
|
||||
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
|
||||
restoreEnv('EAS_NODEJS_CACHE_URL', originalCacheUrls.EAS_NODEJS_CACHE_URL);
|
||||
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
|
||||
restoreEnv('EAS_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_COCOAPODS_CACHE_URL);
|
||||
restoreEnv('EAS_BUILD_NPM_CACHE_URL', originalCacheUrls.EAS_BUILD_NPM_CACHE_URL);
|
||||
restoreEnv('NPM_CACHE_URL', originalCacheUrls.NPM_CACHE_URL);
|
||||
restoreEnv('NVM_NODEJS_ORG_MIRROR', originalCacheUrls.NVM_NODEJS_ORG_MIRROR);
|
||||
restoreEnv('EAS_BUILD_MAVEN_CACHE_URL', originalCacheUrls.EAS_BUILD_MAVEN_CACHE_URL);
|
||||
restoreEnv('EAS_BUILD_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_BUILD_COCOAPODS_CACHE_URL);
|
||||
mockProcessPlatform(originalPlatform);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -62,6 +98,180 @@ describe(getBuildEnv.name, () => {
|
||||
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
|
||||
});
|
||||
|
||||
it('adds precompiled modules env vars for iOS jobs when enabled', () => {
|
||||
jest.mocked(RuntimeSettings.isUsingIosPrecompiledModulesEnabled).mockReturnValue(true);
|
||||
|
||||
const env = getBuildEnv({
|
||||
job: {
|
||||
platform: Platform.IOS,
|
||||
type: Workflow.GENERIC,
|
||||
builderEnvironment: {
|
||||
env: {},
|
||||
},
|
||||
} as any,
|
||||
projectId: 'project-id',
|
||||
metadata: {
|
||||
buildProfile: 'production',
|
||||
gitCommitHash: 'abc123',
|
||||
username: 'expo-user',
|
||||
} as any,
|
||||
buildId: 'build-id',
|
||||
});
|
||||
|
||||
expect(env.EAS_USE_PRECOMPILED_MODULES).toBe('1');
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars for Android jobs when enabled', () => {
|
||||
jest.mocked(RuntimeSettings.isUsingIosPrecompiledModulesEnabled).mockReturnValue(true);
|
||||
|
||||
const env = getBuildEnv({
|
||||
job: {
|
||||
platform: Platform.ANDROID,
|
||||
type: Workflow.MANAGED,
|
||||
builderEnvironment: {
|
||||
env: {},
|
||||
},
|
||||
username: 'expo-user',
|
||||
} as any,
|
||||
projectId: 'project-id',
|
||||
metadata: {
|
||||
buildProfile: 'production',
|
||||
gitCommitHash: 'abc123',
|
||||
username: 'expo-user',
|
||||
} as any,
|
||||
buildId: 'build-id',
|
||||
});
|
||||
|
||||
expect(env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves job-provided precompiled modules env vars in override position', () => {
|
||||
jest.mocked(RuntimeSettings.isUsingIosPrecompiledModulesEnabled).mockReturnValue(true);
|
||||
|
||||
const job = {
|
||||
platform: Platform.IOS,
|
||||
type: Workflow.GENERIC,
|
||||
builderEnvironment: {
|
||||
env: {
|
||||
EAS_USE_PRECOMPILED_MODULES: '0',
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const baseEnv = getBuildEnv({
|
||||
job,
|
||||
projectId: 'project-id',
|
||||
metadata: {
|
||||
buildProfile: 'production',
|
||||
gitCommitHash: 'abc123',
|
||||
username: 'expo-user',
|
||||
} as any,
|
||||
buildId: 'build-id',
|
||||
});
|
||||
|
||||
expect({ ...baseEnv, ...job.builderEnvironment.env }.EAS_USE_PRECOMPILED_MODULES).toBe('0');
|
||||
});
|
||||
|
||||
it('does not expose disabled Linux cache URLs in build env', () => {
|
||||
mockProcessPlatform('linux');
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
|
||||
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
|
||||
|
||||
const env = getBuildEnv({
|
||||
job: {
|
||||
platform: Platform.ANDROID,
|
||||
type: Workflow.MANAGED,
|
||||
builderEnvironment: {
|
||||
env: {},
|
||||
},
|
||||
username: 'expo-user',
|
||||
} as any,
|
||||
projectId: 'project-id',
|
||||
metadata: {
|
||||
buildProfile: 'production',
|
||||
gitCommitHash: 'abc123',
|
||||
username: 'expo-user',
|
||||
} as any,
|
||||
buildId: 'build-id',
|
||||
});
|
||||
|
||||
expect(env.NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.NVM_NODEJS_ORG_MIRROR).toBeUndefined();
|
||||
expect(env.EAS_BUILD_NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_BUILD_MAVEN_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_NODEJS_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_MAVEN_CACHE_URL).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not expose disabled Darwin cache URLs in build env', () => {
|
||||
mockProcessPlatform('darwin');
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
|
||||
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
|
||||
|
||||
const env = getBuildEnv({
|
||||
job: {
|
||||
platform: Platform.IOS,
|
||||
type: Workflow.GENERIC,
|
||||
builderEnvironment: {
|
||||
env: {},
|
||||
},
|
||||
} as any,
|
||||
projectId: 'project-id',
|
||||
metadata: {
|
||||
buildProfile: 'production',
|
||||
gitCommitHash: 'abc123',
|
||||
username: 'expo-user',
|
||||
} as any,
|
||||
buildId: 'build-id',
|
||||
});
|
||||
|
||||
expect(env.NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.NVM_NODEJS_ORG_MIRROR).toBeUndefined();
|
||||
expect(env.EAS_BUILD_NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_BUILD_COCOAPODS_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_NODEJS_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_COCOAPODS_CACHE_URL).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes enabled cache URLs inferred from private worker environment variables', () => {
|
||||
mockProcessPlatform('linux');
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
|
||||
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
|
||||
jest.mocked(RuntimeSettings.getNpmCacheUrl).mockReturnValue('https://npm.example');
|
||||
jest.mocked(RuntimeSettings.getNodeJsCacheUrl).mockReturnValue('https://node.example');
|
||||
jest.mocked(RuntimeSettings.getMavenCacheUrl).mockReturnValue('https://maven.example');
|
||||
|
||||
const env = getBuildEnv({
|
||||
job: {
|
||||
platform: Platform.ANDROID,
|
||||
type: Workflow.MANAGED,
|
||||
builderEnvironment: {
|
||||
env: {},
|
||||
},
|
||||
username: 'expo-user',
|
||||
} as any,
|
||||
projectId: 'project-id',
|
||||
metadata: {
|
||||
buildProfile: 'production',
|
||||
gitCommitHash: 'abc123',
|
||||
username: 'expo-user',
|
||||
} as any,
|
||||
buildId: 'build-id',
|
||||
});
|
||||
|
||||
expect(env.NPM_CACHE_URL).toBe('https://npm.example');
|
||||
expect(env.EAS_BUILD_NPM_CACHE_URL).toBe('https://npm.example');
|
||||
expect(env.NVM_NODEJS_ORG_MIRROR).toBe('https://node.example');
|
||||
expect(env.EAS_BUILD_MAVEN_CACHE_URL).toBe('https://maven.example');
|
||||
expect(env.EAS_NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_NODEJS_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_MAVEN_CACHE_URL).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sizes Gradle memory options from total memory', () => {
|
||||
const totalMemory = jest.spyOn(os, 'totalmem');
|
||||
|
||||
@@ -84,3 +294,18 @@ describe(getBuildEnv.name, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockProcessPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: platform,
|
||||
});
|
||||
}
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
// @ts-nocheck
|
||||
import { RuntimeSettings } from '@expo/build-tools';
|
||||
import { Android, Ios, Job } from '@expo/eas-build-job';
|
||||
import templateFile from '@expo/template-file';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import { pathExists } from 'fs-extra';
|
||||
import { prepareRuntimeEnvironment } from '../runtimeEnvironment';
|
||||
import { mkdirp, pathExists } from 'fs-extra';
|
||||
|
||||
import config from '../config';
|
||||
import {
|
||||
prepareRuntimeEnvironment,
|
||||
prepareRuntimeEnvironmentConfigFiles,
|
||||
} from '../runtimeEnvironment';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('@expo/template-file');
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
|
||||
const spawnResult: SpawnResult = {
|
||||
@@ -31,10 +39,75 @@ const ctx = {
|
||||
const builderConfig: Ios.BuilderEnvironment | Android.BuilderEnvironment = {};
|
||||
|
||||
describe('prepareRuntimeEnvironment', () => {
|
||||
const originalEnvironment = config.env;
|
||||
const originalPlatform = process.platform;
|
||||
const originalCacheUrls = {
|
||||
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
|
||||
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.env = originalEnvironment;
|
||||
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
|
||||
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
|
||||
mockProcessPlatform(originalPlatform);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe(prepareRuntimeEnvironmentConfigFiles.name, () => {
|
||||
beforeEach(() => {
|
||||
config.env = 'production';
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
|
||||
});
|
||||
|
||||
it('does not prepare disabled Linux cache config files', async () => {
|
||||
mockProcessPlatform('linux');
|
||||
jest.spyOn(RuntimeSettings, 'getNpmCacheUrl').mockReturnValue(null);
|
||||
jest.spyOn(RuntimeSettings, 'getMavenCacheUrl').mockReturnValue(null);
|
||||
|
||||
await prepareRuntimeEnvironmentConfigFiles();
|
||||
|
||||
expect(spawn).not.toHaveBeenCalledWith('npm', [
|
||||
'config',
|
||||
'set',
|
||||
'registry',
|
||||
'https://npm.example',
|
||||
]);
|
||||
expect(templateFile).not.toHaveBeenCalled();
|
||||
expect(mkdirp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prepares enabled Linux cache config files', async () => {
|
||||
mockProcessPlatform('linux');
|
||||
jest.spyOn(RuntimeSettings, 'getNpmCacheUrl').mockReturnValue('https://npm.example');
|
||||
jest.spyOn(RuntimeSettings, 'getMavenCacheUrl').mockReturnValue('https://maven.example');
|
||||
|
||||
await prepareRuntimeEnvironmentConfigFiles();
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith('npm', [
|
||||
'config',
|
||||
'set',
|
||||
'registry',
|
||||
'https://npm.example',
|
||||
]);
|
||||
expect(templateFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('yarnrc.yml'),
|
||||
{ URL: 'https://npm.example' },
|
||||
expect.stringContaining('.yarnrc.yml')
|
||||
);
|
||||
expect(templateFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('init.gradle'),
|
||||
{ URL: 'https://maven.example' },
|
||||
expect.stringContaining('init.gradle')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installNode', () => {
|
||||
describe('prepareRuntimeEnvironment', () => {
|
||||
beforeEach(() => {
|
||||
@@ -188,3 +261,18 @@ describe('prepareRuntimeEnvironment', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockProcessPlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: platform,
|
||||
});
|
||||
}
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -136,14 +136,14 @@ describe('BuildService Datadog setup', () => {
|
||||
|
||||
expect(datadogSetupMock).toHaveBeenCalledWith({
|
||||
expoApiV2BaseUrl: expect.any(String),
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'build', turtleBuildId: 'build-id' },
|
||||
});
|
||||
expect(createBuildContextMock).toHaveBeenCalled();
|
||||
expect(datadogFlushAsyncMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('configures Datadog for non-platform jobs with the turtle build id', async () => {
|
||||
it('configures Datadog for jobRun jobs with the turtle job run id', async () => {
|
||||
const service = new BuildService();
|
||||
service.checkForHangingWorker = jest.fn(async () => {});
|
||||
|
||||
@@ -158,8 +158,8 @@ describe('BuildService Datadog setup', () => {
|
||||
|
||||
expect(datadogSetupMock).toHaveBeenCalledWith({
|
||||
expoApiV2BaseUrl: expect.any(String),
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
target: { kind: 'jobRun', turtleJobRunId: 'build-id' },
|
||||
});
|
||||
expect(createBuildContextMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RuntimeSettings } from '@expo/build-tools';
|
||||
import { Env, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { spawnSync } from 'child_process';
|
||||
import micromatch from 'micromatch';
|
||||
@@ -35,9 +36,14 @@ export function getBuildEnv({
|
||||
setEnv(env, 'EAS_BUILD_PLATFORM', job.platform);
|
||||
setEnv(env, 'EAS_CLI_SENTRY_DSN', config.sentry.dsn);
|
||||
// NPM_CACHE_URL is deprecated
|
||||
setEnv(env, 'NPM_CACHE_URL', config.npmCacheUrl);
|
||||
setEnv(env, 'NVM_NODEJS_ORG_MIRROR', config.nodeJsCacheUrl);
|
||||
setEnv(env, 'EAS_BUILD_NPM_CACHE_URL', config.npmCacheUrl);
|
||||
const npmCacheUrl = RuntimeSettings.getNpmCacheUrl();
|
||||
const nodeJsCacheUrl = RuntimeSettings.getNodeJsCacheUrl();
|
||||
const mavenCacheUrl = RuntimeSettings.getMavenCacheUrl();
|
||||
const cocoapodsCacheUrl = RuntimeSettings.getCocoapodsCacheUrl();
|
||||
|
||||
setEnv(env, 'NPM_CACHE_URL', npmCacheUrl);
|
||||
setEnv(env, 'NVM_NODEJS_ORG_MIRROR', nodeJsCacheUrl);
|
||||
setEnv(env, 'EAS_BUILD_NPM_CACHE_URL', npmCacheUrl);
|
||||
setEnv(env, 'EAS_BUILD_PROFILE', metadata.buildProfile);
|
||||
setEnv(env, 'EAS_BUILD_GIT_COMMIT_HASH', metadata.gitCommitHash);
|
||||
setEnv(env, 'EAS_BUILD_ID', buildId);
|
||||
@@ -47,8 +53,11 @@ export function getBuildEnv({
|
||||
|
||||
const runnerPlatform = job.platform;
|
||||
if (runnerPlatform === Platform.IOS) {
|
||||
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', config.cocoapodsCacheUrl);
|
||||
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', cocoapodsCacheUrl);
|
||||
setEnv(env, 'COMPILER_INDEX_STORE_ENABLE', 'NO');
|
||||
if (RuntimeSettings.isUsingIosPrecompiledModulesEnabled()) {
|
||||
setEnv(env, 'EAS_USE_PRECOMPILED_MODULES', '1');
|
||||
}
|
||||
|
||||
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
|
||||
setEnv(env, 'USE_CCACHE', '1');
|
||||
@@ -69,7 +78,7 @@ export function getBuildEnv({
|
||||
setEnv(env, 'ANDROID_CCACHE', binPath);
|
||||
}
|
||||
}
|
||||
setEnv(env, 'EAS_BUILD_MAVEN_CACHE_URL', config.mavenCacheUrl);
|
||||
setEnv(env, 'EAS_BUILD_MAVEN_CACHE_URL', mavenCacheUrl);
|
||||
}
|
||||
|
||||
if (config.env !== Environment.TEST) {
|
||||
@@ -136,7 +145,14 @@ export function getBuildEnv({
|
||||
}
|
||||
|
||||
function getFilteredEnv(): Env {
|
||||
const envToFilter = [...getAccessedEnvs(), 'KUBERNETES_*'];
|
||||
const envToFilter = [
|
||||
...getAccessedEnvs(),
|
||||
'EAS_NPM_CACHE_URL',
|
||||
'EAS_NODEJS_CACHE_URL',
|
||||
'EAS_MAVEN_CACHE_URL',
|
||||
'EAS_COCOAPODS_CACHE_URL',
|
||||
'KUBERNETES_*',
|
||||
];
|
||||
const envToReturn = micromatch(
|
||||
Object.keys(process.env),
|
||||
envToFilter.map(env => `!${env}`)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { RuntimeSettings } from '@expo/build-tools';
|
||||
|
||||
import config from './config';
|
||||
import logger from './logger';
|
||||
import { startServer } from './metricsServer';
|
||||
@@ -7,6 +9,16 @@ import { prepareWorkingdir } from './workingdir';
|
||||
import startWsServer from './ws';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: config.env,
|
||||
logger,
|
||||
cacheUrlFallbacks: {
|
||||
npm: config.npmCacheUrl,
|
||||
nodejs: config.nodeJsCacheUrl,
|
||||
maven: config.mavenCacheUrl,
|
||||
cocoapods: config.cocoapodsCacheUrl,
|
||||
},
|
||||
});
|
||||
await prepareRuntimeEnvironmentConfigFiles();
|
||||
await prepareWorkingdir();
|
||||
startWsServer();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BuildContext } from '@expo/build-tools';
|
||||
import { BuildContext, RuntimeSettings } from '@expo/build-tools';
|
||||
import { Job, errors } from '@expo/eas-build-job';
|
||||
import templateFile from '@expo/template-file';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -23,25 +23,28 @@ export async function prepareRuntimeEnvironmentConfigFiles(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.npmCacheUrl) {
|
||||
const npmCacheUrl = RuntimeSettings.getNpmCacheUrl();
|
||||
const mavenCacheUrl = RuntimeSettings.getMavenCacheUrl();
|
||||
|
||||
if (npmCacheUrl) {
|
||||
// create ~/.npmrc
|
||||
await spawn('npm', ['config', 'set', 'registry', config.npmCacheUrl]);
|
||||
await spawn('npm', ['config', 'set', 'registry', npmCacheUrl]);
|
||||
// create ~/.yarnrc.yml
|
||||
await templateFile(
|
||||
path.join(__dirname, '../src/templates/yarnrc.yml'),
|
||||
{
|
||||
URL: config.npmCacheUrl,
|
||||
URL: npmCacheUrl,
|
||||
},
|
||||
path.join(os.homedir(), '.yarnrc.yml')
|
||||
);
|
||||
}
|
||||
|
||||
if (config.mavenCacheUrl) {
|
||||
if (mavenCacheUrl) {
|
||||
await fs.mkdirp(path.join(os.homedir(), '.gradle'));
|
||||
await templateFile(
|
||||
path.join(__dirname, '../src/templates/init.gradle'),
|
||||
{
|
||||
URL: config.mavenCacheUrl,
|
||||
URL: mavenCacheUrl,
|
||||
},
|
||||
path.join(os.homedir(), '.gradle/init.gradle')
|
||||
);
|
||||
|
||||
@@ -283,8 +283,12 @@ export default class BuildService {
|
||||
if (robotAccessToken) {
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: config.wwwApiV2BaseUrl,
|
||||
turtleBuildId: this.buildId,
|
||||
robotAccessToken,
|
||||
// job.platform is set only for build jobs; for a jobRun, this.buildId is the
|
||||
// turtle-job-run id (verified by the jobType === 'jobRun' guard in startBuild()).
|
||||
target: job.platform
|
||||
? { kind: 'build', turtleBuildId: this.buildId }
|
||||
: { kind: 'jobRun', turtleJobRunId: this.buildId },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 || ("$1" != "staging" && "$1" != "production") ]]; then
|
||||
echo "Usage: scripts/edit-runtime-settings staging|production" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "curl is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for tool in gcloud jq; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "$tool is required. Run \`mise install\` from the repository root and try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
env="$1"
|
||||
object_uri="gs://eas-workflows-${env}/runtime-settings.json"
|
||||
object_url="https://storage.googleapis.com/eas-workflows-${env}/runtime-settings.json"
|
||||
cache_control="no-cache, no-store, must-revalidate"
|
||||
editor="${EDITOR:-vi}"
|
||||
|
||||
tmpfile="$(mktemp)"
|
||||
minified_tmpfile="$(mktemp)"
|
||||
downloaded_tmpfile="$(mktemp)"
|
||||
trap 'rm -f "$tmpfile" "$minified_tmpfile" "$downloaded_tmpfile"' EXIT
|
||||
|
||||
echo "Remote URL: $object_url"
|
||||
curl -fsSL "$object_url" | jq . >"$tmpfile"
|
||||
echo "Edit and verify the pretty-formatted JSON file before saving."
|
||||
|
||||
while true; do
|
||||
"$editor" "$tmpfile"
|
||||
if jq empty "$tmpfile" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
|
||||
read -r -n 1 -s -p "JSON is invalid. Press any key to reopen editor." _
|
||||
echo >&2
|
||||
done
|
||||
|
||||
jq -c . "$tmpfile" >"$minified_tmpfile"
|
||||
|
||||
echo "Contents to upload:"
|
||||
cat "$minified_tmpfile"
|
||||
echo
|
||||
|
||||
if [[ "$env" == "production" ]]; then
|
||||
confirmation=""
|
||||
read -r -p 'Type "production" to upload production runtime settings: ' confirmation
|
||||
if [[ "$confirmation" != "production" ]]; then
|
||||
echo "Production upload canceled." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
gcloud storage cp \
|
||||
--cache-control="$cache_control" \
|
||||
--content-type=application/json \
|
||||
--predefined-acl=publicRead \
|
||||
"$minified_tmpfile" \
|
||||
"$object_uri"
|
||||
|
||||
curl -fsSL "$object_url" >"$downloaded_tmpfile"
|
||||
if ! cmp -s "$minified_tmpfile" "$downloaded_tmpfile"; then
|
||||
echo "Read-after-write check failed: uploaded file differs from remote object." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Confirmed remote contents:"
|
||||
cat "$downloaded_tmpfile"
|
||||
echo
|
||||
@@ -789,15 +789,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/code-frame@npm:7.10.4, @babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:~7.10.4":
|
||||
version: 7.10.4
|
||||
resolution: "@babel/code-frame@npm:7.10.4"
|
||||
dependencies:
|
||||
"@babel/highlight": "npm:^7.10.4"
|
||||
checksum: 10c0/69e0f52986a1f40231d891224f420436629b6678711b68c088e97b7bdba1607aeb5eb9cfb070275c433f0bf43c37c134845db80d1cdbf5ac88a69b0bdcce9402
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/code-frame@npm:7.23.5, @babel/code-frame@npm:^7.23.5":
|
||||
version: 7.23.5
|
||||
resolution: "@babel/code-frame@npm:7.23.5"
|
||||
@@ -808,6 +799,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:~7.10.4":
|
||||
version: 7.10.4
|
||||
resolution: "@babel/code-frame@npm:7.10.4"
|
||||
dependencies:
|
||||
"@babel/highlight": "npm:^7.10.4"
|
||||
checksum: 10c0/69e0f52986a1f40231d891224f420436629b6678711b68c088e97b7bdba1607aeb5eb9cfb070275c433f0bf43c37c134845db80d1cdbf5ac88a69b0bdcce9402
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/code-frame@npm:^7.12.13":
|
||||
version: 7.12.13
|
||||
resolution: "@babel/code-frame@npm:7.12.13"
|
||||
@@ -2856,22 +2856,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:20.0.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:20.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:20.0.0"
|
||||
"@expo/eas-build-job": "npm:20.0.0"
|
||||
"@expo/eas-build-job": "npm:20.1.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:20.0.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/plist": "npm:^0.2.0"
|
||||
"@expo/repack-app": "npm:~0.4.1"
|
||||
"@expo/repack-app": "npm:~0.6.1"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:20.0.0"
|
||||
"@expo/steps": "npm:20.1.0"
|
||||
"@expo/template-file": "npm:20.0.0"
|
||||
"@expo/turtle-spawn": "npm:20.0.0"
|
||||
"@expo/xcpretty": "npm:^4.3.1"
|
||||
@@ -3113,7 +3113,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:20.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:20.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:
|
||||
@@ -3132,12 +3132,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:20.0.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:20.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:20.0.0"
|
||||
"@expo/eas-build-job": "npm:20.1.0"
|
||||
"@tsconfig/node20": "npm:20.1.8"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -3419,15 +3419,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/repack-app@npm:~0.4.1":
|
||||
version: 0.4.1
|
||||
resolution: "@expo/repack-app@npm:0.4.1"
|
||||
"@expo/repack-app@npm:~0.6.1":
|
||||
version: 0.6.1
|
||||
resolution: "@expo/repack-app@npm:0.6.1"
|
||||
dependencies:
|
||||
commander: "npm:^14.0.2"
|
||||
picocolors: "npm:^1.1.1"
|
||||
bin:
|
||||
repack-app: bin/cli.js
|
||||
checksum: 10c0/4bc30a7ca95221f079026633802c32894ef25d55677212e86859194bf1a042598eaa5977510a277c3ceee6af6bd0d0a3aff3e21c4510c37b7dea584dd927ed0e
|
||||
checksum: 10c0/fdf3088b4f327393056631270bf90a67ba949e7994892a62abc985bd9478119ab2f1d5be35fa1efb6bd1d2780c49a3dbb87cb669d9c65b44e3d9a0b41fbf091f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -3485,11 +3485,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:20.0.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:20.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:20.0.0"
|
||||
"@expo/eas-build-job": "npm:20.1.0"
|
||||
"@expo/logger": "npm:20.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
@@ -3558,16 +3558,16 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:20.0.0"
|
||||
"@expo/build-tools": "npm:20.1.0"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/downloader": "npm:20.0.0"
|
||||
"@expo/eas-build-job": "npm:20.0.0"
|
||||
"@expo/eas-build-job": "npm:20.1.0"
|
||||
"@expo/logger": "npm:20.0.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:20.0.0"
|
||||
"@expo/steps": "npm:20.1.0"
|
||||
"@expo/template-file": "npm:20.0.0"
|
||||
"@expo/turtle-spawn": "npm:20.0.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
@@ -3614,16 +3614,15 @@ __metadata:
|
||||
linkType: soft
|
||||
|
||||
"@expo/xcpretty@npm:^4.3.1":
|
||||
version: 4.3.2
|
||||
resolution: "@expo/xcpretty@npm:4.3.2"
|
||||
version: 4.4.4
|
||||
resolution: "@expo/xcpretty@npm:4.4.4"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.10.4"
|
||||
"@babel/code-frame": "npm:^7.20.0"
|
||||
chalk: "npm:^4.1.0"
|
||||
find-up: "npm:^5.0.0"
|
||||
js-yaml: "npm:^4.1.0"
|
||||
bin:
|
||||
excpretty: build/cli.js
|
||||
checksum: 10c0/e524817b2e42fb8c8914fca7e8f7c2f723f4f6d338a57b7ae97cd3e76da8108af63a22d4c7dc2e96a192a248a242f6e0f8056f0ca53bc4fb5cd2e5ae428e0891
|
||||
checksum: 10c0/cd555ad49438dee2cc3f2950ecbef3048f7169bbdadc8db169cfcddaad13668fee6377c010624ed4079dc439c46d6023d2551da403a2070deec000bc864e8dd8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10488,7 +10487,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:20.0.0"
|
||||
"@expo/steps": "npm:20.1.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
@@ -11101,8 +11100,8 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:20.0.0"
|
||||
"@expo/eas-build-job": "npm:20.0.0"
|
||||
"@expo/build-tools": "npm:20.1.0"
|
||||
"@expo/eas-build-job": "npm:20.1.0"
|
||||
"@expo/logger": "npm:20.0.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:20.0.0"
|
||||
@@ -11159,8 +11158,8 @@ __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:20.0.0"
|
||||
"@expo/eas-json": "npm:20.0.0"
|
||||
"@expo/eas-build-job": "npm:20.1.0"
|
||||
"@expo/eas-json": "npm:20.1.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:20.0.0"
|
||||
@@ -11175,7 +11174,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:20.0.0"
|
||||
"@expo/steps": "npm:20.1.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:5.0.0"
|
||||
"@graphql-codegen/introspection": "npm:4.0.0"
|
||||
@@ -12119,16 +12118,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"find-up@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "find-up@npm:5.0.0"
|
||||
dependencies:
|
||||
locate-path: "npm:^6.0.0"
|
||||
path-exists: "npm:^4.0.0"
|
||||
checksum: 10c0/062c5a83a9c02f53cdd6d175a37ecf8f87ea5bbff1fdfb828f04bfa021441bc7583e8ebc0872a4c1baab96221fb8a8a275a19809fb93fbc40bd69ec35634069a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"find-yarn-workspace-root@npm:^2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "find-yarn-workspace-root@npm:2.0.0"
|
||||
@@ -15375,15 +15364,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"locate-path@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "locate-path@npm:6.0.0"
|
||||
dependencies:
|
||||
p-locate: "npm:^5.0.0"
|
||||
checksum: 10c0/d3972ab70dfe58ce620e64265f90162d247e87159b6126b01314dd67be43d50e96a50b517bce2d9452a79409c7614054c277b5232377de50416564a77ac7aad3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.clonedeep@npm:^4.5.0":
|
||||
version: 4.5.0
|
||||
resolution: "lodash.clonedeep@npm:4.5.0"
|
||||
@@ -17349,7 +17329,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"p-limit@npm:3.1.0, p-limit@npm:^3.0.1, p-limit@npm:^3.0.2, p-limit@npm:^3.1.0":
|
||||
"p-limit@npm:3.1.0, p-limit@npm:^3.0.1, p-limit@npm:^3.1.0":
|
||||
version: 3.1.0
|
||||
resolution: "p-limit@npm:3.1.0"
|
||||
dependencies:
|
||||
@@ -17394,15 +17374,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"p-locate@npm:^5.0.0":
|
||||
version: 5.0.0
|
||||
resolution: "p-locate@npm:5.0.0"
|
||||
dependencies:
|
||||
p-limit: "npm:^3.0.2"
|
||||
checksum: 10c0/2290d627ab7903b8b70d11d384fee714b797f6040d9278932754a6860845c4d3190603a0772a663c8cb5a7b21d1b16acb3a6487ebcafa9773094edc3dfe6009a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"p-map-series@npm:2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "p-map-series@npm:2.1.0"
|
||||
|
||||
Reference in new issue
Block a user