mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 17:50:32 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27a2e2abc7 | ||
|
|
149f5d86a4 | ||
|
|
18c2e82385 | ||
|
|
43ffe46428 | ||
|
|
fb9b49cd5b | ||
|
|
17eb5f0687 | ||
|
|
6297ac19d9 | ||
|
|
6d86f91dab | ||
|
|
cedfabe18a | ||
|
|
20a21908d0 | ||
|
|
18b1dd8d9d | ||
|
|
fce2a77909 | ||
|
|
c562f08da9 | ||
|
|
5dee409567 | ||
|
|
ea2cd4f318 | ||
|
|
639a7cce61 | ||
|
|
b0db6d1dfa | ||
|
|
783033acb9 | ||
|
|
4366f517c1 | ||
|
|
1e9a0981a5 | ||
|
|
43953d6f16 | ||
|
|
7bb8c0e749 | ||
|
|
150176debc | ||
|
|
fa6c877a81 | ||
|
|
9ee8758aac | ||
|
|
d084e9ab9d | ||
|
|
ec0ac26fc3 | ||
|
|
51fb25c689 | ||
|
|
81cfeede9a | ||
|
|
4bff24b71c | ||
|
|
503b62b73d | ||
|
|
aaab3c98a1 | ||
|
|
b64f25b36d | ||
|
|
1e209b00a8 | ||
|
|
6ec2ea0530 | ||
|
|
d5a6b13dae | ||
|
|
41eb9f1499 | ||
|
|
5c9b93fc34 | ||
|
|
fbc57831cc | ||
|
|
34ca7b0ab9 | ||
|
|
7d6e4db4e8 | ||
|
|
32355274ec | ||
|
|
50ca4cbec1 | ||
|
|
2dbb4372d8 | ||
|
|
4bf38452a9 | ||
|
|
fc7c2950e7 | ||
|
|
30248528ad | ||
|
|
ecf58ab8a8 | ||
|
|
10c7ac64ba | ||
|
|
7ee81e856b | ||
|
|
8328172827 | ||
|
|
29909b223d | ||
|
|
dbf597957d | ||
|
|
4d2b406dc5 | ||
|
|
774195ff74 | ||
|
|
255590cd45 | ||
|
|
d15641948c | ||
|
|
6cbcc8537c | ||
|
|
5ec4eaf052 | ||
|
|
08a9b5ff7f | ||
|
|
501af5213e | ||
|
|
d48f931d77 | ||
|
|
99b18a2189 | ||
|
|
9ece20157f | ||
|
|
985cd9d52f | ||
|
|
576c731d87 | ||
|
|
efe26a729e | ||
|
|
3dcfef1e2c | ||
|
|
c0e86f837e | ||
|
|
ae65e74ed0 | ||
|
|
2ba012bb30 |
No files matched your search
+81
-1
@@ -8,10 +8,91 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--refresh-ad-hoc-provisioning-profile` flag to refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials in non-interactive mode. ([#3716](https://github.com/expo/eas-cli/pull/3716) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-build-job] Add optional `refreshAdHocProvisioningProfile` field to iOS build jobs. ([#3717](https://github.com/expo/eas-cli/pull/3717) by [@sswrk](https://github.com/sswrk))
|
||||
- [build-tools] Pass `refreshAdHocProvisioningProfile` through `eas build:internal` for git-based integration builds. ([#3770](https://github.com/expo/eas-cli/pull/3770) by [@sswrk](https://github.com/sswrk))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [19.0.8](https://github.com/expo/eas-cli/releases/tag/v19.0.8) - 2026-05-21
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] `eas go` now pre-selects the SDK version from the current project's `app.json` or `app.config.js` when available. ([#3776](https://github.com/expo/eas-cli/pull/3776) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [19.0.7](https://github.com/expo/eas-cli/releases/tag/v19.0.7) - 2026-05-21
|
||||
|
||||
## [19.0.6](https://github.com/expo/eas-cli/releases/tag/v19.0.6) - 2026-05-21
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [build-tools] Update the minimum Expo version required for iOS precompiled modules to 55.0.26. ([#3771](https://github.com/expo/eas-cli/pull/3771) by [@chrfalch](https://github.com/chrfalch))
|
||||
|
||||
## [19.0.5](https://github.com/expo/eas-cli/releases/tag/v19.0.5) - 2026-05-20
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Improve Sentry diagnostics for managed iOS entitlements config fallback. ([#3763](https://github.com/expo/eas-cli/pull/3763) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [19.0.4](https://github.com/expo/eas-cli/releases/tag/v19.0.4) - 2026-05-20
|
||||
|
||||
## [19.0.1](https://github.com/expo/eas-cli/releases/tag/v19.0.1) - 2026-05-19
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Disable dotenv loading when resolving managed iOS entitlements. ([#3752](https://github.com/expo/eas-cli/pull/3752) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] Restore the bundled config fallback for managed iOS entitlements introspection. ([#3753](https://github.com/expo/eas-cli/pull/3753) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [19.0.0](https://github.com/expo/eas-cli/releases/tag/v19.0.0) - 2026-05-19
|
||||
|
||||
### 🛠 Breaking changes
|
||||
|
||||
- [eas-cli] Make browser-based authentication the default for `eas login`. Use `--no-browser` for CLI-based login. ([#3746](https://github.com/expo/eas-cli/pull/3746) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Allow command `observe:routes` to filter results to specific route names. ([#3744](https://github.com/expo/eas-cli/pull/3744) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Check that `expo` is resolvable before assuming it's installed ([#3748](https://github.com/expo/eas-cli/pull/3748) by [@kitten](https://github.com/kitten))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [worker] Report build phase durations stats. ([#3732](https://github.com/expo/eas-cli/pull/3732) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [18.13.1](https://github.com/expo/eas-cli/releases/tag/v18.13.1) - 2026-05-18
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Send the installed `expo` package version in build metadata. ([#3725](https://github.com/expo/eas-cli/pull/3725) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Prevent `npx` invocations that can be unreliable and fail when retrieving entitlements or project configs ([#3282](https://github.com/expo/eas-cli/pull/3282) by [@kitten](https://github.com/kitten))
|
||||
|
||||
## [18.13.0](https://github.com/expo/eas-cli/releases/tag/v18.13.0) - 2026-05-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] New command `observe:routes` for navigation metrics. ([#3730](https://github.com/expo/eas-cli/pull/3730) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Refactor observe commands to extract common flag definitions. ([#3731](https://github.com/expo/eas-cli/pull/3731) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
## [18.12.3](https://github.com/expo/eas-cli/releases/tag/v18.12.3) - 2026-05-13
|
||||
|
||||
## [18.12.2](https://github.com/expo/eas-cli/releases/tag/v18.12.2) - 2026-05-12
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix `eas go` to use server-side project setup and support multiple Expo Go SDK versions. ([#3657](https://github.com/expo/eas-cli/pull/3657) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [ci] Audit GitHub Actions workflows: harden `pull_request_target` usage, pin all external actions to commit SHAs, and add Dependabot to refresh them. ([#3718](https://github.com/expo/eas-cli/pull/3718) by [@brentvatne](https://github.com/brentvatne))
|
||||
|
||||
## [18.12.1](https://github.com/expo/eas-cli/releases/tag/v18.12.1) - 2026-05-12
|
||||
@@ -24,7 +105,6 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix `eas go` to use server-side project setup and support multiple Expo Go SDK versions. ([#3657](https://github.com/expo/eas-cli/pull/3657) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Update the minimum Expo version required for iOS precompiled modules. ([#3677](https://github.com/expo/eas-cli/pull/3677) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
- [eas-cli] recommend that users read the docs after connecting Convex ([#3683](https://github.com/expo/eas-cli/pull/3683) by [@fiberjw](https://github.com/fiberjw))
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "18.12.2",
|
||||
"version": "19.1.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.12.1",
|
||||
"version": "19.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -37,19 +37,20 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.12.0",
|
||||
"@expo/downloader": "19.0.0",
|
||||
"@expo/eas-build-job": "19.1.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/logger": "19.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": "18.12.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@expo/steps": "19.1.0",
|
||||
"@expo/template-file": "19.0.0",
|
||||
"@expo/turtle-spawn": "19.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@google-cloud/storage": "^7.11.2",
|
||||
"@sentry/node": "7.77.0",
|
||||
"@urql/core": "^6.0.1",
|
||||
"bplist-parser": "0.3.2",
|
||||
"fast-glob": "^3.3.2",
|
||||
|
||||
@@ -1,14 +1,36 @@
|
||||
import { BuildTrigger, Job, Metadata } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildPhase,
|
||||
BuildPhaseResult,
|
||||
BuildTrigger,
|
||||
Job,
|
||||
Metadata,
|
||||
Platform,
|
||||
} from '@expo/eas-build-job';
|
||||
import { randomUUID } from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { createMockLogger } from './utils/logger';
|
||||
import { BuildContext } from '../context';
|
||||
import { Datadog } from '../datadog';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../datadog', () => ({
|
||||
Datadog: {
|
||||
distribution: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const datadogDistributionMock = jest.mocked(Datadog.distribution);
|
||||
|
||||
describe('BuildContext', () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
jest.clearAllMocks();
|
||||
(fs.readdir as unknown as jest.Mock).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('should merge secrets', async () => {
|
||||
const robotAccessToken = randomUUID();
|
||||
await vol.promises.mkdir('/workingdir/eas-environment-secrets/', { recursive: true });
|
||||
@@ -116,4 +138,68 @@ describe('BuildContext', () => {
|
||||
foo: 'bar',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits build phase duration metrics for successful build phases', async () => {
|
||||
const ctx = createTestBuildContext({ platform: Platform.IOS });
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {});
|
||||
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
{
|
||||
build_phase: 'run_fastlane',
|
||||
platform: Platform.IOS,
|
||||
result: BuildPhaseResult.SUCCESS,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('emits build phase duration metrics for failed build phases', async () => {
|
||||
const ctx = createTestBuildContext({ platform: Platform.ANDROID });
|
||||
|
||||
await expect(
|
||||
ctx.runBuildPhase(BuildPhase.INSTALL_DEPENDENCIES, async () => {
|
||||
throw new Error('boom');
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
{
|
||||
build_phase: 'install_dependencies',
|
||||
platform: Platform.ANDROID,
|
||||
result: BuildPhaseResult.FAIL,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not emit build phase duration metrics for non-platform jobs', async () => {
|
||||
const ctx = createTestBuildContext({});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.PREPARE_PROJECT, async () => {});
|
||||
|
||||
expect(datadogDistributionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createTestBuildContext({ platform }: { platform?: Platform }): BuildContext {
|
||||
vol.mkdirSync('/workingdir/env', { recursive: true });
|
||||
|
||||
return new BuildContext(
|
||||
{
|
||||
...(platform ? { platform } : {}),
|
||||
builderEnvironment: { env: {} },
|
||||
} as Job,
|
||||
{
|
||||
env: {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
workingdir: '/workingdir',
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
uploadArtifact: jest.fn(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
import { Datadog } from '../datadog';
|
||||
import { Sentry } from '../sentry';
|
||||
import { turtleFetch } from '../utils/turtleFetch';
|
||||
|
||||
jest.mock('../sentry', () => ({
|
||||
Sentry: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../utils/turtleFetch', () => ({
|
||||
turtleFetch: jest.fn(),
|
||||
}));
|
||||
|
||||
const turtleFetchMock = jest.mocked(turtleFetch);
|
||||
const sentryCaptureMock = jest.mocked(Sentry.capture);
|
||||
|
||||
describe('Datadog singleton', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Datadog.setup(null);
|
||||
});
|
||||
|
||||
it('POSTs distribution metrics to the turtle build metrics endpoint', () => {
|
||||
turtleFetchMock.mockResolvedValueOnce({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1234, {
|
||||
build_phase: 'install_dependencies',
|
||||
platform: 'ios',
|
||||
result: 'success',
|
||||
});
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/turtle-builds/build-id/metrics',
|
||||
'POST',
|
||||
{
|
||||
json: {
|
||||
metrics: [
|
||||
{
|
||||
name: 'eas.build.phase_duration',
|
||||
type: 'distribution',
|
||||
value: 1234,
|
||||
tags: {
|
||||
build_phase: 'install_dependencies',
|
||||
platform: 'ios',
|
||||
result: 'success',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: { Authorization: 'Bearer token-abc' },
|
||||
retries: 2,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op when setup is null', () => {
|
||||
Datadog.setup(null);
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
|
||||
expect(turtleFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows upload failures', async () => {
|
||||
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
await flushPromises();
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalled();
|
||||
expect(sentryCaptureMock).toHaveBeenCalledWith(
|
||||
'Failed to report turtle build metric',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: {
|
||||
metrics: [{ name: 'eas.build.phase_duration', type: 'distribution', value: 1, tags: {} }],
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the latest setup options', () => {
|
||||
turtleFetchMock.mockResolvedValue({} as Response);
|
||||
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'first-build-id',
|
||||
robotAccessToken: 'first-token',
|
||||
});
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'second-build-id',
|
||||
robotAccessToken: 'second-token',
|
||||
});
|
||||
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/turtle-builds/second-build-id/metrics',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer second-token' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('flushes pending metric uploads', async () => {
|
||||
let resolveUpload!: (response: Response) => void;
|
||||
turtleFetchMock.mockReturnValueOnce(
|
||||
new Promise<Response>(resolve => {
|
||||
resolveUpload = resolve;
|
||||
})
|
||||
);
|
||||
Datadog.setup({
|
||||
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
|
||||
turtleBuildId: 'build-id',
|
||||
robotAccessToken: 'token-abc',
|
||||
});
|
||||
|
||||
let flushed = false;
|
||||
Datadog.distribution('eas.build.phase_duration', 1);
|
||||
const flushPromise = Datadog.flushAsync().then(() => {
|
||||
flushed = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(flushed).toBe(false);
|
||||
|
||||
resolveUpload({} as Response);
|
||||
await flushPromise;
|
||||
|
||||
expect(flushed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import * as sentryNode from '@sentry/node';
|
||||
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
jest.mock('@sentry/node');
|
||||
|
||||
describe('Sentry singleton', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(sentryNode.init).mockReset();
|
||||
jest.mocked(sentryNode.flush).mockReset();
|
||||
jest.mocked(sentryNode.captureException).mockReset();
|
||||
jest.mocked(sentryNode.captureMessage).mockReset();
|
||||
jest.mocked(sentryNode.withScope).mockReset();
|
||||
});
|
||||
|
||||
describe('setup', () => {
|
||||
it('initializes the SDK with dsn + environment when dsn is non-null', () => {
|
||||
Sentry.setup({ dsn: 'https://[email protected]/1', environment: 'production' });
|
||||
expect(sentryNode.init).toHaveBeenCalledWith({
|
||||
dsn: 'https://[email protected]/1',
|
||||
environment: 'production',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes default tags into the SDK init as initialScope', () => {
|
||||
Sentry.setup({
|
||||
dsn: 'https://[email protected]/1',
|
||||
environment: 'production',
|
||||
tags: { service: 'worker:darwin' },
|
||||
});
|
||||
expect(sentryNode.init).toHaveBeenCalledWith({
|
||||
dsn: 'https://[email protected]/1',
|
||||
environment: 'production',
|
||||
initialScope: { tags: { service: 'worker:darwin' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('skips SDK init when dsn is null', () => {
|
||||
Sentry.setup({ dsn: null, environment: 'development' });
|
||||
expect(sentryNode.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the latest initialScope tags when setup is called more than once', () => {
|
||||
Sentry.setup({
|
||||
dsn: 'https://[email protected]/1',
|
||||
environment: 'test',
|
||||
tags: { service: 'first' },
|
||||
});
|
||||
Sentry.setup({
|
||||
dsn: 'https://[email protected]/1',
|
||||
environment: 'test',
|
||||
tags: { service: 'second' },
|
||||
});
|
||||
|
||||
expect(sentryNode.init).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ initialScope: { tags: { service: 'second' } } })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capture', () => {
|
||||
it('routes (msg) to captureMessage with msg only', () => {
|
||||
const scope = mockWithScope();
|
||||
|
||||
Sentry.capture('plain message');
|
||||
|
||||
expect(sentryNode.captureMessage).toHaveBeenCalledWith('plain message');
|
||||
expect(sentryNode.captureException).not.toHaveBeenCalled();
|
||||
expect(scope.setExtra).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes (msg, options) and sets per-call tags/extras/level on the scope', () => {
|
||||
const scope = mockWithScope();
|
||||
|
||||
Sentry.capture('something happened', {
|
||||
tags: { phase: 'running-xclogparser' },
|
||||
extras: { buildId: 'abc' },
|
||||
level: 'warning',
|
||||
});
|
||||
|
||||
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'running-xclogparser' });
|
||||
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
|
||||
expect(scope.setLevel).toHaveBeenCalledWith('warning');
|
||||
expect(sentryNode.captureMessage).toHaveBeenCalledWith('something happened');
|
||||
expect(sentryNode.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes (msg, err) to captureException and sets msg as `message` extra when distinct from err.message', () => {
|
||||
const scope = mockWithScope();
|
||||
const err = new Error('boom');
|
||||
|
||||
Sentry.capture('something went wrong', err);
|
||||
|
||||
expect(scope.setExtra).toHaveBeenCalledWith('message', 'something went wrong');
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
|
||||
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes (msg, err, options) to captureException with per-call tags/extras', () => {
|
||||
const scope = mockWithScope();
|
||||
const err = new Error('spawn ENOENT');
|
||||
|
||||
Sentry.capture('xclogparser failed', err, {
|
||||
tags: { phase: 'running-xclogparser' },
|
||||
extras: { buildId: 'abc' },
|
||||
});
|
||||
|
||||
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'running-xclogparser' });
|
||||
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
|
||||
expect(scope.setExtra).toHaveBeenCalledWith('message', 'xclogparser failed');
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
|
||||
});
|
||||
|
||||
it('does not set the `message` extra when msg equals err.message', () => {
|
||||
const scope = mockWithScope();
|
||||
const err = new Error('build failed');
|
||||
|
||||
Sentry.capture('build failed', err);
|
||||
|
||||
expect(scope.setExtra).not.toHaveBeenCalledWith('message', expect.anything());
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
|
||||
});
|
||||
|
||||
it('routes (err) alone to captureException with no scope mutations', () => {
|
||||
const scope = mockWithScope();
|
||||
const err = new Error('boom');
|
||||
|
||||
Sentry.capture(err);
|
||||
|
||||
expect(scope.setTags).not.toHaveBeenCalled();
|
||||
expect(scope.setExtras).not.toHaveBeenCalled();
|
||||
expect(scope.setLevel).not.toHaveBeenCalled();
|
||||
expect(scope.setExtra).not.toHaveBeenCalled();
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
|
||||
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats (msg, null) as a no-err call (parity with 3-arg null behavior)', () => {
|
||||
const scope = mockWithScope();
|
||||
|
||||
Sentry.capture('forwarded null', null as unknown as Error);
|
||||
|
||||
expect(scope.setExtra).not.toHaveBeenCalled();
|
||||
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded null');
|
||||
expect(sentryNode.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats (msg, null, options) as a no-err call', () => {
|
||||
const scope = mockWithScope();
|
||||
|
||||
Sentry.capture('forwarded null with opts', null as unknown as Error, {
|
||||
tags: { phase: 'cleanup' },
|
||||
});
|
||||
|
||||
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'cleanup' });
|
||||
expect(scope.setExtra).not.toHaveBeenCalled();
|
||||
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded null with opts');
|
||||
expect(sentryNode.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats (msg, undefined, options) as a no-err call (forwarding `Error | undefined` typed vars)', () => {
|
||||
const scope = mockWithScope();
|
||||
|
||||
Sentry.capture('forwarded with no err', undefined, {
|
||||
tags: { phase: 'install-dependencies' },
|
||||
extras: { buildId: 'abc' },
|
||||
});
|
||||
|
||||
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
|
||||
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
|
||||
expect(scope.setExtra).not.toHaveBeenCalled();
|
||||
expect(sentryNode.captureMessage).toHaveBeenCalledWith('forwarded with no err');
|
||||
expect(sentryNode.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('coerces 2-arg primitive thrown value to Error (e.g. `throw "boom"`)', () => {
|
||||
mockWithScope();
|
||||
|
||||
Sentry.capture('caught a string', 'boom' as unknown as Error);
|
||||
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
const coerced = jest.mocked(sentryNode.captureException).mock.calls[0][0] as Error;
|
||||
expect(coerced.message).toBe('boom');
|
||||
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('coerces non-Error middle arg to Error when caller passes 3 args (e.g. `catch (err: any)`)', () => {
|
||||
const scope = mockWithScope();
|
||||
|
||||
Sentry.capture('something failed', 'raw string thrown' as unknown as Error, {
|
||||
tags: { phase: 'install-dependencies' },
|
||||
extras: { buildId: 'abc' },
|
||||
});
|
||||
|
||||
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
|
||||
expect(scope.setExtras).toHaveBeenCalledWith({ buildId: 'abc' });
|
||||
expect(scope.setExtra).toHaveBeenCalledWith('message', 'something failed');
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
const coerced = jest.mocked(sentryNode.captureException).mock.calls[0][0] as Error;
|
||||
expect(coerced.message).toBe('raw string thrown');
|
||||
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes (err, options) to captureException with no message extra', () => {
|
||||
const scope = mockWithScope();
|
||||
const err = new Error('boom');
|
||||
|
||||
Sentry.capture(err, { tags: { phase: 'install-dependencies' }, level: 'warning' });
|
||||
|
||||
expect(scope.setTags).toHaveBeenCalledWith({ phase: 'install-dependencies' });
|
||||
expect(scope.setLevel).toHaveBeenCalledWith('warning');
|
||||
expect(scope.setExtra).not.toHaveBeenCalled();
|
||||
expect(sentryNode.captureException).toHaveBeenCalledWith(err);
|
||||
expect(sentryNode.captureMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('flush', () => {
|
||||
it('forwards the timeout to the SDK and resolves with its result', async () => {
|
||||
jest.mocked(sentryNode.flush).mockResolvedValue(true);
|
||||
|
||||
const result = await Sentry.flush(3000);
|
||||
|
||||
expect(sentryNode.flush).toHaveBeenCalledWith(3000);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults the timeout to 2000ms when not specified', async () => {
|
||||
jest.mocked(sentryNode.flush).mockResolvedValue(true);
|
||||
|
||||
await Sentry.flush();
|
||||
|
||||
expect(sentryNode.flush).toHaveBeenCalledWith(2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mockWithScope(): {
|
||||
setTags: jest.Mock;
|
||||
setExtras: jest.Mock;
|
||||
setExtra: jest.Mock;
|
||||
setLevel: jest.Mock;
|
||||
} {
|
||||
const scope = {
|
||||
setTags: jest.fn(),
|
||||
setExtras: jest.fn(),
|
||||
setExtra: jest.fn(),
|
||||
setLevel: jest.fn(),
|
||||
};
|
||||
jest.mocked(sentryNode.withScope).mockImplementation((cb: any) => {
|
||||
cb(scope as unknown as sentryNode.Scope);
|
||||
return undefined as any;
|
||||
});
|
||||
return scope;
|
||||
}
|
||||
@@ -32,18 +32,27 @@ export async function runGradleCommand(
|
||||
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
|
||||
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? '--info' : '';
|
||||
|
||||
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
|
||||
cwd: androidDir,
|
||||
logger,
|
||||
lineTransformer: (line?: string) => {
|
||||
if (!line || /^\.+$/.exec(line)) {
|
||||
return null;
|
||||
} else {
|
||||
return line;
|
||||
}
|
||||
},
|
||||
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx), LC_ALL: 'C.UTF-8' },
|
||||
});
|
||||
const spawnPromise = spawn(
|
||||
'bash',
|
||||
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
|
||||
{
|
||||
cwd: androidDir,
|
||||
logger,
|
||||
lineTransformer: (line?: string) => {
|
||||
if (!line || /^\.+$/.exec(line)) {
|
||||
return null;
|
||||
} else {
|
||||
return line;
|
||||
}
|
||||
},
|
||||
env: {
|
||||
...ctx.env,
|
||||
...extraEnv,
|
||||
...resolveVersionOverridesEnvs(ctx),
|
||||
LC_ALL: 'C.UTF-8',
|
||||
},
|
||||
}
|
||||
);
|
||||
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
|
||||
adjustOOMScore(spawnPromise, logger);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
export interface GradleProfileTask {
|
||||
path: string;
|
||||
durationMs: number;
|
||||
result: string;
|
||||
}
|
||||
|
||||
export async function parseGradleProfile(androidDir: string): Promise<GradleProfileTask[]> {
|
||||
const profileDir = path.join(androidDir, 'build', 'reports', 'profile');
|
||||
// Gradle profile may not exist for all builds (e.g. custom, repack
|
||||
// jobs). We should short circuit and not unnecessarily log.
|
||||
if (!(await fs.pathExists(profileDir))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await fs.readdir(profileDir);
|
||||
const htmlFile = files
|
||||
.filter(f => f.startsWith('profile-') && f.endsWith('.html'))
|
||||
.sort()
|
||||
.pop();
|
||||
|
||||
if (!htmlFile) {
|
||||
throw new Error(`No Gradle profile HTML found in ${profileDir}`);
|
||||
}
|
||||
|
||||
const html = await fs.readFile(path.join(profileDir, htmlFile), 'utf8');
|
||||
|
||||
// Locate the <h2>Task Execution</h2> heading and extract its <table>
|
||||
const headingMatch = html.match(/<h2[^>]*>\s*Task Execution\s*<\/h2>/i);
|
||||
if (!headingMatch || headingMatch.index === undefined) {
|
||||
throw new Error('Could not find Task Execution section in Gradle profile');
|
||||
}
|
||||
|
||||
const sectionStart = headingMatch.index;
|
||||
const tableStart = html.indexOf('<table', sectionStart);
|
||||
const tableEnd = html.indexOf('</table>', tableStart);
|
||||
if (tableStart === -1 || tableEnd === -1) {
|
||||
throw new Error('Could not find task execution table in Gradle profile');
|
||||
}
|
||||
|
||||
const tableHtml = html.slice(tableStart, tableEnd + '</table>'.length);
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: true,
|
||||
isArray: name => name === 'tr' || name === 'td',
|
||||
trimValues: true,
|
||||
});
|
||||
|
||||
const parsed = parser.parse(tableHtml);
|
||||
const rows: any[] = parsed?.table?.tbody?.tr ?? parsed?.table?.tr ?? [];
|
||||
|
||||
const tasks: GradleProfileTask[] = [];
|
||||
for (const row of rows) {
|
||||
const cells: unknown[] = row?.td;
|
||||
if (!cells || cells.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const taskPath = String(cells[0] ?? '').trim();
|
||||
const durationStr = String(cells[1] ?? '').trim();
|
||||
const result = String(cells[2] ?? '').trim() || 'executed';
|
||||
|
||||
if (!taskPath || !durationStr) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const durationMs = parseDurationToMs(durationStr);
|
||||
tasks.push({ path: taskPath, durationMs, result: result.toLowerCase() });
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function parseDurationToMs(duration: string): number {
|
||||
// Gradle profile durations can be like "1.234s", "0.045s", "12.5s"
|
||||
const secondsMatch = duration.match(/^([\d.]+)s$/);
|
||||
if (secondsMatch) {
|
||||
return Math.round(parseFloat(secondsMatch[1]) * 1000);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function formatSeconds(ms: number): string {
|
||||
const s = ms / 1000;
|
||||
if (s < 0.1) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
return `${s.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export function formatGradleProfileReport(tasks: GradleProfileTask[]): string {
|
||||
// Filter out tasks under 1 second
|
||||
const significantTasks = tasks.filter(t => t.durationMs >= 1000);
|
||||
|
||||
// Separate module totals from individual tasks
|
||||
const moduleTotals = significantTasks.filter(t => t.result === '(total)');
|
||||
const individualTasks = significantTasks.filter(t => t.result !== '(total)');
|
||||
|
||||
// Group individual tasks by their module prefix
|
||||
const moduleChildren = new Map<string, GradleProfileTask[]>();
|
||||
const orphanTasks: GradleProfileTask[] = [];
|
||||
|
||||
for (const task of individualTasks) {
|
||||
const parent = moduleTotals.find(m => task.path.startsWith(m.path + ':'));
|
||||
if (parent) {
|
||||
const children = moduleChildren.get(parent.path) ?? [];
|
||||
children.push(task);
|
||||
moduleChildren.set(parent.path, children);
|
||||
} else {
|
||||
orphanTasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort module totals by duration, sort children within each module
|
||||
const sortedModules = [...moduleTotals].sort((a, b) => b.durationMs - a.durationMs);
|
||||
for (const children of moduleChildren.values()) {
|
||||
children.sort((a, b) => b.durationMs - a.durationMs);
|
||||
}
|
||||
orphanTasks.sort((a, b) => b.durationMs - a.durationMs);
|
||||
|
||||
// Build display rows: [displayName, task]
|
||||
const rows: { displayName: string; task: GradleProfileTask }[] = [];
|
||||
|
||||
for (const mod of sortedModules) {
|
||||
rows.push({ displayName: mod.path, task: mod });
|
||||
const children = moduleChildren.get(mod.path) ?? [];
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const isLast = i === children.length - 1;
|
||||
const prefix = isLast ? ' └─ ' : ' ├─ ';
|
||||
const shortName = children[i].path.slice(mod.path.length + 1);
|
||||
rows.push({ displayName: prefix + shortName, task: children[i] });
|
||||
}
|
||||
}
|
||||
|
||||
for (const task of orphanTasks) {
|
||||
rows.push({ displayName: task.path, task });
|
||||
}
|
||||
|
||||
// Compute totals from individual tasks only (avoid double-counting)
|
||||
const totalMs = individualTasks.reduce((sum, t) => sum + t.durationMs, 0);
|
||||
const maxMs = totalMs || 1;
|
||||
|
||||
const nameWidth = Math.max(4, ...rows.map(r => r.displayName.length)) + 2;
|
||||
const barMaxWidth = 20;
|
||||
|
||||
const header =
|
||||
'┌─' +
|
||||
'─'.repeat(nameWidth) +
|
||||
'─┬────────────┬──────────┬────────────┬─' +
|
||||
'─'.repeat(barMaxWidth) +
|
||||
'─┐';
|
||||
const divider =
|
||||
'├─' +
|
||||
'─'.repeat(nameWidth) +
|
||||
'─┼────────────┼──────────┼────────────┼─' +
|
||||
'─'.repeat(barMaxWidth) +
|
||||
'─┤';
|
||||
const footer =
|
||||
'└─' +
|
||||
'─'.repeat(nameWidth) +
|
||||
'─┴────────────┴──────────┴────────────┴─' +
|
||||
'─'.repeat(barMaxWidth) +
|
||||
'─┘';
|
||||
|
||||
const taskCount = individualTasks.length;
|
||||
const cachedCount = individualTasks.filter(t => t.result !== 'executed').length;
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('Gradle Build — Task Execution Profile');
|
||||
const cachedSuffix = cachedCount > 0 ? ` (${cachedCount} cached/up-to-date)` : '';
|
||||
lines.push(`${taskCount} tasks${cachedSuffix}, total task time: ${formatSeconds(totalMs)}`);
|
||||
lines.push('% Time = share of total task execution time');
|
||||
lines.push('');
|
||||
lines.push(header);
|
||||
lines.push(
|
||||
'│ ' +
|
||||
'Task'.padEnd(nameWidth) +
|
||||
' │ ' +
|
||||
'Duration'.padStart(10) +
|
||||
' │ ' +
|
||||
'% Time'.padStart(8) +
|
||||
' │ ' +
|
||||
'Result'.padEnd(10) +
|
||||
' │ ' +
|
||||
' '.repeat(barMaxWidth) +
|
||||
' │'
|
||||
);
|
||||
lines.push(divider);
|
||||
|
||||
for (const row of rows) {
|
||||
const pct = totalMs === 0 ? 0 : (row.task.durationMs / totalMs) * 100;
|
||||
const barLength = Math.round((row.task.durationMs / maxMs) * barMaxWidth);
|
||||
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
|
||||
const result = row.task.result === '(total)' ? 'total' : row.task.result;
|
||||
|
||||
lines.push(
|
||||
'│ ' +
|
||||
row.displayName.padEnd(nameWidth) +
|
||||
' │ ' +
|
||||
formatSeconds(row.task.durationMs).padStart(10) +
|
||||
' │ ' +
|
||||
`${pct.toFixed(1)}%`.padStart(8) +
|
||||
' │ ' +
|
||||
result.padEnd(10) +
|
||||
' │ ' +
|
||||
bar +
|
||||
' │'
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(divider);
|
||||
lines.push(
|
||||
'│ ' +
|
||||
'TOTAL'.padEnd(nameWidth) +
|
||||
' │ ' +
|
||||
formatSeconds(totalMs).padStart(10) +
|
||||
' │ ' +
|
||||
'100.0%'.padStart(8) +
|
||||
' │ ' +
|
||||
' '.repeat(10) +
|
||||
' │ ' +
|
||||
' '.repeat(barMaxWidth) +
|
||||
' │'
|
||||
);
|
||||
lines.push(footer);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { vol } from 'memfs';
|
||||
import { createTestAndroidJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { restoreCredentials } from '../../android/credentials';
|
||||
import androidBuilder from '../android';
|
||||
import { runBuilderWithHooksAsync } from '../common';
|
||||
@@ -67,8 +68,11 @@ jest.mock('../../utils/prepareBuildExecutable', () => ({
|
||||
prepareExecutableAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const datadogDistributionMock = jest.spyOn(Datadog, 'distribution');
|
||||
|
||||
describe(androidBuilder, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vol.fromJSON(
|
||||
{
|
||||
'/workingdir/env/.gitkeep': '',
|
||||
@@ -138,7 +142,6 @@ describe(androidBuilder, () => {
|
||||
});
|
||||
|
||||
it('marks the configure Android version phase as warning for legacy eas-build.gradle', async () => {
|
||||
const reportBuildPhaseStats = jest.fn();
|
||||
const job: Android.Job = {
|
||||
...createTestAndroidJob(),
|
||||
secrets: {
|
||||
@@ -157,23 +160,23 @@ describe(androidBuilder, () => {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
reportBuildPhaseStats,
|
||||
});
|
||||
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
|
||||
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
|
||||
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the prepare credentials phase as warning for legacy eas-build.gradle', async () => {
|
||||
const reportBuildPhaseStats = jest.fn();
|
||||
const ctx = new BuildContext(createTestAndroidJob(), {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
@@ -182,23 +185,23 @@ describe(androidBuilder, () => {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
reportBuildPhaseStats,
|
||||
});
|
||||
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
|
||||
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
|
||||
|
||||
await androidBuilder(ctx);
|
||||
|
||||
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
|
||||
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('injects credentials and version config when both are configured', async () => {
|
||||
const reportBuildPhaseStats = jest.fn();
|
||||
const job: Android.Job = {
|
||||
...createTestAndroidJob(),
|
||||
version: {
|
||||
@@ -214,7 +217,6 @@ describe(androidBuilder, () => {
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
reportBuildPhaseStats,
|
||||
});
|
||||
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
|
||||
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
|
||||
@@ -234,15 +236,19 @@ describe(androidBuilder, () => {
|
||||
versionName: '1.2.3',
|
||||
}
|
||||
);
|
||||
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
|
||||
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
|
||||
expect(datadogDistributionMock).toHaveBeenCalledWith(
|
||||
'eas.build.phase_duration',
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
|
||||
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
|
||||
result: BuildPhaseResult.WARNING,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
resolveGradleCommand,
|
||||
runGradleCommand,
|
||||
} from '../android/gradle';
|
||||
import { parseGradleProfile, formatGradleProfileReport } from '../android/gradleProfile';
|
||||
import { Sentry } from '../sentry';
|
||||
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
|
||||
import { prebuildAsync } from '../common/prebuild';
|
||||
import { setupAsync } from '../common/setup';
|
||||
@@ -180,6 +182,20 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.GRADLE_BUILD_PROFILE, async () => {
|
||||
try {
|
||||
const androidDir = path.join(ctx.getReactNativeProjectDirectory(), 'android');
|
||||
const profileTasks = await parseGradleProfile(androidDir);
|
||||
if (profileTasks.length > 0) {
|
||||
const report = formatGradleProfileReport(profileTasks);
|
||||
ctx.logger.info(report);
|
||||
}
|
||||
} catch (err: any) {
|
||||
Sentry.capture('Failed to parse Gradle build profile', err);
|
||||
ctx.markBuildPhaseSkipped();
|
||||
}
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
|
||||
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
|
||||
import { installPods } from '../ios/pod';
|
||||
import { downloadApplicationArchiveAsync } from '../ios/resign';
|
||||
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
|
||||
import { Sentry } from '../sentry';
|
||||
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
@@ -173,17 +174,25 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.env.EXPERIMENTAL_EAS_XCACTIVITYLOG === '1') {
|
||||
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
|
||||
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
|
||||
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
|
||||
if (ctx.isLocal) {
|
||||
ctx.logger.info('Local builds skip build performance analysis.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
logger: ctx.logger,
|
||||
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
env: ctx.env,
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
Sentry.capture('Failed to parse xcactivitylog', err);
|
||||
ctx.markBuildPhaseSkipped();
|
||||
}
|
||||
});
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
|
||||
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BuildJob } from '@expo/eas-build-job';
|
||||
import { ArchiveSourceType, BuildJob, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
|
||||
@@ -15,7 +15,7 @@ jest.mock('../../utils/easCli', () => ({
|
||||
|
||||
describe('easBuildInternal', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
|
||||
cmd: 'npx',
|
||||
args: ['-y', 'eas-cli@latest'],
|
||||
@@ -69,4 +69,131 @@ describe('easBuildInternal', () => {
|
||||
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const internalJob = {
|
||||
platform: Platform.IOS,
|
||||
type: Workflow.GENERIC,
|
||||
triggeredBy: 'EAS_CLI',
|
||||
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
|
||||
projectRootDirectory: '.',
|
||||
secrets: {
|
||||
buildCredentials: {
|
||||
testapp: {
|
||||
distributionCertificate: {
|
||||
dataBase64: 'YmluYXJ5Y29udGVudDE=',
|
||||
password: 'distCertPassword',
|
||||
},
|
||||
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
|
||||
},
|
||||
},
|
||||
},
|
||||
initiatingUserId: 'user-id',
|
||||
appId: 'app-id',
|
||||
};
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue({
|
||||
stdout: Buffer.from(
|
||||
JSON.stringify({
|
||||
job: internalJob,
|
||||
metadata: {},
|
||||
})
|
||||
),
|
||||
stderr: Buffer.from(''),
|
||||
} as any);
|
||||
|
||||
const job = {
|
||||
platform: Platform.IOS,
|
||||
buildProfile: 'preview',
|
||||
refreshAdHocProvisioningProfile: true,
|
||||
appId: 'app-id',
|
||||
initiatingUserId: 'user-id',
|
||||
secrets: { robotAccessToken: 'token' },
|
||||
} as unknown as BuildJob;
|
||||
|
||||
await runEasBuildInternalAsync({
|
||||
job,
|
||||
logger,
|
||||
env: {},
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
expect.arrayContaining([
|
||||
'build:internal',
|
||||
'--platform',
|
||||
Platform.IOS,
|
||||
'--profile',
|
||||
'preview',
|
||||
'--refresh-ad-hoc-provisioning-profile',
|
||||
]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves refreshAdHocProvisioningProfile from the incoming job', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const internalJob = {
|
||||
platform: Platform.IOS,
|
||||
type: Workflow.GENERIC,
|
||||
triggeredBy: 'EAS_CLI',
|
||||
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
|
||||
projectRootDirectory: '.',
|
||||
secrets: {
|
||||
buildCredentials: {
|
||||
testapp: {
|
||||
distributionCertificate: {
|
||||
dataBase64: 'YmluYXJ5Y29udGVudDE=',
|
||||
password: 'distCertPassword',
|
||||
},
|
||||
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
|
||||
},
|
||||
},
|
||||
},
|
||||
initiatingUserId: 'user-id',
|
||||
appId: 'app-id',
|
||||
};
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue({
|
||||
stdout: Buffer.from(
|
||||
JSON.stringify({
|
||||
job: internalJob,
|
||||
metadata: {},
|
||||
})
|
||||
),
|
||||
stderr: Buffer.from(''),
|
||||
} as any);
|
||||
|
||||
const job = {
|
||||
platform: Platform.IOS,
|
||||
buildProfile: 'preview',
|
||||
refreshAdHocProvisioningProfile: true,
|
||||
appId: 'app-id',
|
||||
initiatingUserId: 'user-id',
|
||||
secrets: { robotAccessToken: 'token' },
|
||||
} as unknown as BuildJob;
|
||||
|
||||
const { newJob } = await runEasBuildInternalAsync({
|
||||
job,
|
||||
logger,
|
||||
env: {},
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
|
||||
expect((newJob as any).refreshAdHocProvisioningProfile).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildJob,
|
||||
Env,
|
||||
Ios,
|
||||
Metadata,
|
||||
Platform,
|
||||
sanitizeBuildJob,
|
||||
sanitizeMetadata,
|
||||
} from '@expo/eas-build-job';
|
||||
import { PipeMode, bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -42,6 +50,11 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
|
||||
autoSubmitArgs.push('--auto-submit');
|
||||
}
|
||||
|
||||
const refreshAdHocProvisioningProfileArgs = [];
|
||||
if (job.platform === Platform.IOS && job.refreshAdHocProvisioningProfile === true) {
|
||||
refreshAdHocProvisioningProfileArgs.push('--refresh-ad-hoc-provisioning-profile');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawn(
|
||||
cmd,
|
||||
@@ -53,6 +66,7 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
|
||||
'--profile',
|
||||
buildProfile,
|
||||
...autoSubmitArgs,
|
||||
...refreshAdHocProvisioningProfileArgs,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
@@ -138,6 +152,9 @@ function validateEasBuildInternalResult<TJob extends BuildJob>({
|
||||
// We want to retain values that we have set on the job.
|
||||
appId: oldJob.appId,
|
||||
initiatingUserId: oldJob.initiatingUserId,
|
||||
...(oldJob.platform === Platform.IOS && oldJob.refreshAdHocProvisioningProfile === true
|
||||
? { refreshAdHocProvisioningProfile: true }
|
||||
: null),
|
||||
}) as TJob;
|
||||
assert(newJob.platform === oldJob.platform, 'eas-cli returned a job for a wrong platform');
|
||||
const newMetadata = sanitizeMetadata(value.metadata);
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ExpoConfig } from '@expo/config';
|
||||
import {
|
||||
BuildPhase,
|
||||
BuildPhaseResult,
|
||||
BuildPhaseStats,
|
||||
Env,
|
||||
EnvironmentSecretType,
|
||||
GenericArtifactType,
|
||||
@@ -20,6 +19,7 @@ import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { resolveBuildPhaseErrorAsync } from './buildErrors/detectError';
|
||||
import { Datadog } from './datadog';
|
||||
import { readAppConfig } from './utils/appConfig';
|
||||
import { createTemporaryEnvironmentSecretFile } from './utils/environmentSecrets';
|
||||
import { PackageManager, resolvePackageManager } from './utils/packageManager';
|
||||
@@ -64,7 +64,6 @@ export interface BuildContextOptions {
|
||||
err?: Error,
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
|
||||
) => void;
|
||||
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
|
||||
skipNativeBuild?: boolean;
|
||||
metadata?: Metadata;
|
||||
expoApiV2BaseUrl?: string;
|
||||
@@ -97,7 +96,6 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
private buildPhaseSkipped = false;
|
||||
private buildPhaseHasWarnings = false;
|
||||
private _appConfig?: Promise<ExpoConfig>;
|
||||
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
|
||||
public readonly graphqlClient: Client;
|
||||
|
||||
constructor(job: TJob, options: BuildContextOptions) {
|
||||
@@ -113,7 +111,6 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
this._metadata = options.metadata;
|
||||
this.skipNativeBuild = options.skipNativeBuild;
|
||||
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
|
||||
this.reportBuildPhaseStats = options.reportBuildPhaseStats;
|
||||
|
||||
const environmentSecrets = this.getEnvironmentSecrets(job);
|
||||
this._env = {
|
||||
@@ -354,7 +351,13 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
}
|
||||
await this.collectAndUpdateEnvVariablesAsync();
|
||||
|
||||
this.reportBuildPhaseStats?.({ buildPhase: this.buildPhase, result, durationMs });
|
||||
if (this.job.platform) {
|
||||
Datadog.distribution('eas.build.phase_duration', durationMs, {
|
||||
build_phase: this.buildPhase.toLowerCase(),
|
||||
platform: this.job.platform,
|
||||
result,
|
||||
});
|
||||
}
|
||||
|
||||
if (!doNotMarkEnd) {
|
||||
this.logger.info(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Sentry } from './sentry';
|
||||
import { turtleFetch } from './utils/turtleFetch';
|
||||
|
||||
type DatadogSetupOptions = {
|
||||
expoApiV2BaseUrl: string;
|
||||
turtleBuildId: string;
|
||||
robotAccessToken: string;
|
||||
};
|
||||
|
||||
let setupOptions: DatadogSetupOptions | null = null;
|
||||
let pendingMetricUploads: Promise<unknown>[] = [];
|
||||
|
||||
export const Datadog = {
|
||||
setup(opts: DatadogSetupOptions | null): void {
|
||||
setupOptions = opts;
|
||||
},
|
||||
|
||||
distribution(name: string, value: number, tags: Record<string, string> = {}): void {
|
||||
if (!setupOptions) {
|
||||
return;
|
||||
}
|
||||
const { expoApiV2BaseUrl, turtleBuildId, robotAccessToken } = setupOptions;
|
||||
const metrics = [
|
||||
{
|
||||
name,
|
||||
type: 'distribution' as const,
|
||||
value,
|
||||
tags,
|
||||
},
|
||||
];
|
||||
|
||||
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, {
|
||||
extras: { metrics },
|
||||
});
|
||||
});
|
||||
|
||||
pendingMetricUploads.push(uploadPromise);
|
||||
},
|
||||
|
||||
async flushAsync(): Promise<void> {
|
||||
await Promise.allSettled(pendingMetricUploads);
|
||||
},
|
||||
};
|
||||
@@ -20,4 +20,10 @@ export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
|
||||
|
||||
export { Hook, runHookIfPresent } from './utils/hooks';
|
||||
|
||||
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
|
||||
export type { GradleProfileTask } from './android/gradleProfile';
|
||||
|
||||
export * from './generic';
|
||||
|
||||
export { Datadog } from './datadog';
|
||||
export { Sentry } from './sentry';
|
||||
@@ -9,6 +9,7 @@ exports[`fastfile createFastfileForResigningBuild should create Fastfile with al
|
||||
"com.example.app" => "/path/to/profiles/main.mobileprovision",
|
||||
"com.example.app.widget" => "/path/to/profiles/widget.mobileprovision",
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "/Users/expo/Library/Keychains/login.keychain"
|
||||
)
|
||||
end
|
||||
@@ -22,6 +23,7 @@ exports[`fastfile createFastfileForResigningBuild should handle empty provisioni
|
||||
signing_identity: "iPhone Distribution",
|
||||
provisioning_profile: {
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "/tmp/keychain"
|
||||
)
|
||||
end
|
||||
@@ -39,6 +41,7 @@ exports[`fastfile createFastfileForResigningBuild should handle multiple provisi
|
||||
"com.example.app.extension" => "/profiles/extension.mobileprovision",
|
||||
"com.example.app.intents" => "/profiles/intents.mobileprovision",
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "/tmp/keychain"
|
||||
)
|
||||
end
|
||||
@@ -53,6 +56,7 @@ exports[`fastfile createFastfileForResigningBuild should handle paths with speci
|
||||
provisioning_profile: {
|
||||
"com.example.app" => "/path/with spaces/profile (1).mobileprovision",
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "/Users/expo/Library/Keychains/login keychain.keychain"
|
||||
)
|
||||
end
|
||||
@@ -67,6 +71,7 @@ exports[`fastfile createFastfileForResigningBuild should handle single provision
|
||||
provisioning_profile: {
|
||||
"com.example.app" => "/tmp/profile.mobileprovision",
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "/tmp/keychain"
|
||||
)
|
||||
end
|
||||
@@ -81,6 +86,7 @@ exports[`fastfile createFastfileForResigningBuild should produce valid Ruby synt
|
||||
provisioning_profile: {
|
||||
"com.example.app" => "/path/to/profile.mobileprovision",
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "/tmp/keychain"
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -8,6 +9,10 @@ import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { installPods } from '../pod';
|
||||
|
||||
jest.mock('@expo/eas-build-job', () => ({
|
||||
...jest.requireActual('@expo/eas-build-job'),
|
||||
getInstalledExpoPackageVersionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
@@ -61,14 +66,10 @@ function makeIosBuildContext({
|
||||
describe(installPods, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
(spawn as jest.Mock).mockImplementation(command => {
|
||||
if (command === 'node') {
|
||||
return Promise.resolve({
|
||||
stdout: Buffer.from(path.join(PROJECT_DIR, 'node_modules/expo/package.json')),
|
||||
});
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
jest.mocked(getInstalledExpoPackageVersionAsync).mockImplementation(async ({ projectDir }) => {
|
||||
return (await fs.readJson(path.join(projectDir, 'node_modules/expo/package.json'))).version;
|
||||
});
|
||||
(spawn as jest.Mock).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('adds precompiled modules env var for Expo versions well above the minimum', async () => {
|
||||
@@ -76,17 +77,12 @@ describe(installPods, () => {
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'node',
|
||||
['--print', "require.resolve('expo/package.json')"],
|
||||
expect.objectContaining({
|
||||
cwd: PROJECT_DIR,
|
||||
env: expect.objectContaining({
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
}),
|
||||
stdio: 'pipe',
|
||||
})
|
||||
);
|
||||
expect(getInstalledExpoPackageVersionAsync).toHaveBeenCalledWith({
|
||||
env: expect.objectContaining({
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
}),
|
||||
projectDir: PROJECT_DIR,
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'pod',
|
||||
['install'],
|
||||
@@ -130,14 +126,14 @@ describe(installPods, () => {
|
||||
});
|
||||
|
||||
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
|
||||
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
|
||||
const ctx = makeIosBuildContext({ expoVersion: '55.0.25' });
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/^Detected expo=55\.0\.17; not enabling precompiled modules use because precompiled modules require expo>=/
|
||||
/^Detected expo=55\.0\.25; not enabling precompiled modules use because precompiled modules require expo>=55\.0\.26\./
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -147,7 +143,7 @@ describe(installPods, () => {
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
|
||||
);
|
||||
@@ -158,7 +154,7 @@ describe(installPods, () => {
|
||||
|
||||
await installPods(ctx, {});
|
||||
|
||||
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
|
||||
expect(ctx.logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
|
||||
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Env, Ios } from '@expo/eas-build-job';
|
||||
import { Env, Ios, getInstalledExpoPackageVersionAsync } from '@expo/eas-build-job';
|
||||
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
|
||||
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.21';
|
||||
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.26';
|
||||
const PRECOMPILED_MODULES_BASE_URL =
|
||||
'https://storage.googleapis.com/eas-build-precompiled-modules/';
|
||||
|
||||
@@ -53,7 +52,10 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
|
||||
|
||||
let expoPackageVersion: string;
|
||||
try {
|
||||
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
|
||||
expoPackageVersion = await getInstalledExpoPackageVersionAsync({
|
||||
env: ctx.env,
|
||||
projectDir: ctx.getReactNativeProjectDirectory(),
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.info(
|
||||
{ err },
|
||||
@@ -93,18 +95,6 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
|
||||
return env;
|
||||
}
|
||||
|
||||
async function getInstalledExpoPackageVersionAsync<TJob extends Ios.Job>(
|
||||
ctx: BuildContext<TJob>
|
||||
): Promise<string> {
|
||||
const { stdout } = await spawn('node', ['--print', "require.resolve('expo/package.json')"], {
|
||||
cwd: ctx.getReactNativeProjectDirectory(),
|
||||
env: ctx.env,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
const expoPackageJsonPath = stdout.toString().trim();
|
||||
return (await fs.readJson(expoPackageJsonPath)).version;
|
||||
}
|
||||
|
||||
function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
|
||||
if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
|
||||
return PRECOMPILED_MODULES_BASE_URL;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as sentryNode from '@sentry/node';
|
||||
|
||||
type CaptureOptions = {
|
||||
tags?: Record<string, string>;
|
||||
extras?: Record<string, unknown>;
|
||||
level?: sentryNode.SeverityLevel;
|
||||
};
|
||||
|
||||
type SentryAPI = {
|
||||
setup(opts: { dsn: string | null; environment: string; tags?: Record<string, string> }): void;
|
||||
capture(msg: string, options?: CaptureOptions): void;
|
||||
capture(msg: string, err: Error | undefined, options?: CaptureOptions): void;
|
||||
capture(err: Error, options?: CaptureOptions): void;
|
||||
flush(timeoutMs?: number): Promise<boolean>;
|
||||
};
|
||||
|
||||
export const Sentry: SentryAPI = {
|
||||
setup({ dsn, environment, tags }) {
|
||||
if (dsn) {
|
||||
sentryNode.init({
|
||||
dsn,
|
||||
environment,
|
||||
...(tags ? { initialScope: { tags } } : {}),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
capture(arg1: string | Error, arg2?: Error | CaptureOptions, arg3?: CaptureOptions): void {
|
||||
let msg: string | undefined;
|
||||
let err: Error | undefined;
|
||||
let options: CaptureOptions = {};
|
||||
|
||||
if (arg1 instanceof Error) {
|
||||
err = arg1;
|
||||
options = (arg2 as CaptureOptions | undefined) ?? {};
|
||||
} else {
|
||||
msg = arg1;
|
||||
if (arg3 !== undefined) {
|
||||
// 3-arg form: arg2 unambiguously means err (null/undefined → no err)
|
||||
options = arg3;
|
||||
if (arg2 !== undefined && arg2 !== null) {
|
||||
err = arg2 instanceof Error ? arg2 : new Error(String(arg2));
|
||||
}
|
||||
} else if (arg2 instanceof Error) {
|
||||
err = arg2;
|
||||
} else if (arg2 !== undefined && arg2 !== null) {
|
||||
// 2-arg form: non-Error object → options; primitive → coerced err
|
||||
if (typeof arg2 === 'object') {
|
||||
options = arg2;
|
||||
} else {
|
||||
err = new Error(String(arg2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sentryNode.withScope(scope => {
|
||||
if (options.tags) {
|
||||
scope.setTags(options.tags);
|
||||
}
|
||||
if (options.extras) {
|
||||
scope.setExtras(options.extras);
|
||||
}
|
||||
if (options.level) {
|
||||
scope.setLevel(options.level);
|
||||
}
|
||||
|
||||
if (err) {
|
||||
if (msg && err.message !== msg) {
|
||||
scope.setExtra('message', msg);
|
||||
}
|
||||
sentryNode.captureException(err);
|
||||
} else if (msg) {
|
||||
sentryNode.captureMessage(msg);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
flush(timeoutMs: number = 2000): Promise<boolean> {
|
||||
return sentryNode.flush(timeoutMs);
|
||||
},
|
||||
};
|
||||
@@ -41,6 +41,7 @@ import { createSaveCacheFunction } from './functions/saveCache';
|
||||
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
|
||||
@@ -81,6 +82,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
|
||||
createStartArgentRemoteSessionBuildFunction(ctx),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
|
||||
@@ -45,6 +45,7 @@ export function parseXcactivitylogFunction(): BuildFunction {
|
||||
xclogparserVersion: version,
|
||||
logger: stepCtx.logger,
|
||||
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
env,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,25 +8,24 @@ import {
|
||||
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 { sleepAsync } from '../../utils/retry';
|
||||
import {
|
||||
DetachedProcessHandle,
|
||||
ensureBrewPackageInstalledAsync,
|
||||
ensureCloudflaredInstalledAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
spawnDetached,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForFileAsync,
|
||||
waitForMatchInOutputAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
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');
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
@@ -91,12 +90,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
|
||||
await waitForFileAsync({
|
||||
const { port: daemonPort, token: daemonToken } = await waitForFileAsync({
|
||||
filePath: DAEMON_JSON_PATH,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'agent-device daemon',
|
||||
description: 'agent-device daemon credentials',
|
||||
parse: parseDaemonInfo,
|
||||
});
|
||||
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
|
||||
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
|
||||
|
||||
logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort}.`);
|
||||
@@ -107,18 +106,35 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
});
|
||||
|
||||
logger.info('Waiting for a public tunnel URL.');
|
||||
const tunnelUrl = await waitForMatchInOutputAsync({
|
||||
const agentDeviceRemoteSessionUrl = await waitForMatchInOutputAsync({
|
||||
process: cloudflared,
|
||||
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'cloudflared tunnel',
|
||||
});
|
||||
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
|
||||
logger.info(`Tunnel is ready at ${agentDeviceRemoteSessionUrl}.`);
|
||||
|
||||
// serve-sim is iOS-only — only launch it (and report a webPreviewUrl)
|
||||
// on Darwin. Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
const { previewUrl } = await startServeSimWithTunnelAsync({
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
webPreviewUrl = previewUrl;
|
||||
logger.info(`Web preview URL: ${webPreviewUrl}`);
|
||||
}
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: { url: tunnelUrl, token: daemonToken },
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
...(webPreviewUrl ? { webPreviewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -130,62 +146,6 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureCloudflaredInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<string> {
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
|
||||
return 'cloudflared';
|
||||
}
|
||||
if (await isCommandAvailableAsync({ command: 'cloudflared', env })) {
|
||||
return 'cloudflared';
|
||||
}
|
||||
const cloudflaredArch = cloudflaredLinuxArchForNodeArch(os.arch());
|
||||
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflaredArch}`;
|
||||
logger.info(`Downloading cloudflared from ${downloadUrl} to ${CLOUDFLARED_LINUX_INSTALL_PATH}.`);
|
||||
await spawn('sudo', ['curl', '-fsSL', '-o', CLOUDFLARED_LINUX_INSTALL_PATH, downloadUrl], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
await spawn('sudo', ['chmod', '+x', CLOUDFLARED_LINUX_INSTALL_PATH], { env, logger });
|
||||
// Return the absolute install path so the tunnel command works even when
|
||||
// /usr/local/bin is not on the step's PATH.
|
||||
return CLOUDFLARED_LINUX_INSTALL_PATH;
|
||||
}
|
||||
|
||||
function cloudflaredLinuxArchForNodeArch(arch: string): 'amd64' | 'arm64' {
|
||||
if (arch === 'x64') {
|
||||
return 'amd64';
|
||||
}
|
||||
if (arch === 'arm64') {
|
||||
return 'arm64';
|
||||
}
|
||||
throw new SystemError(
|
||||
`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`
|
||||
);
|
||||
}
|
||||
|
||||
async function isCommandAvailableAsync({
|
||||
command,
|
||||
env,
|
||||
}: {
|
||||
command: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
await spawn('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneAgentDeviceAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
@@ -202,54 +162,7 @@ async function cloneAgentDeviceAsync({
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForMatchInOutputAsync({
|
||||
process,
|
||||
pattern,
|
||||
timeoutMs,
|
||||
description,
|
||||
}: {
|
||||
process: DetachedProcessHandle;
|
||||
pattern: RegExp;
|
||||
timeoutMs: number;
|
||||
description: string;
|
||||
}): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const match = pattern.exec(process.getOutput());
|
||||
if (match) {
|
||||
return match[1] ?? match[0];
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${description} to start. Last output:\n${process.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForFileAsync({
|
||||
filePath,
|
||||
timeoutMs,
|
||||
description,
|
||||
}: {
|
||||
filePath: string;
|
||||
timeoutMs: number;
|
||||
description: string;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
return;
|
||||
} catch {
|
||||
// not yet; keep polling
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(`Timed out waiting for ${description} to write ${filePath}.`);
|
||||
}
|
||||
|
||||
function readDaemonInfo(filePath: string): { port: number; token: string } {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
function parseDaemonInfo(raw: string): { port: number; token: string } {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
!parsed ||
|
||||
@@ -258,7 +171,7 @@ function readDaemonInfo(filePath: string): { port: number; token: string } {
|
||||
typeof (parsed as { token: unknown }).token !== 'string'
|
||||
) {
|
||||
throw new SystemError(
|
||||
`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`
|
||||
'Expected daemon credentials to contain { "httpPort": <number>, "token": "..." }.'
|
||||
);
|
||||
}
|
||||
const { httpPort, token } = parsed as { httpPort: number; token: string };
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
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 { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
ensureCloudflaredInstalledAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
spawnDetached,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForFileAsync,
|
||||
waitForMatchInOutputAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
|
||||
const ARGENT_STATE_FILE = path.join(os.homedir(), '.argent', 'tool-server.json');
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
const ArgentToolServerStateSchema = z.object({ port: z.number() });
|
||||
|
||||
export function createStartArgentRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_argent_remote_session',
|
||||
name: 'Start argent remote session',
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger, global }, { inputs, env }) => {
|
||||
// Fail fast before any expensive setup if the orchestrator-injected
|
||||
// DEVICE_RUN_SESSION_ID env var is missing — without it we cannot
|
||||
// report the remote config back to the API server.
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
logger.info(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
}
|
||||
|
||||
logger.info('Ensuring cloudflared is installed.');
|
||||
const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
// Stale state from a previous run would mask the new server's port.
|
||||
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} via bunx.`);
|
||||
// `argent mcp` is the public entry that triggers @argent/tools-client
|
||||
// to spawn the tool-server detached + unref'd, so the tool-server
|
||||
// outlives this MCP process. ARGENT_IDLE_TIMEOUT_MINUTES=0 disables the
|
||||
// 30-min idle shutdown that would otherwise tear the tunnel down.
|
||||
spawnDetached({
|
||||
command: 'bunx',
|
||||
args: [`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'mcp'],
|
||||
env: { ...env, ARGENT_IDLE_TIMEOUT_MINUTES: '0' },
|
||||
});
|
||||
|
||||
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
|
||||
const { port: toolServerPort } = await waitForFileAsync({
|
||||
filePath: ARGENT_STATE_FILE,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'argent tool-server state',
|
||||
parse: parseArgentToolServerState,
|
||||
});
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
|
||||
logger.info(`Starting cloudflared tunnel to http://localhost:${toolServerPort}.`);
|
||||
const cloudflared = spawnDetached({
|
||||
command: cloudflaredCommand,
|
||||
args: ['tunnel', '--url', `http://localhost:${toolServerPort}`],
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info('Waiting for a public tunnel URL.');
|
||||
const toolsUrl = await waitForMatchInOutputAsync({
|
||||
process: cloudflared,
|
||||
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'cloudflared tunnel',
|
||||
});
|
||||
logger.info(`Tunnel is ready at ${toolsUrl}.`);
|
||||
|
||||
// serve-sim is iOS-only — Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
const serveSim = await startServeSimWithTunnelAsync({
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
webPreviewUrl = serveSim.previewUrl;
|
||||
logger.info(`Web preview URL: ${webPreviewUrl}`);
|
||||
}
|
||||
|
||||
await uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl,
|
||||
...(webPreviewUrl ? { webPreviewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
|
||||
// Keep the turtle job alive so the tool-server and tunnel stay reachable
|
||||
// until stopDeviceRunSession cancels the run.
|
||||
await new Promise<never>(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgentToolServerState(raw: string): { port: number } {
|
||||
const result = ArgentToolServerStateSchema.safeParse(JSON.parse(raw));
|
||||
if (!result.success) {
|
||||
throw new SystemError(
|
||||
`Expected tool-server state to contain { "port": <number>, ... }: ${result.error.message}`
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import {
|
||||
DetachedProcessHandle,
|
||||
ensureBrewPackageInstalledAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
spawnDetached,
|
||||
ensureBrewPackageInstalledAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
const TRYCLOUDFLARE_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
||||
|
||||
export function createStartServeSimRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
@@ -36,16 +32,9 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
logger.info('Ensuring cloudflared is installed.');
|
||||
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
|
||||
|
||||
logger.info('Launching serve-sim with tunnel.');
|
||||
const serveSim = spawnDetached({
|
||||
command: 'npx',
|
||||
args: ['serve-sim-szdziedzic@latest', '--tunnel'],
|
||||
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
|
||||
const { previewUrl, streamUrl } = await waitForServeSimUrlsAsync({
|
||||
serveSim,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
});
|
||||
logger.info(`Preview URL: ${previewUrl}`);
|
||||
@@ -65,31 +54,3 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServeSimUrlsAsync({
|
||||
serveSim,
|
||||
timeoutMs,
|
||||
}: {
|
||||
serveSim: DetachedProcessHandle;
|
||||
timeoutMs: number;
|
||||
}): Promise<{ previewUrl: string; streamUrl: string }> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const output = serveSim.getOutput();
|
||||
const previewUrl = matchLabeledUrl(output, 'Tunnel');
|
||||
const streamUrl = matchLabeledUrl(output, 'Stream');
|
||||
if (previewUrl && streamUrl) {
|
||||
return { previewUrl, streamUrl };
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for serve-sim to report Tunnel and Stream URLs. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
function matchLabeledUrl(content: string, label: string): string | null {
|
||||
const labelPattern = new RegExp(`${label}:\\s*(${TRYCLOUDFLARE_URL_PATTERN.source})`);
|
||||
const match = labelPattern.exec(content);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
@@ -23,18 +23,27 @@ export async function runGradleCommand({
|
||||
|
||||
logger.info(`Running 'gradlew ${gradleCommand} ${verboseFlag}' in ${androidDir}`);
|
||||
await fs.chmod(path.join(androidDir, 'gradlew'), 0o755);
|
||||
const spawnPromise = spawn('bash', ['-c', `./gradlew ${gradleCommand} ${verboseFlag}`], {
|
||||
cwd: androidDir,
|
||||
logger,
|
||||
lineTransformer: (line?: string) => {
|
||||
if (!line || /^\.+$/.exec(line)) {
|
||||
return null;
|
||||
} else {
|
||||
return line;
|
||||
}
|
||||
},
|
||||
env: { ...env, ...extraEnv, LC_ALL: 'C.UTF-8' },
|
||||
});
|
||||
|
||||
const spawnPromise = spawn(
|
||||
'bash',
|
||||
['-c', `./gradlew ${gradleCommand} --profile ${verboseFlag}`],
|
||||
{
|
||||
cwd: androidDir,
|
||||
logger,
|
||||
lineTransformer: (line?: string) => {
|
||||
if (!line || /^\.+$/.exec(line)) {
|
||||
return null;
|
||||
} else {
|
||||
return line;
|
||||
}
|
||||
},
|
||||
env: {
|
||||
...env,
|
||||
...extraEnv,
|
||||
LC_ALL: 'C.UTF-8',
|
||||
},
|
||||
}
|
||||
);
|
||||
if (env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
|
||||
adjustOOMScore(spawnPromise, logger);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../../../sentry';
|
||||
import {
|
||||
buildTargetMetrics,
|
||||
isCompileStep,
|
||||
collectTopLevelCompileSteps,
|
||||
formatReport,
|
||||
isCompileStep,
|
||||
parseAndReportXcactivitylog,
|
||||
} from '../xcactivitylog';
|
||||
|
||||
@@ -17,6 +18,13 @@ jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../sentry', () => ({
|
||||
Sentry: {
|
||||
setup: jest.fn(),
|
||||
capture: jest.fn(),
|
||||
flush: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Minimal fixture: two modules with compile steps
|
||||
const FIXTURE_TWO_MODULES = {
|
||||
@@ -187,6 +195,8 @@ const FIXTURE_OVERLAP_WITH_GAP = {
|
||||
const mockedDownloadFile = jest.mocked(downloadFile);
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
const TEST_ENV = { PATH: '/custom/bin:/usr/bin' };
|
||||
|
||||
function mockFilesystem({
|
||||
tempDir = '/tmp/xclogparser-123',
|
||||
jsonOutput = JSON.stringify(FIXTURE_TWO_MODULES),
|
||||
@@ -209,38 +219,45 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
beforeEach(() => {
|
||||
mockedDownloadFile.mockReset();
|
||||
mockedSpawn.mockReset();
|
||||
jest.mocked(Sentry.capture).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('downloads xclogparser from GCS and logs only the final report on success', async () => {
|
||||
it('downloads xclogparser from GCS and logs resolution + final report when no binary is preinstalled', async () => {
|
||||
const logger = createMockLogger();
|
||||
const { tempDir, reportPath } = mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(1, 'xclogparser', ['version'], {
|
||||
stdio: 'pipe',
|
||||
env: TEST_ENV,
|
||||
});
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.47.zip',
|
||||
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
2,
|
||||
'unzip',
|
||||
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'), '-d', tempDir],
|
||||
{ stdio: 'pipe' }
|
||||
{ stdio: 'pipe', env: TEST_ENV }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
3,
|
||||
path.join(tempDir, 'xclogparser'),
|
||||
[
|
||||
'parse',
|
||||
@@ -250,18 +267,22 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
'/tmp/workspace',
|
||||
'--reporter',
|
||||
'json',
|
||||
'--omit_warnings',
|
||||
'--omit_notes',
|
||||
'--trunc_large_issues',
|
||||
'--output',
|
||||
reportPath,
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
{ stdio: 'pipe', env: TEST_ENV }
|
||||
);
|
||||
expect(fs.readFile).toHaveBeenCalledWith(reportPath, 'utf8');
|
||||
expect(logger.info).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Xcode Build — Compile Metrics by Module')
|
||||
);
|
||||
expect(logger.debug).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tries the proxy URL first and falls back to the direct GCS URL', async () => {
|
||||
@@ -271,6 +292,7 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
mockedDownloadFile
|
||||
.mockRejectedValueOnce(new Error('proxy failed'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
@@ -278,6 +300,7 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
proxyBaseUrl: 'https://cache.example.com',
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
@@ -305,6 +328,7 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('xclogparser not found'))
|
||||
.mockRejectedValueOnce(new Error('invalid zip'))
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any);
|
||||
@@ -314,6 +338,7 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
proxyBaseUrl: 'https://cache.example.com',
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
@@ -338,11 +363,12 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips reporting and logs debug when the JSON output is invalid', async () => {
|
||||
it('reports phase "parsing_xclogparser_output" when the JSON output is invalid', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem({ jsonOutput: '{not valid json' });
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
@@ -350,18 +376,20 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to analyze build performance; continuing without a report'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "parsing_xclogparser_output"',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ tags: { phase: 'parsing_xclogparser_output' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('skips reporting and logs debug when the JSON output does not match the expected schema', async () => {
|
||||
it('reports phase "parsing_xclogparser_output" when the JSON output does not match the expected schema', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem({
|
||||
jsonOutput: JSON.stringify({
|
||||
@@ -371,6 +399,7 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
});
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
@@ -378,15 +407,109 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to analyze build performance; continuing without a report'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "parsing_xclogparser_output"',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({ tags: { phase: 'parsing_xclogparser_output' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('reports phase "running_xclogparser" when the xclogparser invocation fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('xclogparser not found')) // version detect
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
|
||||
.mockRejectedValueOnce(new Error('spawn xclogparser ENOENT')); // xclogparser run
|
||||
|
||||
await expect(
|
||||
parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "running_xclogparser"',
|
||||
expect.objectContaining({ message: 'spawn xclogparser ENOENT' }),
|
||||
expect.objectContaining({ tags: { phase: 'running_xclogparser' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces spawn error stderr/stdout/status/signal as Sentry extras', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
const spawnError: any = new Error('xclogparser exited with non-zero code: 1');
|
||||
spawnError.stderr = 'XCLogParser fatal: missing xcactivitylog\n';
|
||||
spawnError.stdout = 'some stdout';
|
||||
spawnError.status = 1;
|
||||
spawnError.signal = null;
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('xclogparser not found')) // version detect
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
|
||||
.mockRejectedValueOnce(spawnError); // xclogparser run
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "running_xclogparser"',
|
||||
expect.any(Error),
|
||||
{
|
||||
tags: { phase: 'running_xclogparser' },
|
||||
extras: {
|
||||
exitStatus: 1,
|
||||
signal: null,
|
||||
stderr: 'XCLogParser fatal: missing xcactivitylog\n',
|
||||
stdout: 'some stdout',
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('reports phase "downloading_xclogparser" when the direct download fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockRejectedValue(new Error('network unreachable'));
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith('Build performance analysis skipped.');
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Build performance analysis failed during "downloading_xclogparser"',
|
||||
expect.objectContaining({ message: 'network unreachable' }),
|
||||
expect.objectContaining({ tags: { phase: 'downloading_xclogparser' } })
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows cleanup failures without emitting additional logs', async () => {
|
||||
@@ -397,6 +520,7 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
});
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
@@ -404,13 +528,174 @@ describe('parseAndReportXcactivitylog', () => {
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledTimes(1);
|
||||
expect(logger.debug).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('xclogparser version resolution', () => {
|
||||
it('uses preinstalled binary when no version is requested', async () => {
|
||||
const logger = createMockLogger();
|
||||
const { reportPath } = mockFilesystem();
|
||||
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.50\n', stderr: '' } as any) // version detect
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith('unzip', expect.any(Array), expect.any(Object));
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xclogparser',
|
||||
[
|
||||
'parse',
|
||||
'--derived_data',
|
||||
'/tmp/derived-data',
|
||||
'--workspace',
|
||||
'/tmp/workspace',
|
||||
'--reporter',
|
||||
'json',
|
||||
'--omit_warnings',
|
||||
'--omit_notes',
|
||||
'--trunc_large_issues',
|
||||
'--output',
|
||||
reportPath,
|
||||
],
|
||||
{ stdio: 'pipe', env: TEST_ENV }
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Using preinstalled xclogparser v0.2.50.');
|
||||
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('downloaded'));
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('downloads default version when no version requested and no preinstall found', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
|
||||
expect.any(String),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
|
||||
});
|
||||
|
||||
it('uses preinstalled binary when requested version matches', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.50\n', stderr: '' } as any) // version detect
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
xclogparserVersion: 'v0.2.50',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith('Using preinstalled xclogparser v0.2.50.');
|
||||
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('downloaded'));
|
||||
});
|
||||
|
||||
it('downloads requested version when it does not match the preinstalled binary', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({ stdout: 'XCLogParser 0.2.48\n', stderr: '' } as any) // version detect
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any); // xclogparser parse
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
xclogparserVersion: 'v0.2.50',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.50.zip'),
|
||||
expect.any(String),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.50.');
|
||||
});
|
||||
|
||||
it('downloads requested version when no preinstall is found', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockRejectedValueOnce(new Error('xclogparser not found'));
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
xclogparserVersion: 'v0.2.50',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.50.zip'),
|
||||
expect.any(String),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.50.');
|
||||
});
|
||||
|
||||
it('falls back to download when the version output cannot be parsed', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn
|
||||
.mockResolvedValueOnce({ stdout: 'something unexpected', stderr: '' } as any) // version detect
|
||||
.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
env: TEST_ENV,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('XCLogParser-macOS-x86-64-arm64-v0.2.47.zip'),
|
||||
expect.any(String),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('Using downloaded xclogparser v0.2.47.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCompileStep', () => {
|
||||
|
||||
@@ -1,58 +1,96 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
|
||||
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.47';
|
||||
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
|
||||
const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
|
||||
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
|
||||
|
||||
/**
|
||||
* Download xclogparser, parse xcactivitylog from derived data, and log a
|
||||
* compile metrics report. Never throws — all errors are logged at debug level.
|
||||
*
|
||||
* Can be called from both the step-based flow (BuildFunction) and the
|
||||
* traditional builder flow (runBuildPhase).
|
||||
* Never throws — best-effort observability that does not affect build status.
|
||||
* Failures route to Sentry via `Sentry.capture` for engineering triage;
|
||||
* users see only a generic skip message.
|
||||
*/
|
||||
export async function parseAndReportXcactivitylog({
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
xclogparserVersion = DEFAULT_XCLOGPARSER_VERSION,
|
||||
xclogparserVersion,
|
||||
logger,
|
||||
proxyBaseUrl,
|
||||
env,
|
||||
}: {
|
||||
derivedDataPath: string;
|
||||
workspacePath: string;
|
||||
xclogparserVersion?: string;
|
||||
logger: bunyan;
|
||||
proxyBaseUrl?: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<void> {
|
||||
let tempDir: string | undefined;
|
||||
let phase = 'creating_temp_directory';
|
||||
try {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xclogparser-'));
|
||||
const xclogparserPath = await downloadXclogparser(
|
||||
tempDir,
|
||||
xclogparserVersion,
|
||||
logger,
|
||||
proxyBaseUrl
|
||||
);
|
||||
|
||||
phase = 'resolving_xclogparser';
|
||||
const preinstalledVersion = await detectPreinstalledXclogparserVersion(env);
|
||||
let binaryPath: string;
|
||||
let resolvedVersion: string;
|
||||
if (
|
||||
preinstalledVersion &&
|
||||
(!xclogparserVersion || preinstalledVersion === xclogparserVersion)
|
||||
) {
|
||||
binaryPath = 'xclogparser';
|
||||
resolvedVersion = preinstalledVersion;
|
||||
logger.info(`Using preinstalled xclogparser ${resolvedVersion}.`);
|
||||
} else {
|
||||
phase = 'downloading_xclogparser';
|
||||
resolvedVersion = xclogparserVersion ?? DEFAULT_XCLOGPARSER_VERSION;
|
||||
binaryPath = await downloadXclogparser({
|
||||
tempDir,
|
||||
version: resolvedVersion,
|
||||
logger,
|
||||
proxyBaseUrl,
|
||||
env,
|
||||
});
|
||||
logger.info(`Using downloaded xclogparser ${resolvedVersion}.`);
|
||||
}
|
||||
|
||||
phase = 'running_xclogparser';
|
||||
const jsonOutputPath = await runXclogparser({
|
||||
binaryPath: xclogparserPath,
|
||||
binaryPath,
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
outputDir: tempDir,
|
||||
env,
|
||||
});
|
||||
|
||||
phase = 'parsing_xclogparser_output';
|
||||
const data = XcactivitylogDataSchemaZ.parse(
|
||||
JSON.parse(await fs.readFile(jsonOutputPath, 'utf8'))
|
||||
);
|
||||
|
||||
logger.info(formatReport(data));
|
||||
} catch (err: unknown) {
|
||||
logger.debug({ err }, 'Failed to analyze build performance; continuing without a report');
|
||||
} catch (err: any) {
|
||||
logger.info('Build performance analysis skipped.');
|
||||
const msg = `Build performance analysis failed during "${phase}"`;
|
||||
Sentry.capture(msg, err, {
|
||||
tags: { phase },
|
||||
extras: {
|
||||
exitStatus: err?.status,
|
||||
signal: err?.signal,
|
||||
stderr: err?.stderr?.slice(-4000),
|
||||
stdout: err?.stdout?.slice(-4000),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
|
||||
@@ -60,12 +98,28 @@ export async function parseAndReportXcactivitylog({
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadXclogparser(
|
||||
tempDir: string,
|
||||
version: string,
|
||||
logger: bunyan,
|
||||
proxyBaseUrl?: string
|
||||
): Promise<string> {
|
||||
async function detectPreinstalledXclogparserVersion(env: BuildStepEnv): Promise<string | null> {
|
||||
const result = await asyncResult(spawn('xclogparser', ['version'], { stdio: 'pipe', env }));
|
||||
if (!result.ok) {
|
||||
return null;
|
||||
}
|
||||
const match = result.value.stdout.match(/(\d+\.\d+\.\d+)/);
|
||||
return match ? `v${match[1]}` : null;
|
||||
}
|
||||
|
||||
async function downloadXclogparser({
|
||||
tempDir,
|
||||
version,
|
||||
logger,
|
||||
proxyBaseUrl,
|
||||
env,
|
||||
}: {
|
||||
tempDir: string;
|
||||
version: string;
|
||||
logger: bunyan;
|
||||
proxyBaseUrl?: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const zipName = getXclogparserZipName(version);
|
||||
const zipPath = path.join(tempDir, zipName);
|
||||
const directUrl = `${XCLOGPARSER_DOWNLOAD_URL}/${zipName}`;
|
||||
@@ -73,7 +127,7 @@ async function downloadXclogparser(
|
||||
|
||||
if (proxiedUrl) {
|
||||
const proxiedDownloadResult = await asyncResult(
|
||||
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl })
|
||||
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl, env })
|
||||
);
|
||||
if (!proxiedDownloadResult.ok) {
|
||||
logger.debug(
|
||||
@@ -86,31 +140,35 @@ async function downloadXclogparser(
|
||||
}
|
||||
}
|
||||
|
||||
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl });
|
||||
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl, env });
|
||||
}
|
||||
|
||||
async function downloadAndUnpackXclogparser({
|
||||
tempDir,
|
||||
zipPath,
|
||||
sourceUrl,
|
||||
env,
|
||||
}: {
|
||||
tempDir: string;
|
||||
zipPath: string;
|
||||
sourceUrl: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
await downloadFile(sourceUrl, zipPath, { retry: 3, timeout: XCLOGPARSER_DOWNLOAD_TIMEOUT_MS });
|
||||
|
||||
return await unpackXclogparser({ tempDir, zipPath });
|
||||
return await unpackXclogparser({ tempDir, zipPath, env });
|
||||
}
|
||||
|
||||
async function unpackXclogparser({
|
||||
tempDir,
|
||||
zipPath,
|
||||
env,
|
||||
}: {
|
||||
tempDir: string;
|
||||
zipPath: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe' });
|
||||
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe', env });
|
||||
|
||||
const binaryPath = path.join(tempDir, 'xclogparser');
|
||||
await fs.chmod(binaryPath, 0o755);
|
||||
@@ -133,11 +191,13 @@ async function runXclogparser({
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
outputDir,
|
||||
env,
|
||||
}: {
|
||||
binaryPath: string;
|
||||
derivedDataPath: string;
|
||||
workspacePath: string;
|
||||
outputDir: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<string> {
|
||||
const outputPath = path.join(outputDir, XCLOGPARSER_OUTPUT_FILENAME);
|
||||
|
||||
@@ -151,10 +211,13 @@ async function runXclogparser({
|
||||
workspacePath,
|
||||
'--reporter',
|
||||
'json',
|
||||
'--omit_warnings',
|
||||
'--omit_notes',
|
||||
'--trunc_large_issues',
|
||||
'--output',
|
||||
outputPath,
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
{ stdio: 'pipe', env }
|
||||
);
|
||||
|
||||
return outputPath;
|
||||
@@ -410,7 +473,7 @@ export function formatReport(data: XcactivitylogData): string {
|
||||
' │ ' +
|
||||
'100.0%'.padStart(pctWidth) +
|
||||
' │ ' +
|
||||
'n/a'.padStart(wallWidth) +
|
||||
' '.repeat(wallWidth) +
|
||||
' │ ' +
|
||||
' '.repeat(barMaxWidth) +
|
||||
' │'
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
const TRYCLOUDFLARE_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
||||
const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
@@ -107,3 +113,159 @@ export function spawnDetached({
|
||||
|
||||
return { getOutput: () => output };
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync({
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
}): Promise<{ previewUrl: string; streamUrl: string }> {
|
||||
logger.info('Launching serve-sim with tunnel.');
|
||||
const serveSim = spawnDetached({
|
||||
command: 'npx',
|
||||
args: [
|
||||
'serve-sim-szdziedzic@latest',
|
||||
'--tunnel',
|
||||
'--tunnel-protocol',
|
||||
'quic',
|
||||
'--stream-max-dimension',
|
||||
'1280',
|
||||
'--stream-quality',
|
||||
'0.55',
|
||||
],
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const output = serveSim.getOutput();
|
||||
const previewUrl = matchLabeledUrl(output, 'Tunnel');
|
||||
const streamUrl = matchLabeledUrl(output, 'Stream');
|
||||
if (previewUrl && streamUrl) {
|
||||
return { previewUrl, streamUrl };
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for serve-sim to report Tunnel and Stream URLs. Last output:\n${serveSim.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
function matchLabeledUrl(content: string, label: string): string | null {
|
||||
const labelPattern = new RegExp(`${label}:\\s*(${TRYCLOUDFLARE_URL_PATTERN.source})`);
|
||||
const match = labelPattern.exec(content);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
export async function ensureCloudflaredInstalledAsync({
|
||||
runtimePlatform,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<string> {
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
|
||||
return 'cloudflared';
|
||||
}
|
||||
if (await isCommandAvailableAsync({ command: 'cloudflared', env })) {
|
||||
return 'cloudflared';
|
||||
}
|
||||
const cloudflaredArch = cloudflaredLinuxArchForNodeArch(os.arch());
|
||||
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflaredArch}`;
|
||||
logger.info(`Downloading cloudflared from ${downloadUrl} to ${CLOUDFLARED_LINUX_INSTALL_PATH}.`);
|
||||
await spawn('sudo', ['curl', '-fsSL', '-o', CLOUDFLARED_LINUX_INSTALL_PATH, downloadUrl], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
await spawn('sudo', ['chmod', '+x', CLOUDFLARED_LINUX_INSTALL_PATH], { env, logger });
|
||||
// Return the absolute install path so the tunnel command works even when
|
||||
// /usr/local/bin is not on the step's PATH.
|
||||
return CLOUDFLARED_LINUX_INSTALL_PATH;
|
||||
}
|
||||
|
||||
function cloudflaredLinuxArchForNodeArch(arch: string): 'amd64' | 'arm64' {
|
||||
if (arch === 'x64') {
|
||||
return 'amd64';
|
||||
}
|
||||
if (arch === 'arm64') {
|
||||
return 'arm64';
|
||||
}
|
||||
throw new SystemError(
|
||||
`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`
|
||||
);
|
||||
}
|
||||
|
||||
async function isCommandAvailableAsync({
|
||||
command,
|
||||
env,
|
||||
}: {
|
||||
command: string;
|
||||
env: BuildStepEnv;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
await spawn('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForMatchInOutputAsync({
|
||||
process,
|
||||
pattern,
|
||||
timeoutMs,
|
||||
description,
|
||||
}: {
|
||||
process: DetachedProcessHandle;
|
||||
pattern: RegExp;
|
||||
timeoutMs: number;
|
||||
description: string;
|
||||
}): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const match = pattern.exec(process.getOutput());
|
||||
if (match) {
|
||||
return match[1] ?? match[0];
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${description} to start. Last output:\n${process.getOutput() || '<empty>'}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForFileAsync<T>({
|
||||
filePath,
|
||||
timeoutMs,
|
||||
description,
|
||||
parse,
|
||||
}: {
|
||||
filePath: string;
|
||||
timeoutMs: number;
|
||||
description: string;
|
||||
parse: (raw: string) => T;
|
||||
}): Promise<T> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const raw = await fs.promises.readFile(filePath, 'utf8');
|
||||
return parse(raw);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
throw new SystemError(
|
||||
`Timed out waiting for ${description} to be ready at ${filePath}${
|
||||
lastError instanceof Error ? `: ${lastError.message}` : ''
|
||||
}.`
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export const FastfileResignTemplate = `lane :do_resign do
|
||||
provisioning_profile: {<% _.forEach(PROFILES, function(profile) { %>
|
||||
"<%- profile.BUNDLE_ID %>" => "<%- profile.PATH %>",<% }); %>
|
||||
},
|
||||
use_app_entitlements: true,
|
||||
keychain_path: "<%- KEYCHAIN_PATH %>"
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { findArtifacts } from '../artifacts';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('../../sentry', () => ({
|
||||
Sentry: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(findArtifacts, () => {
|
||||
beforeEach(async () => {
|
||||
vol.reset();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('with correct path', async () => {
|
||||
@@ -69,6 +76,44 @@ describe(findArtifacts, () => {
|
||||
});
|
||||
expect(loggerMock.error).toHaveBeenCalledTimes(0);
|
||||
expect(paths.length).toBe(2);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('with absolute glob pattern', async () => {
|
||||
await fs.mkdirp('/tmp');
|
||||
await fs.mkdirp('/tmp/maestro_xctestrunner_xcodebuild_output123');
|
||||
await fs.mkdirp('/tmp/maestro_xctestrunner_xcodebuild_output456');
|
||||
const loggerMock = {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
await expect(
|
||||
findArtifacts({
|
||||
rootDir: '/Users/expo/build',
|
||||
patternOrPath: '/tmp/maestro_xctestrunner_xcodebuild_output*',
|
||||
logger: loggerMock as any,
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'There are no files matching pattern "/tmp/maestro_xctestrunner_xcodebuild_output*"'
|
||||
);
|
||||
expect(loggerMock.error).toHaveBeenCalledTimes(0);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(expect.any(Error), {
|
||||
tags: {
|
||||
source: 'find-artifacts',
|
||||
reason: 'absolute_path',
|
||||
},
|
||||
extras: {
|
||||
rootDir: '/Users/expo/build',
|
||||
patternOrPath: '/tmp/maestro_xctestrunner_xcodebuild_output*',
|
||||
currentCount: 0,
|
||||
dryRunCount: 2,
|
||||
currentSample: [],
|
||||
dryRunSample: [
|
||||
'/tmp/maestro_xctestrunner_xcodebuild_output123',
|
||||
'/tmp/maestro_xctestrunner_xcodebuild_output456',
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('with missing file in empty directory', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from 'path';
|
||||
import promiseLimit from 'promise-limit';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { Sentry } from '../sentry';
|
||||
|
||||
export class FindArtifactsError extends Error {}
|
||||
|
||||
@@ -24,6 +25,11 @@ export async function findArtifacts({
|
||||
? [patternOrPath]
|
||||
: []
|
||||
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
|
||||
await maybeReportAbsoluteGlobDryRunMismatchAsync({
|
||||
rootDir,
|
||||
patternOrPath,
|
||||
files,
|
||||
});
|
||||
if (files.length === 0) {
|
||||
if (fg.isDynamicPattern(patternOrPath)) {
|
||||
throw new FindArtifactsError(`There are no files matching pattern "${patternOrPath}"`);
|
||||
@@ -48,6 +54,78 @@ export async function findArtifacts({
|
||||
});
|
||||
}
|
||||
|
||||
async function findArtifactsWithAbsoluteGlobSupportDryRunAsync(
|
||||
rootDir: string,
|
||||
patternOrPath: string
|
||||
): Promise<string[]> {
|
||||
return path.isAbsolute(patternOrPath) && !fg.isDynamicPattern(patternOrPath)
|
||||
? (await fs.pathExists(patternOrPath))
|
||||
? [patternOrPath]
|
||||
: []
|
||||
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
|
||||
}
|
||||
|
||||
async function maybeReportAbsoluteGlobDryRunMismatchAsync({
|
||||
rootDir,
|
||||
patternOrPath,
|
||||
files,
|
||||
}: {
|
||||
rootDir: string;
|
||||
patternOrPath: string;
|
||||
files: string[];
|
||||
}): Promise<void> {
|
||||
if (!path.isAbsolute(patternOrPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filesWithAbsoluteGlobSupport = await findArtifactsWithAbsoluteGlobSupportDryRunAsync(
|
||||
rootDir,
|
||||
patternOrPath
|
||||
);
|
||||
if (areArtifactListsEqual(files, filesWithAbsoluteGlobSupport)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Sentry.capture(new Error('findArtifacts output changed for an absolute path'), {
|
||||
tags: {
|
||||
source: 'find-artifacts',
|
||||
reason: 'absolute_path',
|
||||
},
|
||||
extras: {
|
||||
rootDir,
|
||||
patternOrPath,
|
||||
currentCount: files.length,
|
||||
dryRunCount: filesWithAbsoluteGlobSupport.length,
|
||||
currentSample: files.slice(0, 20),
|
||||
dryRunSample: filesWithAbsoluteGlobSupport.slice(0, 20),
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
Sentry.capture(err, {
|
||||
tags: {
|
||||
source: 'find-artifacts',
|
||||
reason: 'absolute_path_dry_run_failed',
|
||||
},
|
||||
extras: {
|
||||
rootDir,
|
||||
patternOrPath,
|
||||
currentCount: files.length,
|
||||
currentSample: files.slice(0, 20),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function areArtifactListsEqual(first: string[], second: string[]): boolean {
|
||||
if (first.length !== second.length) {
|
||||
return false;
|
||||
}
|
||||
const sortedFirst = [...first].sort();
|
||||
const sortedSecond = [...second].sort();
|
||||
return sortedFirst.every((artifactPath, index) => artifactPath === sortedSecond[index]);
|
||||
}
|
||||
|
||||
async function logMissingFileError(artifactPath: string, buildLogger: bunyan): Promise<void> {
|
||||
let currentPath = artifactPath;
|
||||
while (!(await fs.pathExists(currentPath))) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "18.12.0",
|
||||
"version": "19.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": "18.12.0"
|
||||
"@expo/steps": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "18.5.0",
|
||||
"version": "19.0.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "18.5.0",
|
||||
"version": "19.0.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "18.12.0",
|
||||
"version": "19.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,7 +25,9 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/logger": "19.0.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "19.0.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
|
||||
@@ -534,4 +534,47 @@ describe('Ios.JobSchema', () => {
|
||||
expect(value).toMatchObject(job);
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('accepts optional refreshAdHocProvisioningProfile', () => {
|
||||
const job = {
|
||||
secrets: {
|
||||
buildCredentials,
|
||||
},
|
||||
type: Workflow.GENERIC,
|
||||
platform: Platform.IOS,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.URL,
|
||||
url: 'http://localhost:3000',
|
||||
},
|
||||
projectRootDirectory: '.',
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
refreshAdHocProvisioningProfile: true,
|
||||
};
|
||||
|
||||
const { value, error } = Ios.JobSchema.validate(job, joiOptions);
|
||||
expect(value.refreshAdHocProvisioningProfile).toBe(true);
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
|
||||
test('does not require refreshAdHocProvisioningProfile', () => {
|
||||
const job = {
|
||||
secrets: {
|
||||
buildCredentials,
|
||||
},
|
||||
type: Workflow.GENERIC,
|
||||
platform: Platform.IOS,
|
||||
projectArchive: {
|
||||
type: ArchiveSourceType.URL,
|
||||
url: 'http://localhost:3000',
|
||||
},
|
||||
projectRootDirectory: '.',
|
||||
initiatingUserId: randomUUID(),
|
||||
appId: randomUUID(),
|
||||
};
|
||||
|
||||
const { value, error } = Ios.JobSchema.validate(job, joiOptions);
|
||||
expect(value.refreshAdHocProvisioningProfile).toBeUndefined();
|
||||
expect(error).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { errors } from '..';
|
||||
import { getInstalledExpoPackageVersionAsync } from '../projectPackage';
|
||||
|
||||
describe(getInstalledExpoPackageVersionAsync, () => {
|
||||
it('returns the installed expo package version resolved by project node', async () => {
|
||||
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
|
||||
try {
|
||||
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, 'node_modules/expo/package.json'),
|
||||
JSON.stringify({ version: '55.0.17' })
|
||||
);
|
||||
|
||||
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).resolves.toBe('55.0.17');
|
||||
} finally {
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when expo cannot be resolved from the project', async () => {
|
||||
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
|
||||
try {
|
||||
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toMatchObject({
|
||||
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
|
||||
});
|
||||
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toBeInstanceOf(
|
||||
errors.UserError
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when expo package.json cannot be read', async () => {
|
||||
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
|
||||
try {
|
||||
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, 'node_modules/expo/package.json'),
|
||||
JSON.stringify({ version: '55.0.17' })
|
||||
);
|
||||
jest.spyOn(fs, 'readFile').mockRejectedValueOnce(new Error('read failed'));
|
||||
|
||||
const promise = getInstalledExpoPackageVersionAsync({ projectDir });
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_READ_FAILED',
|
||||
});
|
||||
await expect(promise).rejects.toBeInstanceOf(errors.UserError);
|
||||
} finally {
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when the installed expo package version is not valid semver', async () => {
|
||||
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-build-job-package-test-'));
|
||||
try {
|
||||
await fs.mkdir(path.join(projectDir, 'node_modules/expo'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectDir, 'node_modules/expo/package.json'),
|
||||
JSON.stringify({ version: 'invalid-version' })
|
||||
);
|
||||
|
||||
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toMatchObject({
|
||||
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
|
||||
});
|
||||
await expect(getInstalledExpoPackageVersionAsync({ projectDir })).rejects.toBeInstanceOf(
|
||||
errors.UserError
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -181,12 +181,6 @@ export const CacheSchema = Joi.object({
|
||||
paths: Joi.array().items(Joi.string()).default([]),
|
||||
});
|
||||
|
||||
export interface BuildPhaseStats {
|
||||
buildPhase: BuildPhase;
|
||||
result: BuildPhaseResult;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
const GitHubContextZ = z.object({
|
||||
triggering_actor: z.string().optional(),
|
||||
event_name: z.enum(['push', 'pull_request', 'workflow_dispatch', 'schedule']),
|
||||
|
||||
@@ -5,7 +5,6 @@ export {
|
||||
ArchiveSource,
|
||||
ArchiveSourceSchemaZ,
|
||||
BuildMode,
|
||||
BuildPhaseStats,
|
||||
BuildTrigger,
|
||||
EasCliNpmTags,
|
||||
Env,
|
||||
@@ -27,6 +26,7 @@ export * from './context';
|
||||
export * from './generic';
|
||||
export * from './step';
|
||||
export * from './submission-config';
|
||||
export * from './projectPackage';
|
||||
|
||||
const version = require('../package.json').version;
|
||||
export { version };
|
||||
@@ -144,6 +144,8 @@ export interface Job {
|
||||
appId: string;
|
||||
|
||||
environment?: string;
|
||||
|
||||
refreshAdHocProvisioningProfile?: boolean;
|
||||
}
|
||||
|
||||
const SecretsSchema = Joi.object({
|
||||
@@ -229,6 +231,8 @@ export const JobSchema = Joi.object({
|
||||
|
||||
environment: Joi.string(),
|
||||
|
||||
refreshAdHocProvisioningProfile: Joi.boolean(),
|
||||
|
||||
workflowInterpolationContext: Joi.object().custom(workflowInterpolationContext =>
|
||||
StaticWorkflowInterpolationContextZ.optional().parse(workflowInterpolationContext)
|
||||
),
|
||||
|
||||
@@ -36,6 +36,7 @@ export enum BuildPhase {
|
||||
// ANDROID
|
||||
FIX_GRADLEW = 'FIX_GRADLEW',
|
||||
RUN_GRADLEW = 'RUN_GRADLEW',
|
||||
GRADLE_BUILD_PROFILE = 'GRADLE_BUILD_PROFILE',
|
||||
|
||||
// IOS
|
||||
INSTALL_PODS = 'INSTALL_PODS',
|
||||
@@ -104,6 +105,7 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
|
||||
// ANDROID
|
||||
[BuildPhase.FIX_GRADLEW]: 'Fix gradlew',
|
||||
[BuildPhase.RUN_GRADLEW]: 'Run gradlew',
|
||||
[BuildPhase.GRADLE_BUILD_PROFILE]: 'Gradle build profile',
|
||||
|
||||
// IOS
|
||||
[BuildPhase.INSTALL_PODS]: 'Install pods',
|
||||
@@ -169,6 +171,7 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
|
||||
// ANDROID
|
||||
[BuildPhase.FIX_GRADLEW]: 'fix-gradlew',
|
||||
[BuildPhase.RUN_GRADLEW]: 'run-gradlew',
|
||||
[BuildPhase.GRADLE_BUILD_PROFILE]: 'gradle-build-profile',
|
||||
|
||||
// IOS
|
||||
[BuildPhase.INSTALL_PODS]: 'install-pods',
|
||||
|
||||
@@ -49,6 +49,11 @@ export type Metadata = {
|
||||
*/
|
||||
sdkVersion?: string;
|
||||
|
||||
/**
|
||||
* Version of the expo package used in the project.
|
||||
*/
|
||||
expoPackageVersion?: string;
|
||||
|
||||
/**
|
||||
* Runtime version (for Expo Updates)
|
||||
*/
|
||||
@@ -180,6 +185,7 @@ export const MetadataSchema = Joi.object({
|
||||
distribution: Joi.string().valid('store', 'internal', 'simulator'),
|
||||
credentialsSource: Joi.string().valid('local', 'remote'),
|
||||
sdkVersion: Joi.string(),
|
||||
expoPackageVersion: Joi.string(),
|
||||
runtimeVersion: Joi.string(),
|
||||
fingerprintHash: Joi.string(),
|
||||
reactNativeVersion: Joi.string(),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import fs from 'fs/promises';
|
||||
import semver from 'semver';
|
||||
|
||||
import { type Env } from './common';
|
||||
import * as errors from './errors';
|
||||
|
||||
export async function getInstalledExpoPackageVersionAsync({
|
||||
env = process.env,
|
||||
projectDir,
|
||||
}: {
|
||||
env?: Env | NodeJS.ProcessEnv;
|
||||
projectDir: string;
|
||||
}): Promise<string> {
|
||||
const expoPackageJsonPathResult = await asyncResult(
|
||||
spawn('node', ['--print', "require.resolve('expo/package.json')"], {
|
||||
cwd: projectDir,
|
||||
env,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
);
|
||||
if (!expoPackageJsonPathResult.ok) {
|
||||
throw new errors.UserError(
|
||||
'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
|
||||
'Cannot resolve the installed expo package version because require.resolve("expo/package.json") failed.',
|
||||
{ cause: expoPackageJsonPathResult.reason }
|
||||
);
|
||||
}
|
||||
|
||||
const expoPackageJsonPath = expoPackageJsonPathResult.value.stdout.toString().trim();
|
||||
const expoPackageJsonResult = await asyncResult(readJsonAsync(expoPackageJsonPath));
|
||||
if (!expoPackageJsonResult.ok) {
|
||||
throw new errors.UserError(
|
||||
'EAS_BUILD_EXPO_PACKAGE_VERSION_READ_FAILED',
|
||||
'Cannot resolve the installed expo package version because expo/package.json could not be read.',
|
||||
{ cause: expoPackageJsonResult.reason }
|
||||
);
|
||||
}
|
||||
|
||||
const expoPackageVersion = expoPackageJsonResult.value.version;
|
||||
if (typeof expoPackageVersion !== 'string' || !semver.valid(expoPackageVersion)) {
|
||||
throw new errors.UserError(
|
||||
'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
|
||||
'Cannot resolve the installed expo package version because expo/package.json has an invalid version.'
|
||||
);
|
||||
}
|
||||
|
||||
return expoPackageVersion;
|
||||
}
|
||||
|
||||
async function readJsonAsync(filePath: string): Promise<any> {
|
||||
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
||||
}
|
||||
+108
-105
@@ -180,8 +180,8 @@ USAGE
|
||||
$ eas account:login [-s] [-b]
|
||||
|
||||
FLAGS
|
||||
-b, --browser Login with your browser
|
||||
-s, --sso Login with SSO
|
||||
-b, --[no-]browser Log in with your browser (default; use --no-browser for CLI-based login)
|
||||
-s, --sso Log in with SSO
|
||||
|
||||
DESCRIPTION
|
||||
log in with your Expo account
|
||||
@@ -190,7 +190,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -207,7 +207,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -229,7 +229,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -246,7 +246,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -260,7 +260,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -312,7 +312,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -333,7 +333,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -353,7 +353,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -373,7 +373,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -396,7 +396,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -406,7 +406,8 @@ start a build
|
||||
USAGE
|
||||
$ eas build [-p android|ios|all] [-e PROFILE_NAME] [--local] [--output <value>] [--wait] [--clear-cache]
|
||||
[-s | --auto-submit-with-profile PROFILE_NAME] [--what-to-test <value>] [-m <value>] [--build-logger-level
|
||||
trace|debug|info|warn|error|fatal] [--freeze-credentials] [--verbose-logs] [--json] [--non-interactive]
|
||||
trace|debug|info|warn|error|fatal] [--freeze-credentials] [--refresh-ad-hoc-provisioning-profile] [--verbose-logs]
|
||||
[--json] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. Defaults to "production" if
|
||||
@@ -425,6 +426,8 @@ FLAGS
|
||||
--local Run build locally [experimental]
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--output=<value> Output path for local build
|
||||
--refresh-ad-hoc-provisioning-profile Refresh managed ad-hoc provisioning profiles from App Store Connect
|
||||
before gathering build credentials
|
||||
--verbose-logs Use verbose logs for the build process
|
||||
--[no-]wait Wait for build(s) to complete
|
||||
--what-to-test=<value> Specify the "What to Test" information for the build in TestFlight
|
||||
@@ -434,7 +437,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -454,7 +457,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -472,7 +475,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -492,7 +495,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -517,7 +520,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -544,7 +547,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -582,7 +585,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -622,7 +625,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -652,7 +655,7 @@ DESCRIPTION
|
||||
re-sign a build archive
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -680,7 +683,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -732,7 +735,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -750,7 +753,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -768,7 +771,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -785,7 +788,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -806,7 +809,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -827,7 +830,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -849,7 +852,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:insights`
|
||||
|
||||
@@ -873,7 +876,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/channel/insights.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -893,7 +896,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -915,7 +918,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -937,7 +940,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -972,7 +975,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -995,7 +998,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -1016,7 +1019,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/v18.12.2/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -1033,7 +1036,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1051,7 +1054,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1080,7 +1083,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1105,7 +1108,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1126,7 +1129,7 @@ ALIASES
|
||||
$ eas worker:alias:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1147,7 +1150,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1184,7 +1187,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1204,7 +1207,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1225,7 +1228,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1246,7 +1249,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1260,7 +1263,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1274,7 +1277,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1306,7 +1309,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1332,7 +1335,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1354,7 +1357,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1382,7 +1385,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1410,7 +1413,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1433,7 +1436,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1455,7 +1458,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1489,7 +1492,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1533,7 +1536,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1564,7 +1567,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1643,7 +1646,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
|
||||
|
||||
## `eas integrations:asc:disconnect`
|
||||
|
||||
@@ -1662,7 +1665,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
|
||||
|
||||
## `eas integrations:asc:status`
|
||||
|
||||
@@ -1680,7 +1683,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
|
||||
|
||||
## `eas integrations:convex:connect`
|
||||
|
||||
@@ -1702,7 +1705,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
|
||||
|
||||
## `eas integrations:convex:dashboard`
|
||||
|
||||
@@ -1716,7 +1719,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
|
||||
|
||||
## `eas integrations:convex:project`
|
||||
|
||||
@@ -1730,7 +1733,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/integrations/convex/project.ts)_
|
||||
|
||||
## `eas integrations:convex:project:delete`
|
||||
|
||||
@@ -1748,7 +1751,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
|
||||
|
||||
## `eas integrations:convex:team`
|
||||
|
||||
@@ -1762,7 +1765,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/integrations/convex/team.ts)_
|
||||
|
||||
## `eas integrations:convex:team:delete [CONVEX_TEAM]`
|
||||
|
||||
@@ -1783,7 +1786,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
|
||||
|
||||
## `eas integrations:convex:team:invite [CONVEX_TEAM]`
|
||||
|
||||
@@ -1803,7 +1806,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
|
||||
|
||||
## `eas login`
|
||||
|
||||
@@ -1814,8 +1817,8 @@ USAGE
|
||||
$ eas login [-s] [-b]
|
||||
|
||||
FLAGS
|
||||
-b, --browser Login with your browser
|
||||
-s, --sso Login with SSO
|
||||
-b, --[no-]browser Log in with your browser (default; use --no-browser for CLI-based login)
|
||||
-s, --sso Log in with SSO
|
||||
|
||||
DESCRIPTION
|
||||
log in with your Expo account
|
||||
@@ -1855,7 +1858,7 @@ DESCRIPTION
|
||||
validate the local store configuration
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1873,7 +1876,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1891,7 +1894,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1943,7 +1946,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/v18.12.2/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1957,7 +1960,7 @@ DESCRIPTION
|
||||
information about the current project
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1980,7 +1983,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -2004,7 +2007,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -2022,7 +2025,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -2055,7 +2058,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.12.2/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -2098,7 +2101,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -2119,7 +2122,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -2140,7 +2143,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -2165,7 +2168,7 @@ 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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:insights GROUPID`
|
||||
|
||||
@@ -2192,7 +2195,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/update/insights.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -2219,7 +2222,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -2257,7 +2260,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2285,7 +2288,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2315,7 +2318,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2336,7 +2339,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2360,7 +2363,7 @@ DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2381,7 +2384,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/v18.12.2/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2403,7 +2406,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2423,7 +2426,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2442,7 +2445,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2465,7 +2468,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2482,7 +2485,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2603,7 +2606,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2623,7 +2626,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2647,7 +2650,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -2687,7 +2690,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2709,7 +2712,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2737,7 +2740,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2757,7 +2760,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2779,5 +2782,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/v18.12.2/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/v19.1.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
Generated
+1822
-2
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "18.12.2",
|
||||
"version": "19.1.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -47,11 +47,11 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "18.12.0",
|
||||
"@expo/eas-json": "18.12.0",
|
||||
"@expo/eas-build-job": "19.1.0",
|
||||
"@expo/eas-json": "19.1.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/logger": "19.0.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.12.0",
|
||||
"@expo/steps": "19.1.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -78,6 +78,7 @@
|
||||
"chalk": "4.1.2",
|
||||
"cli-progress": "3.12.0",
|
||||
"dateformat": "4.6.3",
|
||||
"debug": "4.4.3",
|
||||
"diff": "7.0.0",
|
||||
"dotenv": "16.3.1",
|
||||
"env-paths": "2.2.0",
|
||||
@@ -138,6 +139,7 @@
|
||||
"@tsconfig/node20": "20.1.8",
|
||||
"@types/cli-progress": "3.11.5",
|
||||
"@types/dateformat": "3.0.1",
|
||||
"@types/debug": "4.1.13",
|
||||
"@types/diff": "6.0.0",
|
||||
"@types/envinfo": "7.8.3",
|
||||
"@types/express": "4.17.15",
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { getConfigFilePaths } from '@expo/config';
|
||||
|
||||
import { getWorkflowRunUrl } from '../../build/utils/url';
|
||||
import Go from '../../commands/go';
|
||||
import { WorkflowRunStatus } from '../../graphql/generated';
|
||||
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
|
||||
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
|
||||
import Log from '../../log';
|
||||
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
|
||||
import { ensureActorHasPrimaryAccount } from '../../user/actions';
|
||||
import { detectProjectSdkVersionAsync } from '../../commands/go';
|
||||
import { mockTestCommand } from './utils';
|
||||
|
||||
jest.mock('@expo/config', () => ({
|
||||
...jest.requireActual('@expo/config'),
|
||||
getConfigFilePaths: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../project/expoConfig');
|
||||
jest.mock('../../log', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
log: jest.fn(),
|
||||
withTick: jest.fn(),
|
||||
newLine: jest.fn(),
|
||||
succeed: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
markFreshLine: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
learnMore: jest.fn().mockReturnValue(''),
|
||||
}));
|
||||
jest.mock('../../ora', () => ({
|
||||
ora: jest.fn().mockReturnValue({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
stop: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('fs-extra', () => ({
|
||||
ensureDir: jest.fn().mockResolvedValue(undefined),
|
||||
writeFile: jest.fn().mockResolvedValue(undefined),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../../user/actions');
|
||||
jest.mock('../../graphql/queries/WorkflowRunQuery');
|
||||
jest.mock('../../graphql/mutations/WorkflowRunMutation');
|
||||
jest.mock('../../project/uploadAccountScopedFileAsync');
|
||||
jest.mock('../../project/uploadAccountScopedProjectSourceAsync');
|
||||
jest.mock('../../build/utils/url');
|
||||
|
||||
const mockGetConfigFilePaths = jest.mocked(getConfigFilePaths);
|
||||
const mockGetPrivateExpoConfigAsync = jest.mocked(getPrivateExpoConfigAsync);
|
||||
|
||||
describe('detectProjectSdkVersionAsync', () => {
|
||||
it('returns undefined when no config file exists', async () => {
|
||||
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
|
||||
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns the sdkVersion from the project config', async () => {
|
||||
mockGetConfigFilePaths.mockReturnValue({
|
||||
staticConfigPath: '/project/app.json',
|
||||
dynamicConfigPath: null,
|
||||
});
|
||||
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
|
||||
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBe('55.0.0');
|
||||
});
|
||||
|
||||
it('returns undefined when reading the config throws', async () => {
|
||||
mockGetConfigFilePaths.mockReturnValue({
|
||||
staticConfigPath: '/project/app.json',
|
||||
dynamicConfigPath: null,
|
||||
});
|
||||
mockGetPrivateExpoConfigAsync.mockRejectedValue(new Error('config error'));
|
||||
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const mockAccount = { id: 'account-id', name: 'testuser' };
|
||||
const mockActor = {
|
||||
__typename: 'User' as const,
|
||||
id: 'user-id',
|
||||
username: 'testuser',
|
||||
primaryAccount: mockAccount,
|
||||
};
|
||||
|
||||
describe('Go command', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(ensureActorHasPrimaryAccount).mockReturnValue(mockAccount as any);
|
||||
jest.mocked(WorkflowRunQuery.expoGoRepackConfigurationAsync).mockResolvedValue({
|
||||
files: [],
|
||||
sdkVersion: '55.0.0',
|
||||
} as any);
|
||||
jest.mocked(WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync).mockResolvedValue({
|
||||
id: 'run-id',
|
||||
} as any);
|
||||
jest.mocked(uploadAccountScopedProjectSourceAsync).mockResolvedValue({
|
||||
projectArchiveBucketKey: 'archive-key',
|
||||
});
|
||||
jest
|
||||
.mocked(uploadAccountScopedFileAsync)
|
||||
.mockResolvedValue({ fileBucketKey: 'file-key' } as any);
|
||||
jest.mocked(getWorkflowRunUrl).mockReturnValue('https://expo.dev/run/123');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeCmd(argv: string[] = []) {
|
||||
const ctx = {
|
||||
loggedIn: { actor: mockActor as any, graphqlClient: {} as any },
|
||||
analytics: {} as any,
|
||||
};
|
||||
const cmd = mockTestCommand(Go, ['--bundle-id', 'com.test.go', ...argv], ctx);
|
||||
jest.spyOn(cmd as any, 'ensureEasProjectAsync').mockResolvedValue('project-id');
|
||||
jest.spyOn(cmd as any, 'setupCredentialsAsync').mockResolvedValue({ id: 'asc-app-id' });
|
||||
jest.spyOn(cmd as any, 'monitorWorkflowJobsAsync').mockResolvedValue(WorkflowRunStatus.Success);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
it('logs auto-selected SDK message and reports resolved version after dispatch', async () => {
|
||||
mockGetConfigFilePaths.mockReturnValue({
|
||||
staticConfigPath: '/app.json',
|
||||
dynamicConfigPath: null,
|
||||
});
|
||||
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
|
||||
|
||||
await makeCmd().run();
|
||||
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('SDK 55'));
|
||||
expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('Using Expo Go SDK'));
|
||||
});
|
||||
|
||||
it('skips auto-select log when --sdk-version flag is provided', async () => {
|
||||
mockGetConfigFilePaths.mockReturnValue({
|
||||
staticConfigPath: '/app.json',
|
||||
dynamicConfigPath: null,
|
||||
});
|
||||
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
|
||||
|
||||
await makeCmd(['--sdk-version', '55.0.0']).run();
|
||||
|
||||
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('Auto-selected'));
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
customBuildConfigMetadata,
|
||||
buildLoggerLevel,
|
||||
freezeCredentials,
|
||||
refreshAdHocProvisioningProfile: refreshAdHocProvisioningProfileFlag,
|
||||
isVerboseLoggingEnabled,
|
||||
whatToTest,
|
||||
env,
|
||||
@@ -65,6 +66,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
buildLoggerLevel?: LoggerLevel;
|
||||
freezeCredentials: boolean;
|
||||
refreshAdHocProvisioningProfile?: boolean;
|
||||
isVerboseLoggingEnabled: boolean;
|
||||
whatToTest?: string;
|
||||
env: Record<string, string>;
|
||||
@@ -90,6 +92,8 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
|
||||
const requiredPackageManager = resolvePackageManager(projectDir);
|
||||
|
||||
const refreshAdHocProvisioningProfile = refreshAdHocProvisioningProfileFlag ?? false;
|
||||
|
||||
const credentialsCtx = new CredentialsContext({
|
||||
projectInfo: { exp, projectId },
|
||||
nonInteractive,
|
||||
@@ -101,6 +105,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
easJsonCliConfig,
|
||||
vcsClient,
|
||||
freezeCredentials,
|
||||
refreshAdHocProvisioningProfile,
|
||||
});
|
||||
|
||||
const devClientProperties = getDevClientEventProperties({
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { CredentialsSource } from '@expo/eas-json';
|
||||
|
||||
import { BuildContext } from '../../context';
|
||||
import { ensureIosCredentialsAsync } from '../credentials';
|
||||
|
||||
describe(ensureIosCredentialsAsync, () => {
|
||||
it('errors when refresh is enabled with local credentials source', async () => {
|
||||
const buildCtx = {
|
||||
buildProfile: {
|
||||
credentialsSource: CredentialsSource.LOCAL,
|
||||
simulator: false,
|
||||
withoutCredentials: false,
|
||||
},
|
||||
credentialsCtx: {
|
||||
refreshAdHocProvisioningProfile: true,
|
||||
},
|
||||
} as BuildContext<Platform.IOS>;
|
||||
|
||||
await expect(ensureIosCredentialsAsync(buildCtx, [])).rejects.toThrow(
|
||||
'--refresh-ad-hoc-provisioning-profile cannot be used with credentialsSource "local". Use remote credentials or omit the flag.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reject refresh when credentials source is remote', async () => {
|
||||
const buildCtx = {
|
||||
buildProfile: {
|
||||
credentialsSource: CredentialsSource.REMOTE,
|
||||
simulator: true,
|
||||
withoutCredentials: false,
|
||||
distribution: 'internal',
|
||||
},
|
||||
credentialsCtx: {
|
||||
refreshAdHocProvisioningProfile: true,
|
||||
},
|
||||
} as BuildContext<Platform.IOS>;
|
||||
|
||||
await expect(ensureIosCredentialsAsync(buildCtx, [])).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,16 @@ export async function ensureIosCredentialsAsync(
|
||||
return;
|
||||
}
|
||||
|
||||
const { credentialsSource } = buildCtx.buildProfile;
|
||||
if (
|
||||
buildCtx.credentialsCtx.refreshAdHocProvisioningProfile &&
|
||||
credentialsSource === 'local'
|
||||
) {
|
||||
throw new Error(
|
||||
'--refresh-ad-hoc-provisioning-profile cannot be used with credentialsSource "local". Use remote credentials or omit the flag.'
|
||||
);
|
||||
}
|
||||
|
||||
const provider = new IosCredentialsProvider(buildCtx.credentialsCtx, {
|
||||
app: await getAppFromContextAsync(buildCtx.credentialsCtx),
|
||||
targets,
|
||||
@@ -24,8 +34,6 @@ export async function ensureIosCredentialsAsync(
|
||||
enterpriseProvisioning: buildCtx.buildProfile.enterpriseProvisioning,
|
||||
});
|
||||
|
||||
const { credentialsSource } = buildCtx.buildProfile;
|
||||
|
||||
logCredentialsSource(credentialsSource, Platform.IOS);
|
||||
return {
|
||||
credentials: await provider.getCredentialsAsync(credentialsSource),
|
||||
|
||||
@@ -112,6 +112,9 @@ export async function prepareJobAsync(
|
||||
// See: https://github.com/expo/eas-build/pull/454
|
||||
appId: ctx.projectId,
|
||||
initiatingUserId: ctx.user.id,
|
||||
...(ctx.credentialsCtx.refreshAdHocProvisioningProfile && {
|
||||
refreshAdHocProvisioningProfile: true,
|
||||
}),
|
||||
};
|
||||
return sanitizeBuildJob(job) as Ios.Job;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Metadata, Platform, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import {
|
||||
Metadata,
|
||||
Platform,
|
||||
getInstalledExpoPackageVersionAsync,
|
||||
sanitizeMetadata,
|
||||
} from '@expo/eas-build-job';
|
||||
import { IosEnterpriseProvisioning } from '@expo/eas-json';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import fs from 'fs-extra';
|
||||
import resolveFrom from 'resolve-from';
|
||||
|
||||
@@ -9,9 +15,7 @@ import { maybeResolveVersionsAsync as maybeResolveIosVersionsAsync } from './ios
|
||||
import { LocalBuildMode } from './local';
|
||||
import { BuildDistributionType } from './types';
|
||||
import Log from '../log';
|
||||
import {
|
||||
isExpoUpdatesInstalled,
|
||||
} from '../project/projectUtils';
|
||||
import { isExpoUpdatesInstalled } from '../project/projectUtils';
|
||||
import { readChannelSafelyAsync as readAndroidChannelSafelyAsync } from '../update/android/UpdatesModule';
|
||||
import { readChannelSafelyAsync as readIosChannelSafelyAsync } from '../update/ios/UpdatesModule';
|
||||
import { easCliVersion } from '../utils/easCli';
|
||||
@@ -26,6 +30,16 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
): Promise<Metadata> {
|
||||
const channelObject = await resolveChannelAsync(ctx);
|
||||
const distribution = ctx.buildProfile.distribution ?? BuildDistributionType.STORE;
|
||||
const expoPackageVersionResult = await asyncResult(
|
||||
getInstalledExpoPackageVersionAsync({
|
||||
env: process.env,
|
||||
projectDir: ctx.projectDir,
|
||||
})
|
||||
);
|
||||
if (!expoPackageVersionResult.ok) {
|
||||
Log.debug('Failed to resolve expo package version:');
|
||||
Log.debug(expoPackageVersionResult.reason);
|
||||
}
|
||||
const metadata: Metadata = {
|
||||
trackingContext: ctx.analyticsEventProperties,
|
||||
...(await maybeResolveVersionsAsync(ctx)),
|
||||
@@ -36,6 +50,7 @@ export async function collectMetadataAsync<T extends Platform>(
|
||||
runtimeVersion: runtimeAndFingerprintMetadata?.runtimeVersion,
|
||||
fingerprintHash: runtimeAndFingerprintMetadata?.fingerprintHash,
|
||||
reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir),
|
||||
expoPackageVersion: expoPackageVersionResult.value,
|
||||
...channelObject,
|
||||
distribution,
|
||||
appName: ctx.exp.name,
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface BuildFlags {
|
||||
message?: string;
|
||||
buildLoggerLevel?: LoggerLevel;
|
||||
freezeCredentials: boolean;
|
||||
refreshAdHocProvisioningProfile?: boolean;
|
||||
isVerboseLoggingEnabled?: boolean;
|
||||
whatToTest?: string;
|
||||
simulator?: SimulatorRunTarget;
|
||||
@@ -413,6 +414,7 @@ async function prepareAndStartBuildAsync({
|
||||
customBuildConfigMetadata,
|
||||
buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined),
|
||||
freezeCredentials: flags.freezeCredentials,
|
||||
refreshAdHocProvisioningProfile: flags.refreshAdHocProvisioningProfile,
|
||||
isVerboseLoggingEnabled: flags.isVerboseLoggingEnabled ?? false,
|
||||
whatToTest: flags.whatToTest,
|
||||
env,
|
||||
|
||||
@@ -35,4 +35,5 @@ export interface BuildFlags {
|
||||
message?: string;
|
||||
buildLoggerLevel?: LoggerLevel;
|
||||
freezeCredentials: boolean;
|
||||
refreshAdHocProvisioningProfile?: boolean;
|
||||
}
|
||||
+4
-1
@@ -74,7 +74,10 @@ describe(getProjectIdAsync, () => {
|
||||
);
|
||||
|
||||
jest.mocked(findProjectRootAsync).mockResolvedValue('/app');
|
||||
jest.mocked(isExpoInstalled).mockReturnValue(true);
|
||||
|
||||
// NOTE(@kitten): Updating this test is easiest by letting it fallback to `@expo/config`
|
||||
// This isn't a great solution, but the test is pretty involved
|
||||
jest.mocked(isExpoInstalled).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('gets the project ID from app config if exists', async () => {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../../onboarding/git';
|
||||
import { installDependenciesAsync } from '../../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../../onboarding/runCommand';
|
||||
import { expoCommandAsync } from '../../../utils/expoCli';
|
||||
import {
|
||||
cloneTemplateAsync,
|
||||
initializeGitRepositoryAsync,
|
||||
installProjectDependenciesAsync,
|
||||
} from '../commands';
|
||||
|
||||
jest.mock('../../../utils/expoCli');
|
||||
jest.mock('../../../onboarding/git');
|
||||
jest.mock('../../../onboarding/runCommand');
|
||||
jest.mock('../../../onboarding/installDependencies');
|
||||
@@ -79,21 +81,11 @@ describe('commands', () => {
|
||||
packageManager: 'npm',
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', 'expo-updates'],
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
|
||||
expect(runCommandAsync).toHaveBeenCalledWith({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', '@expo/metro-runtime'],
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
expect(expoCommandAsync).toHaveBeenCalledWith(
|
||||
projectDir,
|
||||
['install', 'expo-updates', '@expo/metro-runtime'],
|
||||
{ silent: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { canAccessRepositoryUsingSshAsync, runGitCloneAsync } from '../../onboar
|
||||
import { PackageManager, installDependenciesAsync } from '../../onboarding/installDependencies';
|
||||
import { runCommandAsync } from '../../onboarding/runCommand';
|
||||
import { ora } from '../../ora';
|
||||
import { expoCommandAsync } from '../../utils/expoCli';
|
||||
|
||||
export async function cloneTemplateAsync(targetProjectDir: string): Promise<string> {
|
||||
const githubUsername = 'expo';
|
||||
@@ -48,16 +49,8 @@ export async function installProjectDependenciesAsync(
|
||||
});
|
||||
|
||||
const dependencies = ['expo-updates', '@expo/metro-runtime'];
|
||||
for (const dependency of dependencies) {
|
||||
spinner.text = `Installing ${chalk.bold(dependency)}`;
|
||||
await runCommandAsync({
|
||||
cwd: projectDir,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', dependency],
|
||||
showOutput: false,
|
||||
showSpinner: false,
|
||||
});
|
||||
}
|
||||
spinner.text = `Installing ${dependencies.map(dep => chalk.bold(dep)).join(', ')}`;
|
||||
await expoCommandAsync(projectDir, ['install', ...dependencies], { silent: true });
|
||||
spinner.succeed(`Installed project dependencies`);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,14 +13,15 @@ export default class AccountLogin extends EasCommand {
|
||||
static override flags = {
|
||||
// can pass either --sso or -s
|
||||
sso: Flags.boolean({
|
||||
description: 'Login with SSO',
|
||||
description: 'Log in with SSO',
|
||||
char: 's',
|
||||
default: false,
|
||||
}),
|
||||
browser: Flags.boolean({
|
||||
description: 'Login with your browser',
|
||||
description: 'Log in with your browser (default; use --no-browser for CLI-based login)',
|
||||
char: 'b',
|
||||
default: false,
|
||||
default: true,
|
||||
allowNo: true,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ interface RawBuildFlags {
|
||||
message?: string;
|
||||
'build-logger-level'?: LoggerLevel;
|
||||
'freeze-credentials': boolean;
|
||||
'refresh-ad-hoc-provisioning-profile': boolean;
|
||||
'verbose-logs'?: boolean;
|
||||
'what-to-test'?: string;
|
||||
}
|
||||
@@ -121,6 +122,11 @@ export default class Build extends EasCommand {
|
||||
default: false,
|
||||
description: 'Prevent the build from updating credentials in non-interactive mode',
|
||||
}),
|
||||
'refresh-ad-hoc-provisioning-profile': Flags.boolean({
|
||||
default: false,
|
||||
description:
|
||||
'Refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials',
|
||||
}),
|
||||
'verbose-logs': Flags.boolean({
|
||||
default: false,
|
||||
description: 'Use verbose logs for the build process',
|
||||
@@ -188,6 +194,20 @@ export default class Build extends EasCommand {
|
||||
flags: RawBuildFlags
|
||||
): Omit<BuildFlags, 'requestedPlatform'> & { requestedPlatform?: RequestedPlatform } {
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (flags['refresh-ad-hoc-provisioning-profile']) {
|
||||
if (!nonInteractive) {
|
||||
Errors.error(
|
||||
'--refresh-ad-hoc-provisioning-profile can only be used in non-interactive mode.',
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
if (flags['freeze-credentials']) {
|
||||
Errors.error(
|
||||
'Cannot use --refresh-ad-hoc-provisioning-profile with --freeze-credentials.',
|
||||
{ exit: 1 }
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!flags.local && flags.output) {
|
||||
Errors.error('--output is allowed only for local builds', { exit: 1 });
|
||||
}
|
||||
@@ -249,6 +269,7 @@ export default class Build extends EasCommand {
|
||||
message,
|
||||
buildLoggerLevel: flags['build-logger-level'],
|
||||
freezeCredentials: flags['freeze-credentials'],
|
||||
refreshAdHocProvisioningProfile: flags['refresh-ad-hoc-provisioning-profile'],
|
||||
isVerboseLoggingEnabled: flags['verbose-logs'],
|
||||
whatToTest: flags['what-to-test'],
|
||||
};
|
||||
|
||||
@@ -40,6 +40,11 @@ export default class BuildInternal extends EasCommand {
|
||||
helpValue: 'PROFILE_NAME',
|
||||
exclusive: ['auto-submit'],
|
||||
}),
|
||||
'refresh-ad-hoc-provisioning-profile': Flags.boolean({
|
||||
default: false,
|
||||
description:
|
||||
'Refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials',
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -85,6 +90,7 @@ export default class BuildInternal extends EasCommand {
|
||||
profile: flags.profile,
|
||||
nonInteractive: true,
|
||||
freezeCredentials: false,
|
||||
refreshAdHocProvisioningProfile: flags['refresh-ad-hoc-provisioning-profile'],
|
||||
wait: false,
|
||||
clearCache: false,
|
||||
json: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
|
||||
import { App, User, UserRole } from '@expo/apple-utils';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
@@ -28,6 +28,7 @@ import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
import { confirmAsync } from '../prompts';
|
||||
import { ora } from '../ora';
|
||||
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
|
||||
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
|
||||
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
|
||||
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
|
||||
@@ -45,6 +46,20 @@ function deriveBundleIdSlug(bundleId: string): string {
|
||||
return bundleId.split('.').filter(Boolean).pop()!;
|
||||
}
|
||||
|
||||
export async function detectProjectSdkVersionAsync(
|
||||
projectDir: string
|
||||
): Promise<string | undefined> {
|
||||
const paths = getConfigFilePaths(projectDir);
|
||||
if (!paths.staticConfigPath && !paths.dynamicConfigPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
return (await getPrivateExpoConfigAsync(projectDir)).sdkVersion;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';
|
||||
|
||||
async function setupTestFlightAsync(ascApp: App): Promise<void> {
|
||||
@@ -189,7 +204,13 @@ export default class Go extends EasCommand {
|
||||
});
|
||||
Log.withTick(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
|
||||
|
||||
const sdkVersion = flags['sdk-version'];
|
||||
const detectedSdkVersion = await detectProjectSdkVersionAsync(process.cwd());
|
||||
if (detectedSdkVersion && !flags['sdk-version']) {
|
||||
Log.log(
|
||||
`Current project using SDK ${detectedSdkVersion.split('.')[0]}. Auto-selected same version. To use a different version, pass --sdk-version.`
|
||||
);
|
||||
}
|
||||
const sdkVersion = flags['sdk-version'] ?? detectedSdkVersion;
|
||||
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
|
||||
if (!isBundleIdentifierValid(bundleId)) {
|
||||
throw new Error(
|
||||
@@ -231,7 +252,11 @@ export default class Go extends EasCommand {
|
||||
}
|
||||
);
|
||||
|
||||
const { workflowUrl, workflowRunId } = await this.dispatchWorkflowAsync(
|
||||
const {
|
||||
workflowUrl,
|
||||
workflowRunId,
|
||||
sdkVersion: resolvedSdkVersion,
|
||||
} = await this.dispatchWorkflowAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
actor,
|
||||
@@ -242,6 +267,7 @@ export default class Go extends EasCommand {
|
||||
tmpDir,
|
||||
vcsClient
|
||||
);
|
||||
Log.withTick(`Using Expo Go SDK ${chalk.cyan(resolvedSdkVersion.split('.')[0])}`);
|
||||
Log.withTick(`Build started: ${chalk.cyan(workflowUrl)}`);
|
||||
|
||||
const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId);
|
||||
@@ -401,7 +427,7 @@ export default class Go extends EasCommand {
|
||||
sdkVersion: string | undefined,
|
||||
tmpDir: string,
|
||||
vcsClient: Client
|
||||
): Promise<{ workflowUrl: string; workflowRunId: string }> {
|
||||
): Promise<{ workflowUrl: string; workflowRunId: string; sdkVersion: string }> {
|
||||
const account = ensureActorHasPrimaryAccount(actor);
|
||||
|
||||
const repackConfig = await WorkflowRunQuery.expoGoRepackConfigurationAsync(graphqlClient, {
|
||||
@@ -451,7 +477,7 @@ export default class Go extends EasCommand {
|
||||
|
||||
const workflowUrl = getWorkflowRunUrl(account.name, deriveBundleIdSlug(bundleId), result.id);
|
||||
|
||||
return { workflowUrl, workflowRunId: result.id };
|
||||
return { workflowUrl, workflowRunId: result.id, sdkVersion: repackConfig.sdkVersion };
|
||||
}
|
||||
|
||||
private async monitorWorkflowJobsAsync(
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { AppPlatform } from '../../../graphql/generated';
|
||||
import { fetchObserveNavigationRoutesAsync } from '../../../observe/fetchNavigationRoutes';
|
||||
import {
|
||||
buildObserveNavigationRoutesJson,
|
||||
buildObserveNavigationRoutesTable,
|
||||
} from '../../../observe/formatNavigationRoutes';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveRoutes from '../routes';
|
||||
|
||||
jest.mock('../../../observe/fetchNavigationRoutes');
|
||||
jest.mock('../../../observe/formatNavigationRoutes', () => {
|
||||
const actual = jest.requireActual('../../../observe/formatNavigationRoutes');
|
||||
return {
|
||||
...actual,
|
||||
buildObserveNavigationRoutesTable: jest.fn().mockReturnValue('table'),
|
||||
buildObserveNavigationRoutesJson: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveNavigationRoutesAsync = jest.mocked(fetchObserveNavigationRoutesAsync);
|
||||
const mockBuildObserveNavigationRoutesTable = jest.mocked(buildObserveNavigationRoutesTable);
|
||||
const mockBuildObserveNavigationRoutesJson = jest.mocked(buildObserveNavigationRoutesJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveRoutes, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveNavigationRoutesAsync.mockResolvedValue({
|
||||
routes: [],
|
||||
pageInfoByPlatform: new Map(),
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveRoutes {
|
||||
const command = new ObserveRoutes(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('queries both platforms by default with all three navigation metric full names', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveNavigationRoutesAsync).toHaveBeenCalledTimes(1);
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.platforms).toEqual([AppPlatform.Android, AppPlatform.Ios]);
|
||||
expect(options.limit).toBe(50);
|
||||
|
||||
const tableCall = mockBuildObserveNavigationRoutesTable.mock.calls[0];
|
||||
expect(tableCall[1]).toEqual([
|
||||
'expo.navigation.cold_ttr',
|
||||
'expo.navigation.warm_ttr',
|
||||
'expo.navigation.tti',
|
||||
]);
|
||||
expect(tableCall[2]).toEqual(['median', 'count']);
|
||||
});
|
||||
|
||||
it('filters platform when --platform ios is provided', async () => {
|
||||
const command = createCommand(['--platform', 'ios']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.platforms).toEqual([AppPlatform.Ios]);
|
||||
});
|
||||
|
||||
it('resolves --metric short aliases to navigation metric full names and deduplicates', async () => {
|
||||
const command = createCommand([
|
||||
'--metric',
|
||||
'cold_ttr',
|
||||
'--metric',
|
||||
'cold_ttr',
|
||||
'--metric',
|
||||
'nav_tti',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveNavigationRoutesTable.mock.calls[0][1]).toEqual([
|
||||
'expo.navigation.cold_ttr',
|
||||
'expo.navigation.tti',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes --limit and --after through to the fetcher', async () => {
|
||||
const command = createCommand(['--limit', '25', '--after', 'cursor-abc']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.limit).toBe(25);
|
||||
expect(options.after).toBe('cursor-abc');
|
||||
});
|
||||
|
||||
it('uses --days to compute start/end time range', async () => {
|
||||
const now = new Date('2025-06-15T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--days', '7']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.endTime).toBe('2025-06-15T12:00:00.000Z');
|
||||
expect(options.startTime).toBe('2025-06-08T12:00:00.000Z');
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses explicit --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'--start',
|
||||
'2025-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2025-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.startTime).toBe('2025-01-01T00:00:00.000Z');
|
||||
expect(options.endTime).toBe('2025-02-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('rejects --days combined with --start', async () => {
|
||||
const command = createCommand(['--days', '7', '--start', '2025-01-01T00:00:00.000Z']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('passes app-version, update-id, and build-number filters', async () => {
|
||||
const command = createCommand([
|
||||
'--app-version',
|
||||
'2.1.0',
|
||||
'--update-id',
|
||||
'update-xyz',
|
||||
'--build-number',
|
||||
'42',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.appVersion).toBe('2.1.0');
|
||||
expect(options.updateId).toBe('update-xyz');
|
||||
expect(options.buildNumber).toBe('42');
|
||||
});
|
||||
|
||||
it('passes --route-name flags through as routeNames array', async () => {
|
||||
const command = createCommand(['--route-name', '/home', '--route-name', '/profile']);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.routeNames).toEqual(['/home', '/profile']);
|
||||
});
|
||||
|
||||
it('dedupes duplicate --route-name flags', async () => {
|
||||
const command = createCommand([
|
||||
'--route-name',
|
||||
'/home',
|
||||
'--route-name',
|
||||
'/profile',
|
||||
'--route-name',
|
||||
'/home',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.routeNames).toEqual(['/home', '/profile']);
|
||||
});
|
||||
|
||||
it('does not pass routeNames when --route-name is not provided', async () => {
|
||||
const command = createCommand([]);
|
||||
await command.runAsync();
|
||||
|
||||
const options = mockFetchObserveNavigationRoutesAsync.mock.calls[0][2];
|
||||
expect(options.routeNames).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves --stat aliases and passes them through', async () => {
|
||||
const command = createCommand(['--stat', 'p90', '--stat', 'eventCount']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveNavigationRoutesTable.mock.calls[0][2]).toEqual(['p90', 'count']);
|
||||
});
|
||||
|
||||
it('uses default JSON stats (median, p90, count) when --json is set without --stat', async () => {
|
||||
const command = createCommand(['--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveNavigationRoutesJson.mock.calls[0][2]).toEqual([
|
||||
'median',
|
||||
'p90',
|
||||
'count',
|
||||
]);
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -171,6 +171,7 @@ describe(ObserveVersions, () => {
|
||||
firstSeenAt: '2025-06-01T00:00:00.000Z',
|
||||
eventCount: 100,
|
||||
uniqueUserCount: 50,
|
||||
lastSeenUserCount: 12,
|
||||
buildNumbers: [],
|
||||
updates: [],
|
||||
metrics: [],
|
||||
|
||||
@@ -11,13 +11,17 @@ import {
|
||||
fetchTotalEventCountAsync,
|
||||
resolveOrderBy,
|
||||
} from '../../observe/fetchEvents';
|
||||
import {
|
||||
ObserveAfterFlag,
|
||||
ObserveAppVersionFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
import { METRIC_ALIASES, METRIC_SHORT_NAMES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { buildObserveEventsJson, buildObserveEventsTable } from '../../observe/formatEvents';
|
||||
import {
|
||||
allowedPlatformFlagValues,
|
||||
appObservePlatformFromFlag,
|
||||
appPlatformsFromFlag,
|
||||
} from '../../observe/platforms';
|
||||
import { appObservePlatformFromFlag, appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { selectAsync } from '../../prompts';
|
||||
@@ -44,40 +48,16 @@ export default class ObserveEvents extends EasCommand {
|
||||
required: false,
|
||||
default: EventsOrderPreset.Oldest.valueOf().toLowerCase(),
|
||||
})(),
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: allowedPlatformFlagValues,
|
||||
})(),
|
||||
after: Flags.string({
|
||||
description:
|
||||
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
|
||||
}),
|
||||
...ObservePlatformFlag,
|
||||
...ObserveAfterFlag,
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_EVENTS_LIMIT,
|
||||
limit: 100,
|
||||
}),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show events from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
'app-version': Flags.string({
|
||||
description: 'Filter by app version',
|
||||
}),
|
||||
'update-id': Flags.string({
|
||||
description: 'Filter by EAS update ID',
|
||||
}),
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveAppVersionFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,14 @@ import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import Log from '../../log';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveCustomEventsAsync } from '../../observe/fetchCustomEvents';
|
||||
import {
|
||||
ObserveAfterFlag,
|
||||
ObserveAppVersionFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
import {
|
||||
buildObserveCustomEventNamesJson,
|
||||
buildObserveCustomEventNamesTable,
|
||||
@@ -14,7 +22,7 @@ import {
|
||||
buildObserveCustomEventsJson,
|
||||
buildObserveCustomEventsTable,
|
||||
} from '../../observe/formatCustomEvents';
|
||||
import { allowedPlatformFlagValues, appObservePlatformFromFlag } from '../../observe/platforms';
|
||||
import { appObservePlatformFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -34,37 +42,15 @@ export default class ObserveLogs extends EasCommand {
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: allowedPlatformFlagValues,
|
||||
})(),
|
||||
after: Flags.string({
|
||||
description:
|
||||
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
|
||||
}),
|
||||
...ObservePlatformFlag,
|
||||
...ObserveAfterFlag,
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_EVENTS_LIMIT,
|
||||
limit: 100,
|
||||
}),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show events from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
'app-version': Flags.string({
|
||||
description: 'Filter by app version',
|
||||
}),
|
||||
'update-id': Flags.string({
|
||||
description: 'Filter by EAS update ID',
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveAppVersionFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
'session-id': Flags.string({
|
||||
description: 'Filter by session ID',
|
||||
}),
|
||||
@@ -73,9 +59,7 @@ export default class ObserveLogs extends EasCommand {
|
||||
'When no event name argument is provided, list all events across all event names instead of a summary of event names + counts.',
|
||||
default: false,
|
||||
}),
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveMetricsAsync } from '../../observe/fetchMetrics';
|
||||
import {
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
} from '../../observe/flags';
|
||||
import {
|
||||
StatisticKey,
|
||||
buildObserveMetricsJson,
|
||||
@@ -11,7 +16,7 @@ import {
|
||||
resolveStatKey,
|
||||
} from '../../observe/formatMetrics';
|
||||
import { METRIC_ALIASES, resolveMetricName } from '../../observe/metricNames';
|
||||
import { allowedPlatformFlagValues, appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -41,10 +46,7 @@ export default class ObserveMetrics extends EasCommand {
|
||||
static override description = 'display app performance metrics grouped by app version';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: allowedPlatformFlagValues,
|
||||
})(),
|
||||
...ObservePlatformFlag,
|
||||
metric: Flags.option({
|
||||
description: 'Metric name to display (can be specified multiple times).',
|
||||
multiple: true,
|
||||
@@ -55,22 +57,8 @@ export default class ObserveMetrics extends EasCommand {
|
||||
multiple: true,
|
||||
options: DEFAULT_STATS_JSON,
|
||||
})(),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range for metrics data (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range for metrics data (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show metrics from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveNavigationRoutesAsync } from '../../observe/fetchNavigationRoutes';
|
||||
import {
|
||||
ObserveAfterFlag,
|
||||
ObserveAppVersionFlag,
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
ObserveUpdateIdFlag,
|
||||
} from '../../observe/flags';
|
||||
import {
|
||||
NAVIGATION_METRIC_NAMES,
|
||||
NavigationStatKey,
|
||||
buildObserveNavigationRoutesJson,
|
||||
buildObserveNavigationRoutesTable,
|
||||
resolveNavigationStatKey,
|
||||
} from '../../observe/formatNavigationRoutes';
|
||||
import { NAVIGATION_METRIC_ALIASES, resolveNavigationMetricName } from '../../observe/metricNames';
|
||||
import { appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_ROUTES_LIMIT = 50;
|
||||
|
||||
const STAT_OPTIONS = ['median', 'med', 'p90', 'count', 'event_count', 'eventCount'];
|
||||
|
||||
const DEFAULT_STATS_TABLE: NavigationStatKey[] = ['median', 'count'];
|
||||
const DEFAULT_STATS_JSON: NavigationStatKey[] = ['median', 'p90', 'count'];
|
||||
|
||||
export default class ObserveRoutes extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route name';
|
||||
|
||||
static override flags = {
|
||||
...ObservePlatformFlag,
|
||||
metric: Flags.option({
|
||||
description:
|
||||
'Navigation metric to display (can be specified multiple times). Defaults to all three.',
|
||||
multiple: true,
|
||||
options: Object.keys(NAVIGATION_METRIC_ALIASES),
|
||||
})(),
|
||||
stat: Flags.option({
|
||||
description: 'Statistic to display per metric (can be specified multiple times)',
|
||||
multiple: true,
|
||||
options: STAT_OPTIONS,
|
||||
})(),
|
||||
...ObserveAfterFlag,
|
||||
limit: getLimitFlagWithCustomValues({
|
||||
defaultTo: DEFAULT_ROUTES_LIMIT,
|
||||
limit: 200,
|
||||
}),
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveAppVersionFlag,
|
||||
...ObserveUpdateIdFlag,
|
||||
'build-number': Flags.string({
|
||||
description: 'Filter by app build number',
|
||||
}),
|
||||
'route-name': Flags.string({
|
||||
description:
|
||||
'Filter by route name (can be specified multiple times to include several routes)',
|
||||
multiple: true,
|
||||
}),
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ObserveRoutes);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveRoutes,
|
||||
loggedInOnlyContextDefinition: ObserveRoutes.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
} else {
|
||||
Log.warn('EAS Observe is in preview and subject to breaking changes.');
|
||||
}
|
||||
|
||||
const metricNames = flags.metric?.length
|
||||
? Array.from(new Set(flags.metric.map(resolveNavigationMetricName)))
|
||||
: NAVIGATION_METRIC_NAMES;
|
||||
|
||||
const argumentsStat = flags.stat?.length
|
||||
? Array.from(new Set(flags.stat.map(resolveNavigationStatKey)))
|
||||
: undefined;
|
||||
|
||||
const routeNames = flags['route-name']?.length
|
||||
? Array.from(new Set(flags['route-name']))
|
||||
: undefined;
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveTimeRange(flags);
|
||||
const platforms = appPlatformsFromFlag(flags.platform);
|
||||
|
||||
const { routes, pageInfoByPlatform } = await fetchObserveNavigationRoutesAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
{
|
||||
startTime,
|
||||
endTime,
|
||||
platforms,
|
||||
limit: flags.limit ?? DEFAULT_ROUTES_LIMIT,
|
||||
...(flags.after && { after: flags.after }),
|
||||
appVersion: flags['app-version'],
|
||||
updateId: flags['update-id'],
|
||||
buildNumber: flags['build-number'],
|
||||
routeNames,
|
||||
}
|
||||
);
|
||||
|
||||
if (flags.json) {
|
||||
const stats = argumentsStat ?? DEFAULT_STATS_JSON;
|
||||
printJsonOnlyOutput(
|
||||
buildObserveNavigationRoutesJson(routes, metricNames, stats, pageInfoByPlatform)
|
||||
);
|
||||
} else {
|
||||
const stats = argumentsStat ?? DEFAULT_STATS_TABLE;
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveNavigationRoutesTable(routes, metricNames, stats, {
|
||||
daysBack,
|
||||
startTime,
|
||||
endTime,
|
||||
pageInfoByPlatform,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveVersionsAsync } from '../../observe/fetchVersions';
|
||||
import {
|
||||
ObservePlatformFlag,
|
||||
ObserveProjectIdFlag,
|
||||
ObserveTimeRangeFlags,
|
||||
} from '../../observe/flags';
|
||||
import { buildObserveVersionsJson, buildObserveVersionsTable } from '../../observe/formatVersions';
|
||||
import { allowedPlatformFlagValues, appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { appPlatformsFromFlag } from '../../observe/platforms';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { resolveTimeRange } from '../../observe/startAndEndTime';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
@@ -15,26 +18,9 @@ export default class ObserveVersions extends EasCommand {
|
||||
static override description = 'display app versions with build and update details';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: allowedPlatformFlagValues,
|
||||
})(),
|
||||
start: Flags.string({
|
||||
description: 'Start of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show versions from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
...ObservePlatformFlag,
|
||||
...ObserveTimeRangeFlags,
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
|
||||
@@ -95,7 +95,10 @@ function mockTestProject(options: {
|
||||
graphqlClient,
|
||||
authenticationInfo: { accessToken: null, sessionSecret: '1234' },
|
||||
});
|
||||
jest.mocked(isExpoInstalled).mockReturnValue(true);
|
||||
|
||||
// NOTE(@kitten): Updating this test is easiest by letting it fallback to `@expo/config`
|
||||
// This isn't a great solution, but the test is pretty involved
|
||||
jest.mocked(isExpoInstalled).mockReturnValue(false);
|
||||
}
|
||||
|
||||
const commandOptions = getMockOclifConfig({ root: '/test-project' });
|
||||
|
||||
@@ -36,6 +36,7 @@ import { ExpoConfigOptions, getPrivateExpoConfigAsync } from '../../project/expo
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { Actor } from '../../user/User';
|
||||
import { easCliVersion } from '../../utils/easCli';
|
||||
import { expoCommandAsync } from '../../utils/expoCli';
|
||||
import GitClient from '../../vcs/clients/git';
|
||||
|
||||
export default class Onboarding extends EasCommand {
|
||||
@@ -205,23 +206,12 @@ export default class Onboarding extends EasCommand {
|
||||
});
|
||||
|
||||
if (!app.githubRepository) {
|
||||
await runCommandAsync({
|
||||
cwd: finalTargetProjectDirectory,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', 'expo-updates'],
|
||||
});
|
||||
Log.log();
|
||||
await runCommandAsync({
|
||||
cwd: finalTargetProjectDirectory,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', 'expo-insights'],
|
||||
});
|
||||
Log.log();
|
||||
await runCommandAsync({
|
||||
cwd: finalTargetProjectDirectory,
|
||||
command: 'npx',
|
||||
args: ['expo', 'install', 'expo-dev-client'],
|
||||
});
|
||||
await expoCommandAsync(finalTargetProjectDirectory, [
|
||||
'install',
|
||||
'expo-updates',
|
||||
'expo-insights',
|
||||
'expo-dev-client',
|
||||
]);
|
||||
Log.log();
|
||||
}
|
||||
await vcsClient.trackFileAsync('package-lock.json');
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
JobRunStatus,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorGet from '../get';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log');
|
||||
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 DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
|
||||
|
||||
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
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(SimulatorGet, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): {
|
||||
command: SimulatorGet;
|
||||
getContextAsync: jest.SpyInstance;
|
||||
} {
|
||||
const command = new SimulatorGet(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return { command, getContextAsync };
|
||||
}
|
||||
|
||||
it('emits JSON when --json is passed', async () => {
|
||||
const session = makeDeviceRunSession();
|
||||
mockByIdAsync.mockResolvedValue(session);
|
||||
|
||||
const { command, getContextAsync } = createCommand(['--id', 'session-123', '--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(getContextAsync).toHaveBeenCalledWith(SimulatorGet, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: 'session-123',
|
||||
type: 'agent-device',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
jobRunUrl: 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123',
|
||||
remoteConfig: session.remoteConfig,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { Config } from '@oclif/core';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
DeviceRunSessionsByAppIdQuery,
|
||||
JobRunStatus,
|
||||
} from '../../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import SimulatorList from '../list';
|
||||
|
||||
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
|
||||
jest.mock('../../../log');
|
||||
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 DeviceRunSessionEdge =
|
||||
DeviceRunSessionsByAppIdQuery['app']['byId']['deviceRunSessionsPaginated']['edges'][number];
|
||||
type DeviceRunSessionNode = DeviceRunSessionEdge['node'];
|
||||
type DeviceRunSessionsPaginated =
|
||||
DeviceRunSessionsByAppIdQuery['app']['byId']['deviceRunSessionsPaginated'];
|
||||
|
||||
const mockListByAppIdAsync = jest.mocked(DeviceRunSessionQuery.listByAppIdAsync);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeSession(overrides: Partial<DeviceRunSessionNode> = {}): DeviceRunSessionNode {
|
||||
return {
|
||||
id: 'session-123',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
type: DeviceRunSessionType.AgentDevice,
|
||||
platform: AppPlatform.Ios,
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
startedAt: '2025-01-01T00:00:05.000Z',
|
||||
finishedAt: null,
|
||||
app: {
|
||||
id: 'app-123',
|
||||
slug: 'testapp',
|
||||
ownerAccount: {
|
||||
id: 'account-123',
|
||||
name: 'testuser',
|
||||
},
|
||||
},
|
||||
turtleJobRun: {
|
||||
id: 'job-123',
|
||||
status: JobRunStatus.InProgress,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeConnection(
|
||||
nodes: DeviceRunSessionNode[],
|
||||
pageInfo: Partial<DeviceRunSessionsPaginated['pageInfo']> = {}
|
||||
): DeviceRunSessionsPaginated {
|
||||
return {
|
||||
edges: nodes.map((node, index) => ({ cursor: `cursor-${index}`, node })),
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: nodes.length > 0 ? 'cursor-0' : null,
|
||||
endCursor: nodes.length > 0 ? `cursor-${nodes.length - 1}` : null,
|
||||
...pageInfo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getMockOclifConfig(): Config {
|
||||
const config = new Config({ root: __dirname });
|
||||
config.runHook = async () => ({
|
||||
failures: [],
|
||||
successes: [],
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
describe(SimulatorList, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): {
|
||||
command: SimulatorList;
|
||||
getContextAsync: jest.SpyInstance;
|
||||
} {
|
||||
const command = new SimulatorList(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
projectId: 'app-123',
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return { command, getContextAsync };
|
||||
}
|
||||
|
||||
it('emits JSON when --json is passed', async () => {
|
||||
const session = makeSession();
|
||||
mockListByAppIdAsync.mockResolvedValue(makeConnection([session]));
|
||||
|
||||
const { command, getContextAsync } = createCommand(['--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(getContextAsync).toHaveBeenCalledWith(SimulatorList, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
expect(mockListByAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'app-123',
|
||||
first: 10,
|
||||
after: undefined,
|
||||
filter: undefined,
|
||||
});
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
sessions: [
|
||||
{
|
||||
id: 'session-123',
|
||||
type: 'agent-device',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
platform: AppPlatform.Ios,
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
startedAt: '2025-01-01T00:00:05.000Z',
|
||||
finishedAt: undefined,
|
||||
jobRunUrl: 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123',
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: 'cursor-0',
|
||||
endCursor: 'cursor-0',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('passes filters and cursor to the query', async () => {
|
||||
mockListByAppIdAsync.mockResolvedValue(makeConnection([]));
|
||||
|
||||
const { command } = createCommand([
|
||||
'--json',
|
||||
'--status',
|
||||
'in-progress',
|
||||
'--status',
|
||||
'new',
|
||||
'--type',
|
||||
'argent',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--limit',
|
||||
'25',
|
||||
'--after',
|
||||
'page-cursor',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockListByAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: 'app-123',
|
||||
first: 25,
|
||||
after: 'page-cursor',
|
||||
filter: {
|
||||
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
|
||||
types: [DeviceRunSessionType.Argent],
|
||||
platforms: [AppPlatform.Ios],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('runs non-interactively without --json', async () => {
|
||||
const session = makeSession({ id: 'session-1', turtleJobRun: null });
|
||||
mockListByAppIdAsync.mockResolvedValue(makeConnection([session]));
|
||||
|
||||
const { command, getContextAsync } = createCommand(['--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
|
||||
expect(getContextAsync).toHaveBeenCalledWith(SimulatorList, {
|
||||
nonInteractive: true,
|
||||
});
|
||||
expect(mockListByAppIdAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles empty results', async () => {
|
||||
mockListByAppIdAsync.mockResolvedValue(makeConnection([]));
|
||||
|
||||
const { command } = createCommand(['--json']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
sessions: [],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,19 @@ import { Flags } from '@oclif/core';
|
||||
|
||||
import { getBareJobRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { DeviceRunSessionStatus } from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { formatRemoteSessionInstructions } from '../../simulator/utils';
|
||||
import {
|
||||
deviceRunSessionTypeToFlagValue,
|
||||
formatRemoteSessionInstructions,
|
||||
} from '../../simulator/utils';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class SimulatorGet extends EasCommand {
|
||||
static override hidden = true;
|
||||
@@ -19,7 +26,7 @@ export default class SimulatorGet extends EasCommand {
|
||||
description: 'Device run session ID',
|
||||
required: true,
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -28,11 +35,16 @@ export default class SimulatorGet extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorGet);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorGet, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
const fetchSpinner = ora(`Fetching device run session ${flags.id}`).start();
|
||||
@@ -49,6 +61,17 @@ export default class SimulatorGet extends EasCommand {
|
||||
? getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, session.turtleJobRun.id)
|
||||
: '';
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: session.id,
|
||||
type: deviceRunSessionTypeToFlagValue(session.type),
|
||||
status: session.status,
|
||||
jobRunUrl: jobRunUrl || undefined,
|
||||
remoteConfig: session.remoteConfig,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(`ID: ${session.id}`);
|
||||
Log.log(`Type: ${session.type}`);
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getBareJobRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionFilterInput,
|
||||
DeviceRunSessionStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
deviceRunSessionTypeToFlagValue,
|
||||
} from '../../simulator/utils';
|
||||
import { fromNow } from '../../utils/date';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
const DEFAULT_LIMIT = 10;
|
||||
const MAX_LIMIT = 100;
|
||||
|
||||
const STATUS_FLAG_VALUES: Record<DeviceRunSessionStatus, string> = {
|
||||
[DeviceRunSessionStatus.New]: 'new',
|
||||
[DeviceRunSessionStatus.InProgress]: 'in-progress',
|
||||
[DeviceRunSessionStatus.Stopped]: 'stopped',
|
||||
[DeviceRunSessionStatus.Errored]: 'errored',
|
||||
};
|
||||
const STATUS_BY_FLAG_VALUE = Object.fromEntries(
|
||||
Object.entries(STATUS_FLAG_VALUES).map(([status, value]) => [
|
||||
value,
|
||||
status as DeviceRunSessionStatus,
|
||||
])
|
||||
);
|
||||
|
||||
const PLATFORM_FLAG_VALUES: Record<AppPlatform, string> = {
|
||||
[AppPlatform.Android]: 'android',
|
||||
[AppPlatform.Ios]: 'ios',
|
||||
};
|
||||
const PLATFORM_BY_FLAG_VALUE = Object.fromEntries(
|
||||
Object.entries(PLATFORM_FLAG_VALUES).map(([platform, value]) => [value, platform as AppPlatform])
|
||||
);
|
||||
|
||||
export default class SimulatorList extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'[EXPERIMENTAL] list remote simulator sessions for the current project';
|
||||
|
||||
static override flags = {
|
||||
status: Flags.option({
|
||||
description: 'Filter by session status (repeatable)',
|
||||
options: Object.values(STATUS_FLAG_VALUES),
|
||||
multiple: true,
|
||||
})(),
|
||||
type: Flags.option({
|
||||
description: 'Filter by session type (repeatable)',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
multiple: true,
|
||||
})(),
|
||||
platform: Flags.option({
|
||||
description: 'Filter by device platform (repeatable)',
|
||||
options: Object.values(PLATFORM_FLAG_VALUES),
|
||||
multiple: true,
|
||||
})(),
|
||||
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
|
||||
after: Flags.string({
|
||||
description:
|
||||
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorList);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorList, {
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
const filter: DeviceRunSessionFilterInput = {};
|
||||
if (flags.status && flags.status.length > 0) {
|
||||
filter.statuses = flags.status.map(value => STATUS_BY_FLAG_VALUE[value]);
|
||||
}
|
||||
if (flags.type && flags.type.length > 0) {
|
||||
filter.types = flags.type.map(value => DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[value]);
|
||||
}
|
||||
if (flags.platform && flags.platform.length > 0) {
|
||||
filter.platforms = flags.platform.map(value => PLATFORM_BY_FLAG_VALUE[value]);
|
||||
}
|
||||
|
||||
const limit = flags.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
const fetchSpinner = jsonFlag ? null : ora('Fetching device run sessions').start();
|
||||
let connection;
|
||||
try {
|
||||
connection = await DeviceRunSessionQuery.listByAppIdAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
first: limit,
|
||||
after: flags.after,
|
||||
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
||||
});
|
||||
fetchSpinner?.succeed(`Fetched ${connection.edges.length} device run session(s)`);
|
||||
} catch (err) {
|
||||
fetchSpinner?.fail('Failed to fetch device run sessions');
|
||||
throw err;
|
||||
}
|
||||
|
||||
const sessions = connection.edges.map(edge => edge.node);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
sessions: sessions.map(session => ({
|
||||
id: session.id,
|
||||
type: deviceRunSessionTypeToFlagValue(session.type),
|
||||
status: session.status,
|
||||
platform: session.platform,
|
||||
createdAt: session.createdAt,
|
||||
startedAt: session.startedAt ?? undefined,
|
||||
finishedAt: session.finishedAt ?? undefined,
|
||||
jobRunUrl: session.turtleJobRun
|
||||
? getBareJobRunUrl(
|
||||
session.app.ownerAccount.name,
|
||||
session.app.slug,
|
||||
session.turtleJobRun.id
|
||||
)
|
||||
: undefined,
|
||||
})),
|
||||
pageInfo: connection.pageInfo,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
Log.newLine();
|
||||
Log.log('No device run sessions found.');
|
||||
return;
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
const formattedEntries = sessions.map(session => {
|
||||
const jobRunUrl = session.turtleJobRun
|
||||
? getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, session.turtleJobRun.id)
|
||||
: null;
|
||||
const lines = [
|
||||
`ID: ${session.id}`,
|
||||
`Type: ${session.type}`,
|
||||
`Status: ${session.status}`,
|
||||
`Platform: ${session.platform}`,
|
||||
`Created: ${fromNow(new Date(session.createdAt))} ago`,
|
||||
];
|
||||
if (jobRunUrl) {
|
||||
lines.push(`URL: ${link(jobRunUrl)}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
});
|
||||
Log.log(formattedEntries.join(`\n\n${chalk.dim('———')}\n\n`));
|
||||
|
||||
if (connection.pageInfo.hasNextPage && connection.pageInfo.endCursor) {
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`More results available. Re-run with --after ${connection.pageInfo.endCursor} to fetch the next page.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@ import { Flags } from '@oclif/core';
|
||||
import { getBareJobRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionStatus,
|
||||
@@ -15,28 +18,18 @@ import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQue
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import {
|
||||
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
|
||||
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
|
||||
DeviceRunSessionRemoteConfig,
|
||||
formatRemoteSessionInstructions,
|
||||
} 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
|
||||
|
||||
// Mapping enum → CLI flag value. Declared as Record<DeviceRunSessionType, string>
|
||||
// so adding a new enum value in codegen fails the build until it is wired up here.
|
||||
const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, string> = {
|
||||
[DeviceRunSessionType.AgentDevice]: 'agent-device',
|
||||
[DeviceRunSessionType.ServeSim]: 'serve-sim',
|
||||
};
|
||||
|
||||
const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
|
||||
(Object.entries(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES) as [DeviceRunSessionType, string][]).map(
|
||||
([type, value]) => [value, type]
|
||||
)
|
||||
) as Record<string, DeviceRunSessionType>;
|
||||
|
||||
export default class SimulatorStart extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
@@ -57,7 +50,7 @@ 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.',
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -67,12 +60,17 @@ export default class SimulatorStart extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorStart);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorStart, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios;
|
||||
@@ -148,11 +146,21 @@ export default class SimulatorStart extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: deviceRunSessionId,
|
||||
type: flags.type,
|
||||
jobRunUrl,
|
||||
remoteConfig,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(formatRemoteSessionInstructions(remoteConfig));
|
||||
Log.newLine();
|
||||
|
||||
if (flags['non-interactive']) {
|
||||
if (nonInteractive) {
|
||||
Log.log(
|
||||
`When you are done, stop the session with: eas simulator:stop --id ${deviceRunSessionId}`
|
||||
);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
|
||||
import { ora } from '../../ora';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class SimulatorStop extends EasCommand {
|
||||
static override hidden = true;
|
||||
@@ -15,7 +19,7 @@ export default class SimulatorStop extends EasCommand {
|
||||
description: 'Device run session ID',
|
||||
required: true,
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
@@ -24,11 +28,16 @@ export default class SimulatorStop extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorStop);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorStop, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
nonInteractive,
|
||||
});
|
||||
|
||||
const stopSpinner = ora(`🛑 Stopping device run session ${flags.id}`).start();
|
||||
@@ -38,6 +47,10 @@ export default class SimulatorStop extends EasCommand {
|
||||
flags.id
|
||||
);
|
||||
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}`);
|
||||
throw err;
|
||||
|
||||
@@ -27,6 +27,7 @@ export class CredentialsContext {
|
||||
public readonly nonInteractive: boolean;
|
||||
public readonly autoAcceptCredentialReuse: boolean;
|
||||
public readonly freezeCredentials: boolean = false;
|
||||
public readonly refreshAdHocProvisioningProfile: boolean = false;
|
||||
public readonly projectDir: string;
|
||||
public readonly user: Actor;
|
||||
public readonly graphqlClient: ExpoGraphqlClient;
|
||||
@@ -52,6 +53,7 @@ export class CredentialsContext {
|
||||
vcsClient: Client;
|
||||
freezeCredentials?: boolean;
|
||||
autoAcceptCredentialReuse?: boolean;
|
||||
refreshAdHocProvisioningProfile?: boolean;
|
||||
env?: Env;
|
||||
}
|
||||
) {
|
||||
@@ -65,6 +67,7 @@ export class CredentialsContext {
|
||||
this.autoAcceptCredentialReuse = options.autoAcceptCredentialReuse ?? false;
|
||||
this.projectInfo = options.projectInfo;
|
||||
this.freezeCredentials = options.freezeCredentials ?? false;
|
||||
this.refreshAdHocProvisioningProfile = options.refreshAdHocProvisioningProfile ?? false;
|
||||
this.usesBroadcastPushNotifications =
|
||||
options.projectInfo?.exp.ios?.usesBroadcastPushNotifications ?? false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,37 @@
|
||||
import { promptAsync } from '../.././../prompts';
|
||||
import { AppleDevice, AppleDeviceFragment } from '../../../graphql/generated';
|
||||
import { AppleDevice, AppleDeviceClass, AppleDeviceFragment } from '../../../graphql/generated';
|
||||
import { APPLE_DEVICE_CLASS_LABELS } from '../../../graphql/types/credentials/AppleDevice';
|
||||
import { ApplePlatform } from '../appstore/constants';
|
||||
|
||||
export function filterDevicesForApplePlatform(
|
||||
devices: AppleDeviceFragment[],
|
||||
applePlatform: ApplePlatform
|
||||
): AppleDeviceFragment[] {
|
||||
if (applePlatform === ApplePlatform.TV_OS) {
|
||||
throw new Error('Filtering for tvOS is not supported yet');
|
||||
}
|
||||
|
||||
if (applePlatform === ApplePlatform.VISION_OS) {
|
||||
throw new Error('Filtering for visionOS is not supported yet');
|
||||
}
|
||||
|
||||
return devices.filter(device => isDeviceCompatibleWithApplePlatform(device, applePlatform));
|
||||
}
|
||||
|
||||
function isDeviceCompatibleWithApplePlatform(
|
||||
device: AppleDeviceFragment,
|
||||
applePlatform: Exclude<ApplePlatform, ApplePlatform.TV_OS | ApplePlatform.VISION_OS>
|
||||
): boolean {
|
||||
switch (applePlatform) {
|
||||
case ApplePlatform.IOS:
|
||||
return (
|
||||
device.deviceClass === AppleDeviceClass.Iphone ||
|
||||
device.deviceClass === AppleDeviceClass.Ipad
|
||||
);
|
||||
case ApplePlatform.MAC_OS:
|
||||
return device.deviceClass === AppleDeviceClass.Mac;
|
||||
}
|
||||
}
|
||||
|
||||
export async function chooseDevicesAsync(
|
||||
allDevices: AppleDeviceFragment[],
|
||||
|
||||
@@ -6,7 +6,11 @@ import nullthrows from 'nullthrows';
|
||||
|
||||
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
|
||||
import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from './BuildCredentialsUtils';
|
||||
import { chooseDevicesAsync, formatDeviceLabel } from './DeviceUtils';
|
||||
import {
|
||||
chooseDevicesAsync,
|
||||
filterDevicesForApplePlatform,
|
||||
formatDeviceLabel,
|
||||
} from './DeviceUtils';
|
||||
import { SetUpDistributionCertificate } from './SetUpDistributionCertificate';
|
||||
import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action';
|
||||
import {
|
||||
@@ -18,6 +22,8 @@ import {
|
||||
IosAppBuildCredentialsFragment,
|
||||
IosDistributionType,
|
||||
} from '../../../graphql/generated';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import Log from '../../../log';
|
||||
import { getApplePlatformFromTarget } from '../../../project/ios/target';
|
||||
import {
|
||||
@@ -29,9 +35,13 @@ import {
|
||||
import differenceBy from '../../../utils/expodash/differenceBy';
|
||||
import { CredentialsContext } from '../../context';
|
||||
import { MissingCredentialsNonInteractiveError } from '../../errors';
|
||||
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
|
||||
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
|
||||
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { ProvisioningProfile } from '../appstore/Credentials.types';
|
||||
import { ApplePlatform } from '../appstore/constants';
|
||||
import { hasAscEnvVars } from '../appstore/resolveCredentials';
|
||||
import { MinimalAscApiKey } from '../credentials';
|
||||
import { Target } from '../types';
|
||||
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
|
||||
|
||||
@@ -51,11 +61,23 @@ export class SetUpAdhocProvisioningProfile {
|
||||
|
||||
async runAsync(ctx: CredentialsContext): Promise<IosAppBuildCredentialsFragment> {
|
||||
const { app } = this.options;
|
||||
|
||||
if (ctx.refreshAdHocProvisioningProfile && ctx.freezeCredentials) {
|
||||
throw new Error(
|
||||
'Cannot refresh ad-hoc provisioning profile when credentials are frozen. Remove --freeze-credentials or --refresh-ad-hoc-provisioning-profile.'
|
||||
);
|
||||
}
|
||||
|
||||
const distCert = await new SetUpDistributionCertificate(
|
||||
app,
|
||||
IosDistributionType.AdHoc
|
||||
).runAsync(ctx);
|
||||
|
||||
if (ctx.nonInteractive && ctx.refreshAdHocProvisioningProfile) {
|
||||
await this.ensureAppStoreAuthenticatedForAdhocRefreshAsync(ctx, app);
|
||||
return await this.runWithDistributionCertificateAsync(ctx, distCert);
|
||||
}
|
||||
|
||||
const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
|
||||
|
||||
if (ctx.nonInteractive) {
|
||||
@@ -111,6 +133,11 @@ export class SetUpAdhocProvisioningProfile {
|
||||
appleTeam
|
||||
);
|
||||
if (registeredAppleDevices.length === 0) {
|
||||
if (ctx.nonInteractive) {
|
||||
throw new Error(
|
||||
'No devices are registered for this Apple team. Register devices with eas device:create first.'
|
||||
);
|
||||
}
|
||||
const shouldRegisterDevices = await confirmAsync({
|
||||
message: `You don't have any registered devices yet. Would you like to register them now?`,
|
||||
initial: true,
|
||||
@@ -127,13 +154,13 @@ export class SetUpAdhocProvisioningProfile {
|
||||
const provisionedDeviceIdentifiers = (
|
||||
currentBuildCredentials?.provisioningProfile?.appleDevices ?? []
|
||||
).map(i => i.identifier);
|
||||
const chosenDevices = await chooseDevicesAsync(
|
||||
registeredAppleDevices,
|
||||
provisionedDeviceIdentifiers
|
||||
);
|
||||
const applePlatform = getApplePlatformFromTarget(target);
|
||||
const chosenDevices =
|
||||
ctx.nonInteractive && ctx.refreshAdHocProvisioningProfile
|
||||
? filterDevicesForApplePlatform(registeredAppleDevices, applePlatform)
|
||||
: await chooseDevicesAsync(registeredAppleDevices, provisionedDeviceIdentifiers);
|
||||
|
||||
// 4. Reuse or create the profile on Apple Developer Portal
|
||||
const applePlatform = getApplePlatformFromTarget(target);
|
||||
const profileType =
|
||||
applePlatform === ApplePlatform.TV_OS
|
||||
? ProfileType.TVOS_APP_ADHOC
|
||||
@@ -187,21 +214,25 @@ export class SetUpAdhocProvisioningProfile {
|
||||
Log.log(
|
||||
'Most commonly devices fail to to be provisioned while they are still being processed by Apple, which can take up to 24-72 hours. Check your Apple Developer Portal page at https://developer.apple.com/account/resources/devices/list, the devices in "Processing" status cannot be provisioned yet'
|
||||
);
|
||||
const shouldContinue = await selectAsync(
|
||||
'Do you want to continue without provisioning these devices?',
|
||||
[
|
||||
{
|
||||
title: 'Yes',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
title: 'No (EAS CLI will exit)',
|
||||
value: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
if (!shouldContinue) {
|
||||
Errors.exit(1);
|
||||
if (ctx.nonInteractive && ctx.refreshAdHocProvisioningProfile) {
|
||||
// Continue without prompting in non-interactive refresh mode.
|
||||
} else {
|
||||
const shouldContinue = await selectAsync(
|
||||
'Do you want to continue without provisioning these devices?',
|
||||
[
|
||||
{
|
||||
title: 'Yes',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
title: 'No (EAS CLI will exit)',
|
||||
value: false,
|
||||
},
|
||||
]
|
||||
);
|
||||
if (!shouldContinue) {
|
||||
Errors.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +402,67 @@ export class SetUpAdhocProvisioningProfile {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAppStoreAuthenticatedForAdhocRefreshAsync(
|
||||
ctx: CredentialsContext,
|
||||
app: AppLookupParams
|
||||
): Promise<void> {
|
||||
if (hasAscEnvVars()) {
|
||||
await ctx.appStore.ensureAuthenticatedAsync({ mode: AuthenticationMode.API_KEY });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
app,
|
||||
});
|
||||
if (!resolvedKey) {
|
||||
throw new Error(
|
||||
'No App Store Connect API Key found for ad-hoc provisioning profile refresh. In non-interactive mode, provide one via:\n' +
|
||||
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' +
|
||||
' - EAS credentials service: configure an App Store Connect API Key for submissions on this app'
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.appStore.ensureAuthenticatedAsync({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: resolvedKey.ascApiKey,
|
||||
teamId: resolvedKey.teamId,
|
||||
teamName: resolvedKey.teamName,
|
||||
// Provide a non-enterprise team type to avoid interactive team-type resolution.
|
||||
// Ad-hoc profile handling below uses explicit ProfileType and does not branch on team.inHouse.
|
||||
teamType: AppleTeamType.COMPANY_OR_ORGANIZATION,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAscApiKeyForAppCredentialsAsync({
|
||||
graphqlClient,
|
||||
app,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
app: AppLookupParams;
|
||||
}): Promise<{
|
||||
ascApiKey: MinimalAscApiKey;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
} | null> {
|
||||
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
|
||||
if (!ascKeyFragment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Log.log('Using App Store Connect API Key from EAS credentials service.');
|
||||
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
|
||||
return {
|
||||
ascApiKey: {
|
||||
keyP8: fullKey.keyP8,
|
||||
keyId: fullKey.keyIdentifier,
|
||||
issuerId: fullKey.issuerIdentifier,
|
||||
},
|
||||
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
|
||||
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean {
|
||||
|
||||
@@ -54,6 +54,18 @@ export class SetUpTargetBuildCredentials {
|
||||
ctx: CredentialsContext
|
||||
): Promise<IosAppBuildCredentialsFragment> {
|
||||
const { app, distribution, enterpriseProvisioning, target } = this.options;
|
||||
if (ctx.refreshAdHocProvisioningProfile) {
|
||||
if (distribution !== 'internal') {
|
||||
throw new Error(
|
||||
'--refresh-ad-hoc-provisioning-profile is only supported for internal distribution builds.'
|
||||
);
|
||||
}
|
||||
if (enterpriseProvisioning === 'universal') {
|
||||
throw new Error(
|
||||
'--refresh-ad-hoc-provisioning-profile is only supported for ad-hoc internal builds.'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (distribution === 'internal') {
|
||||
if (enterpriseProvisioning === 'adhoc') {
|
||||
return await new SetUpAdhocProvisioningProfile({ app, target }).runAsync(ctx);
|
||||
|
||||
@@ -1,7 +1,65 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { AppleDeviceClass, AppleDeviceFragment } from '../../../../graphql/generated';
|
||||
import { formatDeviceLabel } from '../DeviceUtils';
|
||||
import { ApplePlatform } from '../../appstore/constants';
|
||||
import { filterDevicesForApplePlatform, formatDeviceLabel } from '../DeviceUtils';
|
||||
|
||||
function createDevice(
|
||||
overrides: Partial<AppleDeviceFragment> & Pick<AppleDeviceFragment, 'identifier'>
|
||||
): AppleDeviceFragment {
|
||||
return {
|
||||
__typename: 'AppleDevice',
|
||||
id: uuidv4(),
|
||||
name: 'device',
|
||||
model: null,
|
||||
deviceClass: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
} as AppleDeviceFragment;
|
||||
}
|
||||
|
||||
describe(filterDevicesForApplePlatform, () => {
|
||||
const iphone = createDevice({
|
||||
identifier: 'iphone',
|
||||
deviceClass: AppleDeviceClass.Iphone,
|
||||
});
|
||||
const ipad = createDevice({
|
||||
identifier: 'ipad',
|
||||
deviceClass: AppleDeviceClass.Ipad,
|
||||
});
|
||||
const mac = createDevice({
|
||||
identifier: 'mac',
|
||||
deviceClass: AppleDeviceClass.Mac,
|
||||
});
|
||||
const appleTv = createDevice({
|
||||
identifier: 'appletv',
|
||||
model: 'AppleTV6,3',
|
||||
deviceClass: AppleDeviceClass.Unknown,
|
||||
});
|
||||
const unknown = createDevice({
|
||||
identifier: 'unknown',
|
||||
deviceClass: AppleDeviceClass.Unknown,
|
||||
});
|
||||
const noClass = createDevice({
|
||||
identifier: 'noclass',
|
||||
deviceClass: null,
|
||||
});
|
||||
|
||||
it('keeps only iPhone and iPad devices for iOS targets', () => {
|
||||
expect(
|
||||
filterDevicesForApplePlatform(
|
||||
[iphone, ipad, mac, appleTv, unknown, noClass],
|
||||
ApplePlatform.IOS
|
||||
)
|
||||
).toEqual([iphone, ipad]);
|
||||
});
|
||||
|
||||
it('throws for tvOS targets', () => {
|
||||
expect(() =>
|
||||
filterDevicesForApplePlatform([iphone, ipad, mac, appleTv], ApplePlatform.TV_OS)
|
||||
).toThrow('Filtering for tvOS is not supported yet');
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatDeviceLabel, () => {
|
||||
it('returns createdAt clause', async () => {
|
||||
|
||||
+252
-3
@@ -23,17 +23,37 @@ import { ApplePlatform } from '../../appstore/constants';
|
||||
import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from '../BuildCredentialsUtils';
|
||||
import { chooseDevicesAsync } from '../DeviceUtils';
|
||||
import { SetUpAdhocProvisioningProfile, doUDIDsMatch } from '../SetUpAdhocProvisioningProfile';
|
||||
import { getAscApiKeyForAppSubmissionsAsync } from '../../api/GraphqlClient';
|
||||
import { AuthenticationMode, AppleTeamType } from '../../appstore/authenticateTypes';
|
||||
import { hasAscEnvVars } from '../../appstore/resolveCredentials';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
|
||||
jest.mock('../BuildCredentialsUtils');
|
||||
jest.mock('../../../context');
|
||||
jest.mock('../../../ios/api/GraphqlClient');
|
||||
jest.mock('../../../ios/api/GraphqlClient', () => ({
|
||||
...jest.requireActual('../../../ios/api/GraphqlClient'),
|
||||
getAscApiKeyForAppSubmissionsAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../appstore/resolveCredentials', () => ({
|
||||
hasAscEnvVars: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({
|
||||
AppStoreConnectApiKeyQuery: {
|
||||
getByIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('../DeviceUtils', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
...jest.requireActual('../DeviceUtils'),
|
||||
chooseDevicesAsync: jest.fn(),
|
||||
formatDeviceLabel: jest.requireActual('../DeviceUtils').formatDeviceLabel,
|
||||
};
|
||||
});
|
||||
jest.mock('../SetUpDistributionCertificate', () => ({
|
||||
SetUpDistributionCertificate: jest.fn(),
|
||||
}));
|
||||
import { SetUpDistributionCertificate } from '../SetUpDistributionCertificate';
|
||||
jest.mock('../../../../project/ios/target');
|
||||
jest.mock('../../../../prompts');
|
||||
|
||||
@@ -148,6 +168,231 @@ describe('runWithDistributionCertificateAsync', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('runAsync', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
jest.mocked(SetUpDistributionCertificate).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
runAsync: jest.fn().mockResolvedValue({} as AppleDistributionCertificateFragment),
|
||||
}) as any
|
||||
);
|
||||
});
|
||||
|
||||
const setUpAdhocProvisioningProfile = new SetUpAdhocProvisioningProfile({
|
||||
app: { account: {} as Account, projectName: 'projName', bundleIdentifier: 'bundleId' },
|
||||
target: { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} },
|
||||
});
|
||||
|
||||
it('skips non-interactive reuse when refresh is enabled', async () => {
|
||||
const { ctx } = setUpTest();
|
||||
Object.defineProperty(ctx, 'nonInteractive', { value: true });
|
||||
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
|
||||
const areBuildCredentialsSetupAsyncSpy = jest
|
||||
.spyOn(SetUpAdhocProvisioningProfile.prototype as any, 'areBuildCredentialsSetupAsync')
|
||||
.mockResolvedValue(true);
|
||||
const ensureAppStoreAuthenticatedForAdhocRefreshAsyncSpy = jest
|
||||
.spyOn(
|
||||
SetUpAdhocProvisioningProfile.prototype as any,
|
||||
'ensureAppStoreAuthenticatedForAdhocRefreshAsync'
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
jest
|
||||
.spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync')
|
||||
.mockResolvedValue({} as IosAppBuildCredentialsFragment);
|
||||
|
||||
await setUpAdhocProvisioningProfile.runAsync(ctx);
|
||||
|
||||
expect(areBuildCredentialsSetupAsyncSpy).not.toHaveBeenCalled();
|
||||
expect(ensureAppStoreAuthenticatedForAdhocRefreshAsyncSpy).toHaveBeenCalledWith(ctx, {
|
||||
account: {} as Account,
|
||||
projectName: 'projName',
|
||||
bundleIdentifier: 'bundleId',
|
||||
});
|
||||
expect(
|
||||
SetUpAdhocProvisioningProfile.prototype.runWithDistributionCertificateAsync
|
||||
).toHaveBeenCalled();
|
||||
expect(getBuildCredentialsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails when refresh is enabled and credentials are frozen', async () => {
|
||||
const { ctx } = setUpTest();
|
||||
Object.defineProperty(ctx, 'nonInteractive', { value: true });
|
||||
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
|
||||
Object.defineProperty(ctx, 'freezeCredentials', { value: true });
|
||||
|
||||
await expect(setUpAdhocProvisioningProfile.runAsync(ctx)).rejects.toThrow(
|
||||
'Cannot refresh ad-hoc provisioning profile when credentials are frozen'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh ad-hoc provisioning profile', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
jest.mocked(getApplePlatformFromTarget).mockReturnValue(ApplePlatform.IOS);
|
||||
jest.mocked(SetUpDistributionCertificate).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
runAsync: jest.fn().mockResolvedValue({} as AppleDistributionCertificateFragment),
|
||||
}) as any
|
||||
);
|
||||
jest.mocked(hasAscEnvVars).mockReturnValue(false);
|
||||
jest.mocked(getAscApiKeyForAppSubmissionsAsync).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
const setUpAdhocProvisioningProfile = new SetUpAdhocProvisioningProfile({
|
||||
app: { account: {} as Account, projectName: 'projName', bundleIdentifier: 'bundleId' },
|
||||
target: { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} },
|
||||
});
|
||||
|
||||
it('provisions all registered devices without prompting in refresh mode', async () => {
|
||||
const { ctx, distCert } = setUpRefreshTest();
|
||||
Object.defineProperty(ctx, 'nonInteractive', { value: true });
|
||||
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
|
||||
|
||||
await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert);
|
||||
|
||||
expect(chooseDevicesAsync).not.toHaveBeenCalled();
|
||||
expect(ctx.appStore.createOrReuseAdhocProvisioningProfileAsync).toHaveBeenCalledWith(
|
||||
['id1', 'id2', 'id3'],
|
||||
'bundleId',
|
||||
distCert.serialNumber,
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('filters devices by target platform only in refresh mode', async () => {
|
||||
const { ctx, distCert } = setUpRefreshTest();
|
||||
Object.defineProperty(ctx, 'nonInteractive', { value: true });
|
||||
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
|
||||
ctx.ios.getDevicesForAppleTeamAsync = jest.fn().mockResolvedValue([
|
||||
{ identifier: 'id1', model: 'iPhone17,1', deviceClass: AppleDeviceClass.Iphone },
|
||||
{ identifier: 'id2', model: 'AppleTV6,3', deviceClass: AppleDeviceClass.Unknown },
|
||||
{ identifier: 'id3', deviceClass: AppleDeviceClass.Mac },
|
||||
]);
|
||||
|
||||
await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert);
|
||||
|
||||
expect(ctx.appStore.createOrReuseAdhocProvisioningProfileAsync).toHaveBeenCalledWith(
|
||||
['id1'],
|
||||
'bundleId',
|
||||
distCert.serialNumber,
|
||||
expect.anything()
|
||||
);
|
||||
expect(chooseDevicesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('continues without prompting when some devices are not provisioned in refresh mode', async () => {
|
||||
const { ctx, distCert } = setUpRefreshTest();
|
||||
Object.defineProperty(ctx, 'nonInteractive', { value: true });
|
||||
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
|
||||
jest.mocked(getBuildCredentialsAsync).mockResolvedValue({
|
||||
provisioningProfile: {
|
||||
appleTeam: { appleTeamIdentifier: 'team' },
|
||||
appleDevices: [{ identifier: 'id1' }],
|
||||
developerPortalIdentifier: 'provisioningProfileId',
|
||||
},
|
||||
} as IosAppBuildCredentialsFragment);
|
||||
ctx.ios.updateProvisioningProfileAsync = jest.fn().mockResolvedValue({
|
||||
appleTeam: { appleTeamIdentifier: 'team' },
|
||||
appleDevices: [{ identifier: 'id1' }],
|
||||
developerPortalIdentifier: 'provisioningProfileId',
|
||||
});
|
||||
|
||||
await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert);
|
||||
|
||||
expect(selectAsync).not.toHaveBeenCalled();
|
||||
expect(assignBuildCredentialsAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('errors when no devices are registered in non-interactive mode', async () => {
|
||||
const { ctx, distCert } = setUpRefreshTest();
|
||||
Object.defineProperty(ctx, 'nonInteractive', { value: true });
|
||||
ctx.ios.getDevicesForAppleTeamAsync = jest.fn().mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert)
|
||||
).rejects.toThrow(
|
||||
'No devices are registered for this Apple team. Register devices with eas device:create first.'
|
||||
);
|
||||
});
|
||||
|
||||
describe('ensureAppStoreAuthenticatedForAdhocRefreshAsync', () => {
|
||||
it('authenticates with ASC environment variables when present', async () => {
|
||||
const { ctx } = setUpRefreshTest();
|
||||
jest.mocked(hasAscEnvVars).mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync')
|
||||
.mockResolvedValue({} as IosAppBuildCredentialsFragment);
|
||||
|
||||
await setUpAdhocProvisioningProfile.runAsync(ctx);
|
||||
|
||||
expect(ctx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
});
|
||||
expect(getAscApiKeyForAppSubmissionsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('authenticates with the stored submissions ASC API key when env vars are absent', async () => {
|
||||
const { ctx } = setUpRefreshTest();
|
||||
jest.mocked(hasAscEnvVars).mockReturnValue(false);
|
||||
jest.mocked(getAscApiKeyForAppSubmissionsAsync).mockResolvedValue({
|
||||
id: 'asc-key-id',
|
||||
appleTeam: {
|
||||
appleTeamIdentifier: 'TEAM123',
|
||||
appleTeamName: 'Team Name',
|
||||
},
|
||||
} as any);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockResolvedValue({
|
||||
keyP8: 'key-p8',
|
||||
keyIdentifier: 'key-id',
|
||||
issuerIdentifier: 'issuer-id',
|
||||
} as any);
|
||||
jest
|
||||
.spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync')
|
||||
.mockResolvedValue({} as IosAppBuildCredentialsFragment);
|
||||
|
||||
await setUpAdhocProvisioningProfile.runAsync(ctx);
|
||||
|
||||
expect(ctx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: {
|
||||
keyP8: 'key-p8',
|
||||
keyId: 'key-id',
|
||||
issuerId: 'issuer-id',
|
||||
},
|
||||
teamId: 'TEAM123',
|
||||
teamName: 'Team Name',
|
||||
teamType: AppleTeamType.COMPANY_OR_ORGANIZATION,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function setUpRefreshTest(): {
|
||||
ctx: CredentialsContext;
|
||||
distCert: AppleDistributionCertificateFragment;
|
||||
} {
|
||||
const { ctx, distCert } = setUpTest();
|
||||
Object.defineProperty(ctx, 'nonInteractive', { value: true });
|
||||
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
|
||||
distCert.appleTeam = {
|
||||
appleTeamIdentifier: 'team',
|
||||
} as AppleDistributionCertificateFragment['appleTeam'];
|
||||
distCert.serialNumber = 'serial-number';
|
||||
ctx.appStore.ensureAuthenticatedAsync = jest.fn().mockResolvedValue({});
|
||||
jest.mocked(getBuildCredentialsAsync).mockResolvedValue(null);
|
||||
ctx.ios.getDevicesForAppleTeamAsync = jest.fn().mockResolvedValue([
|
||||
{ identifier: 'id1', deviceClass: AppleDeviceClass.Iphone },
|
||||
{ identifier: 'id2', deviceClass: AppleDeviceClass.Ipad },
|
||||
{ identifier: 'id3', deviceClass: AppleDeviceClass.Iphone },
|
||||
] as AppleDeviceFragment[]);
|
||||
return { ctx, distCert };
|
||||
}
|
||||
|
||||
function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCertificateFragment } {
|
||||
const ctx = jest.mocked(
|
||||
new CredentialsContext(
|
||||
@@ -177,8 +422,7 @@ function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCert
|
||||
{ identifier: 'id2', name: 'Device 2', deviceClass: AppleDeviceClass.Iphone },
|
||||
{ identifier: 'id3', name: 'Device 3', deviceClass: AppleDeviceClass.Mac },
|
||||
] as AppleDevice[]);
|
||||
// @ts-expect-error
|
||||
jest.mocked(getApplePlatformFromTarget).mockResolvedValue(ApplePlatform.IOS);
|
||||
jest.mocked(getApplePlatformFromTarget).mockReturnValue(ApplePlatform.IOS);
|
||||
Object.defineProperty(ctx, 'appStore', { value: jest.mock('../../appstore/AppStoreApi') });
|
||||
ctx.appStore.createOrReuseAdhocProvisioningProfileAsync = jest.fn().mockResolvedValue({
|
||||
provisioningProfileId: 'provisioningProfileId',
|
||||
@@ -186,6 +430,11 @@ function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCert
|
||||
ctx.ios.createOrGetExistingAppleAppIdentifierAsync = jest
|
||||
.fn()
|
||||
.mockResolvedValue({} as AppleAppIdentifierFragment);
|
||||
ctx.ios.createProvisioningProfileAsync = jest.fn().mockResolvedValue({
|
||||
appleTeam: { appleTeamIdentifier: 'team' },
|
||||
appleDevices: [{ identifier: 'id1' }, { identifier: 'id2' }, { identifier: 'id3' }],
|
||||
developerPortalIdentifier: 'provisioningProfileId',
|
||||
});
|
||||
jest.mocked(assignBuildCredentialsAsync).mockResolvedValue({} as IosAppBuildCredentialsFragment);
|
||||
const distCert = {} as AppleDistributionCertificateFragment;
|
||||
return { ctx, distCert };
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import { EasJson } from '@expo/eas-json';
|
||||
|
||||
import { Analytics } from '../../../../analytics/AnalyticsManager';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { Account, IosAppBuildCredentialsFragment } from '../../../../graphql/generated';
|
||||
import { Actor } from '../../../../user/User';
|
||||
import { Client } from '../../../../vcs/vcs';
|
||||
import { CredentialsContext, CredentialsContextProjectInfo } from '../../../context';
|
||||
import { SetUpAdhocProvisioningProfile } from '../SetUpAdhocProvisioningProfile';
|
||||
import { SetUpInternalProvisioningProfile } from '../SetUpInternalProvisioningProfile';
|
||||
import { SetUpTargetBuildCredentials } from '../SetUpTargetBuildCredentials';
|
||||
|
||||
jest.mock('../SetUpAdhocProvisioningProfile', () => ({
|
||||
SetUpAdhocProvisioningProfile: jest.fn(),
|
||||
}));
|
||||
jest.mock('../SetUpInternalProvisioningProfile', () => ({
|
||||
SetUpInternalProvisioningProfile: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../context');
|
||||
|
||||
describe(SetUpTargetBuildCredentials, () => {
|
||||
const app = {
|
||||
account: { name: 'account' } as Account,
|
||||
projectName: 'projName',
|
||||
bundleIdentifier: 'bundleId',
|
||||
};
|
||||
const target = { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(SetUpAdhocProvisioningProfile).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
runAsync: jest.fn().mockResolvedValue({} as IosAppBuildCredentialsFragment),
|
||||
}) as any
|
||||
);
|
||||
jest.mocked(SetUpInternalProvisioningProfile).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
runAsync: jest.fn().mockResolvedValue({} as IosAppBuildCredentialsFragment),
|
||||
}) as any
|
||||
);
|
||||
});
|
||||
|
||||
function createCtx(overrides: Partial<CredentialsContext> = {}): CredentialsContext {
|
||||
const ctx = jest.mocked(
|
||||
new CredentialsContext(
|
||||
{} as {
|
||||
projectInfo: CredentialsContextProjectInfo | null;
|
||||
easJsonCliConfig?: EasJson['cli'];
|
||||
nonInteractive: boolean;
|
||||
projectDir: string;
|
||||
user: Actor;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
env?: Env;
|
||||
vcsClient: Client;
|
||||
}
|
||||
)
|
||||
);
|
||||
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', {
|
||||
value: true,
|
||||
writable: true,
|
||||
});
|
||||
return Object.assign(ctx, overrides);
|
||||
}
|
||||
|
||||
it('errors when refresh is enabled for store distribution builds', async () => {
|
||||
const action = new SetUpTargetBuildCredentials({
|
||||
app,
|
||||
distribution: 'store',
|
||||
entitlements: {},
|
||||
target,
|
||||
});
|
||||
|
||||
await expect(action.setupBuildCredentialsAsync(createCtx())).rejects.toThrow(
|
||||
'--refresh-ad-hoc-provisioning-profile is only supported for internal distribution builds.'
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when refresh is enabled for universal enterprise internal builds', async () => {
|
||||
const action = new SetUpTargetBuildCredentials({
|
||||
app,
|
||||
distribution: 'internal',
|
||||
enterpriseProvisioning: 'universal',
|
||||
entitlements: {},
|
||||
target,
|
||||
});
|
||||
|
||||
await expect(action.setupBuildCredentialsAsync(createCtx())).rejects.toThrow(
|
||||
'--refresh-ad-hoc-provisioning-profile is only supported for ad-hoc internal builds.'
|
||||
);
|
||||
});
|
||||
|
||||
it('delegates to internal provisioning setup when refresh is enabled for internal ad-hoc builds', async () => {
|
||||
const action = new SetUpTargetBuildCredentials({
|
||||
app,
|
||||
distribution: 'internal',
|
||||
entitlements: {},
|
||||
target,
|
||||
});
|
||||
const ctx = createCtx();
|
||||
|
||||
await action.setupBuildCredentialsAsync(ctx);
|
||||
|
||||
expect(SetUpInternalProvisioningProfile).toHaveBeenCalledWith({ app, target });
|
||||
expect(SetUpAdhocProvisioningProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+304
-4
@@ -120,6 +120,7 @@ export type Account = {
|
||||
/** Billing information. Only visible to members with the ADMIN or OWNER role. */
|
||||
billing?: Maybe<Billing>;
|
||||
billingPeriod: BillingPeriod;
|
||||
concurrencyConsumers: Array<ConcurrencyConsumer>;
|
||||
/** Convex team connections for this account */
|
||||
convexTeamConnections: Array<ConvexTeamConnection>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
@@ -399,6 +400,15 @@ export type AccountBillingPeriodArgs = {
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* An account is a container owning projects, credentials, billing and other organization
|
||||
* data and settings. Actors may own and be members of accounts.
|
||||
*/
|
||||
export type AccountConcurrencyConsumersArgs = {
|
||||
limit?: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* An account is a container owning projects, credentials, billing and other organization
|
||||
* data and settings. Actors may own and be members of accounts.
|
||||
@@ -1061,8 +1071,13 @@ export type Address = {
|
||||
|
||||
export type AgentDeviceRunSessionRemoteConfig = {
|
||||
__typename?: 'AgentDeviceRunSessionRemoteConfig';
|
||||
token: Scalars['String']['output'];
|
||||
url: Scalars['String']['output'];
|
||||
agentDeviceRemoteSessionToken: Scalars['String']['output'];
|
||||
agentDeviceRemoteSessionUrl: Scalars['String']['output'];
|
||||
/**
|
||||
* URL of the web preview surface for the session. Null when web previews are
|
||||
* not available for the platform (e.g. Android).
|
||||
*/
|
||||
webPreviewUrl?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type AndroidAppBuildCredentials = {
|
||||
@@ -1443,6 +1458,7 @@ export type App = Project & {
|
||||
/** @deprecated Classic updates have been deprecated. */
|
||||
description: Scalars['String']['output'];
|
||||
devDomainName?: Maybe<AppDevDomainName>;
|
||||
deviceRunSessionsPaginated: AppDeviceRunSessionsConnection;
|
||||
/** Environment secrets for an app */
|
||||
environmentSecrets: Array<EnvironmentSecret>;
|
||||
environmentVariableEnvironments: Array<Scalars['EnvironmentVariableEnvironment']['output']>;
|
||||
@@ -1671,6 +1687,16 @@ export type AppDeploymentsArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** Represents an Exponent App (or Experience in legacy terms) */
|
||||
export type AppDeviceRunSessionsPaginatedArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<DeviceRunSessionFilterInput>;
|
||||
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']>>;
|
||||
@@ -1989,6 +2015,18 @@ export type AppDevDomainNameMutationChangeDevDomainNameArgs = {
|
||||
name: Scalars['DevDomainName']['input'];
|
||||
};
|
||||
|
||||
export type AppDeviceRunSessionEdge = {
|
||||
__typename?: 'AppDeviceRunSessionEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: DeviceRunSession;
|
||||
};
|
||||
|
||||
export type AppDeviceRunSessionsConnection = {
|
||||
__typename?: 'AppDeviceRunSessionsConnection';
|
||||
edges: Array<AppDeviceRunSessionEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type AppFingerprintEdge = {
|
||||
__typename?: 'AppFingerprintEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
@@ -2129,8 +2167,18 @@ export type AppObserve = {
|
||||
customEventNames: AppObserveCustomEventNames;
|
||||
environments: Array<Scalars['String']['output']>;
|
||||
events: AppObserveEventsConnection;
|
||||
navigationRoutes: AppObserveNavigationRoutesConnection;
|
||||
timeSeries: AppObserveTimeSeries;
|
||||
totalEventCount: Scalars['Int']['output'];
|
||||
/**
|
||||
* Approximate count of unique users (`eas_client_id`) with at least one
|
||||
* supported-metric event in `[startTime, endTime)`. Uses ClickHouse's
|
||||
* HyperLogLog `uniq()`, so the value can drift by a small percent on apps
|
||||
* with very large user bases. `metricNames` on the input is currently
|
||||
* ignored: the count is always over all supported metrics, matching the
|
||||
* universe used by `appVersions` headline counts.
|
||||
*/
|
||||
uniqueActiveUserCount: Scalars['Int']['output'];
|
||||
updates: AppObserveUpdatesConnection;
|
||||
};
|
||||
|
||||
@@ -2161,6 +2209,7 @@ export type AppObserveCustomEventNamesArgs = {
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveCustomEventNamesOrderBy>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
@@ -2184,22 +2233,66 @@ export type AppObserveEventsArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveNavigationRoutesArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
filter: AppObserveNavigationRoutesFilter;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveNavigationRoutesOrderBy>;
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveTimeSeriesArgs = {
|
||||
input: AppObserveTimeSeriesInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveUniqueActiveUserCountArgs = {
|
||||
input: AppObserveReleasesInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppObserveUpdatesArgs = {
|
||||
input: AppObserveUpdatesInput;
|
||||
};
|
||||
|
||||
export type AppObserveAppBuildEmbeddedSummary = {
|
||||
__typename?: 'AppObserveAppBuildEmbeddedSummary';
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
/**
|
||||
* Unique users whose most recent supported-metric event in the queried
|
||||
* range ran this build's embedded bundle.
|
||||
*/
|
||||
lastSeenUserCount: Scalars['Int']['output'];
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type AppObserveAppBuildNumber = {
|
||||
__typename?: 'AppObserveAppBuildNumber';
|
||||
appBuildNumber: Scalars['String']['output'];
|
||||
easBuilds: Array<AppObserveAppEasBuild>;
|
||||
/**
|
||||
* Summary restricted to events running this build's embedded bundle
|
||||
* (no OTA update applied). Null when the build has no such events
|
||||
* in the queried time range.
|
||||
*/
|
||||
embedded?: Maybe<AppObserveAppBuildEmbeddedSummary>;
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
/**
|
||||
* Unique users whose most recent supported-metric event in the queried
|
||||
* range was on this (appVersion, appBuildNumber) tuple.
|
||||
*/
|
||||
lastSeenUserCount: Scalars['Int']['output'];
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
/**
|
||||
* Updates seen on this build number in the queried time range. Each
|
||||
* entry's counts and EAS builds are scoped to this (appVersion,
|
||||
* appBuildNumber, appUpdateId) tuple.
|
||||
*/
|
||||
updates: Array<AppObserveAppUpdate>;
|
||||
};
|
||||
|
||||
export type AppObserveAppEasBuild = {
|
||||
@@ -2213,9 +2306,15 @@ export type AppObserveAppEasBuild = {
|
||||
export type AppObserveAppUpdate = {
|
||||
__typename?: 'AppObserveAppUpdate';
|
||||
appUpdateId: Scalars['String']['output'];
|
||||
appUpdateMessage?: Maybe<Scalars['String']['output']>;
|
||||
easBuilds: Array<AppObserveAppEasBuild>;
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
/**
|
||||
* Unique users whose most recent supported-metric event in the queried
|
||||
* range matched this update at its current nesting level.
|
||||
*/
|
||||
lastSeenUserCount: Scalars['Int']['output'];
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
@@ -2225,6 +2324,17 @@ export type AppObserveAppVersion = {
|
||||
buildNumbers: Array<AppObserveAppBuildNumber>;
|
||||
eventCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
/**
|
||||
* Unique users whose most recent supported-metric event in
|
||||
* `[startTime, endTime)` was on this version. Each active user
|
||||
* contributes to exactly one version, so summing this field across the
|
||||
* returned versions yields the total active users in the window (equal
|
||||
* to `AppObserve.uniqueActiveUserCount` up to HyperLogLog noise).
|
||||
* A version may be returned with `lastSeenUserCount = 0` when
|
||||
* every user who touched it later moved to a newer version. Users with
|
||||
* no events in the window are not counted on any version.
|
||||
*/
|
||||
lastSeenUserCount: Scalars['Int']['output'];
|
||||
metrics: Array<AppObserveAppVersionMetric>;
|
||||
uniqueUserCount: Scalars['Int']['output'];
|
||||
updates: Array<AppObserveAppUpdate>;
|
||||
@@ -2243,6 +2353,7 @@ export type AppObserveCustomEvent = {
|
||||
appEasBuildId?: Maybe<Scalars['String']['output']>;
|
||||
appIdentifier: Scalars['String']['output'];
|
||||
appUpdateId?: Maybe<Scalars['String']['output']>;
|
||||
appUpdateMessage?: Maybe<Scalars['String']['output']>;
|
||||
appVersion: Scalars['String']['output'];
|
||||
clientVersion?: Maybe<Scalars['String']['output']>;
|
||||
countryCode?: Maybe<Scalars['String']['output']>;
|
||||
@@ -2284,6 +2395,7 @@ export type AppObserveCustomEventCountsInput = {
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
eventName: Scalars['String']['input'];
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
platform: AppObservePlatform;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
@@ -2309,6 +2421,7 @@ export type AppObserveCustomEventListFilter = {
|
||||
endTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
eventName?: InputMaybe<Scalars['String']['input']>;
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
propertyFilters?: InputMaybe<Array<AppObserveCustomEventPropertyFilter>>;
|
||||
sessionId?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -2353,6 +2466,7 @@ export type AppObserveEvent = {
|
||||
appIdentifier: Scalars['String']['output'];
|
||||
appName: Scalars['String']['output'];
|
||||
appUpdateId?: Maybe<Scalars['String']['output']>;
|
||||
appUpdateMessage?: Maybe<Scalars['String']['output']>;
|
||||
appVersion: Scalars['String']['output'];
|
||||
clientVersion?: Maybe<Scalars['String']['output']>;
|
||||
countryCode?: Maybe<Scalars['String']['output']>;
|
||||
@@ -2407,8 +2521,10 @@ export type AppObserveEventsFilter = {
|
||||
easClientId?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
metricName?: InputMaybe<Scalars['String']['input']>;
|
||||
platform?: InputMaybe<AppObservePlatform>;
|
||||
routeName?: InputMaybe<Scalars['String']['input']>;
|
||||
sessionId?: InputMaybe<Scalars['String']['input']>;
|
||||
startTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
};
|
||||
@@ -2428,6 +2544,67 @@ export enum AppObserveEventsOrderByField {
|
||||
Timestamp = 'TIMESTAMP'
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-route navigation timing breakdown. The "Navigations" count shown in the
|
||||
* dashboard is `coldTtr.count` (one cold-TTR event per user navigation).
|
||||
* Per-metric `median`/`p90` are null when `count` is 0 so the client can
|
||||
* distinguish "no data" from "0ms".
|
||||
*/
|
||||
export type AppObserveNavigationRoute = {
|
||||
__typename?: 'AppObserveNavigationRoute';
|
||||
coldTtr: AppObserveNavigationStat;
|
||||
routeName: Scalars['String']['output'];
|
||||
tti: AppObserveNavigationStat;
|
||||
warmTtr: AppObserveNavigationStat;
|
||||
};
|
||||
|
||||
export type AppObserveNavigationRouteEdge = {
|
||||
__typename?: 'AppObserveNavigationRouteEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: AppObserveNavigationRoute;
|
||||
};
|
||||
|
||||
export type AppObserveNavigationRoutesConnection = {
|
||||
__typename?: 'AppObserveNavigationRoutesConnection';
|
||||
edges: Array<AppObserveNavigationRouteEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type AppObserveNavigationRoutesFilter = {
|
||||
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
platform: AppObservePlatform;
|
||||
routeNames?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
export type AppObserveNavigationRoutesOrderBy = {
|
||||
direction: AppObserveEventsOrderByDirection;
|
||||
field: AppObserveNavigationRoutesOrderByField;
|
||||
};
|
||||
|
||||
export enum AppObserveNavigationRoutesOrderByField {
|
||||
MedianColdTtr = 'MEDIAN_COLD_TTR',
|
||||
MedianTti = 'MEDIAN_TTI',
|
||||
MedianWarmTtr = 'MEDIAN_WARM_TTR',
|
||||
NavigationCount = 'NAVIGATION_COUNT',
|
||||
P90ColdTtr = 'P90_COLD_TTR',
|
||||
P90Tti = 'P90_TTI',
|
||||
P90WarmTtr = 'P90_WARM_TTR',
|
||||
RouteName = 'ROUTE_NAME'
|
||||
}
|
||||
|
||||
export type AppObserveNavigationStat = {
|
||||
__typename?: 'AppObserveNavigationStat';
|
||||
count: Scalars['Int']['output'];
|
||||
median?: Maybe<Scalars['Float']['output']>;
|
||||
p90?: Maybe<Scalars['Float']['output']>;
|
||||
};
|
||||
|
||||
export enum AppObservePlatform {
|
||||
Android = 'ANDROID',
|
||||
Ios = 'IOS'
|
||||
@@ -2479,8 +2656,10 @@ export type AppObserveTimeSeriesInput = {
|
||||
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
metricName: Scalars['String']['input'];
|
||||
platform: AppObservePlatform;
|
||||
routeName?: InputMaybe<Scalars['String']['input']>;
|
||||
startTime: Scalars['DateTime']['input'];
|
||||
};
|
||||
|
||||
@@ -2499,6 +2678,7 @@ export type AppObserveTimeSeriesStatistics = {
|
||||
export type AppObserveUpdate = {
|
||||
__typename?: 'AppObserveUpdate';
|
||||
appUpdateId: Scalars['String']['output'];
|
||||
appUpdateMessage?: Maybe<Scalars['String']['output']>;
|
||||
appVersion: Scalars['String']['output'];
|
||||
downloadCount: Scalars['Int']['output'];
|
||||
firstSeenAt: Scalars['DateTime']['output'];
|
||||
@@ -3392,6 +3572,16 @@ export enum AppsFilter {
|
||||
New = 'NEW'
|
||||
}
|
||||
|
||||
export type ArgentRunSessionRemoteConfig = {
|
||||
__typename?: 'ArgentRunSessionRemoteConfig';
|
||||
toolsUrl: Scalars['String']['output'];
|
||||
/**
|
||||
* URL of the web preview surface for the session. Null when web previews are
|
||||
* not available for the platform (e.g. Android).
|
||||
*/
|
||||
webPreviewUrl?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type AscApiKeyInput = {
|
||||
issuerIdentifier: Scalars['String']['input'];
|
||||
keyIdentifier: Scalars['String']['input'];
|
||||
@@ -3951,6 +4141,7 @@ export type BuildMetadataInput = {
|
||||
developmentClient?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
distribution?: InputMaybe<DistributionType>;
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
expoPackageVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
fingerprintHash?: InputMaybe<Scalars['String']['input']>;
|
||||
fingerprintSource?: InputMaybe<FingerprintSourceInput>;
|
||||
gitCommitHash?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -4100,6 +4291,7 @@ export enum BuildPhase {
|
||||
EasBuildInternal = 'EAS_BUILD_INTERNAL',
|
||||
FailBuild = 'FAIL_BUILD',
|
||||
FixGradlew = 'FIX_GRADLEW',
|
||||
GradleBuildProfile = 'GRADLE_BUILD_PROFILE',
|
||||
InstallCustomTools = 'INSTALL_CUSTOM_TOOLS',
|
||||
InstallDependencies = 'INSTALL_DEPENDENCIES',
|
||||
InstallPods = 'INSTALL_PODS',
|
||||
@@ -4108,6 +4300,7 @@ export enum BuildPhase {
|
||||
OnBuildErrorHook = 'ON_BUILD_ERROR_HOOK',
|
||||
OnBuildSuccessHook = 'ON_BUILD_SUCCESS_HOOK',
|
||||
ParseCustomWorkflowConfig = 'PARSE_CUSTOM_WORKFLOW_CONFIG',
|
||||
ParseXcactivitylog = 'PARSE_XCACTIVITYLOG',
|
||||
PostInstallHook = 'POST_INSTALL_HOOK',
|
||||
Prebuild = 'PREBUILD',
|
||||
PrepareArtifacts = 'PREPARE_ARTIFACTS',
|
||||
@@ -4331,6 +4524,8 @@ export type Concurrencies = {
|
||||
total: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
export type ConcurrencyConsumer = Build | JobRun;
|
||||
|
||||
export enum ContinentCode {
|
||||
Af = 'AF',
|
||||
An = 'AN',
|
||||
@@ -5111,6 +5306,12 @@ export type DeviceRunSession = {
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type DeviceRunSessionFilterInput = {
|
||||
platforms?: InputMaybe<Array<AppPlatform>>;
|
||||
statuses?: InputMaybe<Array<DeviceRunSessionStatus>>;
|
||||
types?: InputMaybe<Array<DeviceRunSessionType>>;
|
||||
};
|
||||
|
||||
export type DeviceRunSessionMutation = {
|
||||
__typename?: 'DeviceRunSessionMutation';
|
||||
/** Create a device run session */
|
||||
@@ -5151,7 +5352,7 @@ export type DeviceRunSessionQueryByIdArgs = {
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig;
|
||||
export type DeviceRunSessionRemoteConfig = AgentDeviceRunSessionRemoteConfig | ArgentRunSessionRemoteConfig | ServeSimRunSessionRemoteConfig;
|
||||
|
||||
export enum DeviceRunSessionStatus {
|
||||
Errored = 'ERRORED',
|
||||
@@ -5162,6 +5363,7 @@ export enum DeviceRunSessionStatus {
|
||||
|
||||
export enum DeviceRunSessionType {
|
||||
AgentDevice = 'AGENT_DEVICE',
|
||||
Argent = 'ARGENT',
|
||||
ServeSim = 'SERVE_SIM'
|
||||
}
|
||||
|
||||
@@ -5921,6 +6123,67 @@ export type EditUpdateBranchInput = {
|
||||
newName: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type EmbeddedUpdate = {
|
||||
__typename?: 'EmbeddedUpdate';
|
||||
channel: Scalars['String']['output'];
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
/** The manifest UUID baked into the binary by expo-updates at build time. */
|
||||
id: Scalars['ID']['output'];
|
||||
launchAsset: EmbeddedUpdateAsset;
|
||||
platform: AppPlatform;
|
||||
runtimeVersion: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type EmbeddedUpdateAsset = {
|
||||
__typename?: 'EmbeddedUpdateAsset';
|
||||
contentType: Scalars['String']['output'];
|
||||
fileSHA256: Scalars['String']['output'];
|
||||
fileSize: Scalars['Int']['output'];
|
||||
finalFileSize?: Maybe<Scalars['Int']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
storageKey: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type EmbeddedUpdateAssetMutation = {
|
||||
__typename?: 'EmbeddedUpdateAssetMutation';
|
||||
/**
|
||||
* Returns a presigned POST URL for uploading an embedded update bundle.
|
||||
* Requires PUBLISH permission on the app.
|
||||
*/
|
||||
getSignedEmbeddedUpdateAssetUploadSpecifications: EmbeddedUpdateAssetUploadSpec;
|
||||
};
|
||||
|
||||
|
||||
export type EmbeddedUpdateAssetMutationGetSignedEmbeddedUpdateAssetUploadSpecificationsArgs = {
|
||||
appId: Scalars['ID']['input'];
|
||||
contentType: Scalars['String']['input'];
|
||||
embeddedUpdateId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type EmbeddedUpdateAssetUploadSpec = {
|
||||
__typename?: 'EmbeddedUpdateAssetUploadSpec';
|
||||
/** Form fields that must be included with the POST request alongside the file. */
|
||||
fields: Scalars['JSONObject']['output'];
|
||||
/** Presigned POST URL targeting the upload bucket. Valid for one hour. */
|
||||
presignedUrl: Scalars['String']['output'];
|
||||
/** Storage key (`{appId}/{embeddedUpdateId}`). Same key in both upload and destination buckets. */
|
||||
storageKey: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type EmbeddedUpdateMutation = {
|
||||
__typename?: 'EmbeddedUpdateMutation';
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
uploadEmbeddedUpdate: EmbeddedUpdate;
|
||||
};
|
||||
|
||||
|
||||
export type EmbeddedUpdateMutationUploadEmbeddedUpdateArgs = {
|
||||
input: UploadEmbeddedUpdateInput;
|
||||
};
|
||||
|
||||
export enum EntityTypeName {
|
||||
AccountEntity = 'AccountEntity',
|
||||
AccountSsoConfigurationEntity = 'AccountSSOConfigurationEntity',
|
||||
@@ -7185,6 +7448,7 @@ export type JobRun = {
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
displayName?: Maybe<Scalars['String']['output']>;
|
||||
endedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
enqueuedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
errors: Array<JobRunError>;
|
||||
expiresAt: Scalars['DateTime']['output'];
|
||||
gitCommitHash?: Maybe<Scalars['String']['output']>;
|
||||
@@ -7196,6 +7460,8 @@ export type JobRun = {
|
||||
logFileUrls: Array<Scalars['String']['output']>;
|
||||
name: Scalars['String']['output'];
|
||||
priority: JobRunPriority;
|
||||
/** String describing the worker profile used to run this job run. */
|
||||
resourceClassDisplayName: Scalars['String']['output'];
|
||||
startedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
status: JobRunStatus;
|
||||
updateGroups: Array<Array<Update>>;
|
||||
@@ -8075,6 +8341,9 @@ export type RootMutation = {
|
||||
echoTurn: EchoTurnMutation;
|
||||
/** Mutations for Echo versions */
|
||||
echoVersion: EchoVersionMutation;
|
||||
/** Mutations that register embedded update bundles for bundle diffing. */
|
||||
embeddedUpdate: EmbeddedUpdateMutation;
|
||||
embeddedUpdateAsset: EmbeddedUpdateAssetMutation;
|
||||
/** Mutations that create and delete EnvironmentSecrets */
|
||||
environmentSecret: EnvironmentSecretMutation;
|
||||
/** Mutations that create and delete EnvironmentVariables */
|
||||
@@ -9622,6 +9891,16 @@ export type UpdatesMetricsData = {
|
||||
labels: Array<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type UploadEmbeddedUpdateInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
channel: Scalars['String']['input'];
|
||||
/** UUID baked into the binary by expo-updates at build time (from app.manifest id field). */
|
||||
embeddedUpdateId: Scalars['ID']['input'];
|
||||
platform: AppPlatform;
|
||||
runtimeVersion: Scalars['String']['input'];
|
||||
turtleBuildId?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
|
||||
export type UploadSession = {
|
||||
__typename?: 'UploadSession';
|
||||
/** Create an Upload Session for a specific account */
|
||||
@@ -12702,7 +12981,17 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', url: string, token: string } | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, streamUrl: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
|
||||
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null } | { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, webPreviewUrl?: string | null } | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, streamUrl: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
|
||||
|
||||
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<DeviceRunSessionFilterInput>;
|
||||
}>;
|
||||
|
||||
|
||||
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 EnvironmentSecretsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
@@ -12825,6 +13114,17 @@ export type AppObserveCustomEventNamesQueryVariables = Exact<{
|
||||
|
||||
export type AppObserveCustomEventNamesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', customEventNames: { __typename?: 'AppObserveCustomEventNames', isTruncated: boolean, names: Array<{ __typename?: 'AppObserveCustomEventName', eventName: string, count: number }> } } } } };
|
||||
|
||||
export type AppObserveNavigationRoutesQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
filter: AppObserveNavigationRoutesFilter;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
orderBy?: InputMaybe<AppObserveNavigationRoutesOrderBy>;
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveNavigationRoutesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', navigationRoutes: { __typename?: 'AppObserveNavigationRoutesConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveNavigationRouteEdge', cursor: string, node: { __typename?: 'AppObserveNavigationRoute', routeName: string, coldTtr: { __typename?: 'AppObserveNavigationStat', count: number, median?: number | null, p90?: number | null }, warmTtr: { __typename?: 'AppObserveNavigationStat', count: number, median?: number | null, p90?: number | null }, tti: { __typename?: 'AppObserveNavigationStat', count: number, median?: number | null, p90?: number | null } } }> } } } } };
|
||||
|
||||
export type GetAssetMetadataQueryVariables = Exact<{
|
||||
storageKeys: Array<Scalars['String']['input']> | Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
@@ -2,7 +2,13 @@ import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import { DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables } from '../generated';
|
||||
import {
|
||||
DeviceRunSessionByIdQuery,
|
||||
DeviceRunSessionByIdQueryVariables,
|
||||
DeviceRunSessionFilterInput,
|
||||
DeviceRunSessionsByAppIdQuery,
|
||||
DeviceRunSessionsByAppIdQueryVariables,
|
||||
} from '../generated';
|
||||
|
||||
export const DeviceRunSessionQuery = {
|
||||
async byIdAsync(
|
||||
@@ -30,8 +36,13 @@ export const DeviceRunSessionQuery = {
|
||||
remoteConfig {
|
||||
__typename
|
||||
... on AgentDeviceRunSessionRemoteConfig {
|
||||
url
|
||||
token
|
||||
agentDeviceRemoteSessionUrl
|
||||
agentDeviceRemoteSessionToken
|
||||
webPreviewUrl
|
||||
}
|
||||
... on ArgentRunSessionRemoteConfig {
|
||||
toolsUrl
|
||||
webPreviewUrl
|
||||
}
|
||||
... on ServeSimRunSessionRemoteConfig {
|
||||
previewUrl
|
||||
@@ -53,4 +64,74 @@ export const DeviceRunSessionQuery = {
|
||||
);
|
||||
return data.deviceRunSessions.byId;
|
||||
},
|
||||
async listByAppIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
first,
|
||||
after,
|
||||
filter,
|
||||
}: {
|
||||
appId: string;
|
||||
first?: number;
|
||||
after?: string;
|
||||
filter?: DeviceRunSessionFilterInput;
|
||||
}
|
||||
): Promise<DeviceRunSessionsByAppIdQuery['app']['byId']['deviceRunSessionsPaginated']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<DeviceRunSessionsByAppIdQuery, DeviceRunSessionsByAppIdQueryVariables>(
|
||||
gql`
|
||||
query DeviceRunSessionsByAppId(
|
||||
$appId: String!
|
||||
$first: Int
|
||||
$after: String
|
||||
$filter: DeviceRunSessionFilterInput
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
deviceRunSessionsPaginated(first: $first, after: $after, filter: $filter) {
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
status
|
||||
type
|
||||
platform
|
||||
createdAt
|
||||
startedAt
|
||||
finishedAt
|
||||
app {
|
||||
id
|
||||
slug
|
||||
ownerAccount {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
turtleJobRun {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ appId, first, after, filter },
|
||||
{ requestPolicy: 'network-only' }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.app.byId.deviceRunSessionsPaginated;
|
||||
},
|
||||
};
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
AppObserveEvent,
|
||||
AppObserveEventsFilter,
|
||||
AppObserveEventsOrderBy,
|
||||
AppObserveNavigationRoute,
|
||||
AppObserveNavigationRoutesFilter,
|
||||
AppObserveNavigationRoutesOrderBy,
|
||||
AppObservePlatform,
|
||||
AppObserveReleasesInput,
|
||||
AppObserveTimeSeriesInput,
|
||||
@@ -133,6 +136,31 @@ type AppObserveCustomEventNamesQueryVariables = {
|
||||
environment?: string;
|
||||
};
|
||||
|
||||
type AppObserveNavigationRoutesQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
observe: {
|
||||
navigationRoutes: {
|
||||
pageInfo: PageInfo;
|
||||
edges: Array<{
|
||||
cursor: string;
|
||||
node: AppObserveNavigationRoute;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type AppObserveNavigationRoutesQueryVariables = {
|
||||
appId: string;
|
||||
filter: AppObserveNavigationRoutesFilter;
|
||||
first?: number;
|
||||
after?: string;
|
||||
orderBy?: AppObserveNavigationRoutesOrderBy;
|
||||
};
|
||||
|
||||
export const ObserveQuery = {
|
||||
async timeSeriesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
@@ -398,4 +426,68 @@ export const ObserveQuery = {
|
||||
|
||||
return data.app.byId.observe.customEventNames;
|
||||
},
|
||||
|
||||
async navigationRoutesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
variables: AppObserveNavigationRoutesQueryVariables
|
||||
): Promise<{ routes: AppObserveNavigationRoute[]; pageInfo: PageInfo }> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AppObserveNavigationRoutesQuery, AppObserveNavigationRoutesQueryVariables>(
|
||||
gql`
|
||||
query AppObserveNavigationRoutes(
|
||||
$appId: String!
|
||||
$filter: AppObserveNavigationRoutesFilter!
|
||||
$first: Int
|
||||
$after: String
|
||||
$orderBy: AppObserveNavigationRoutesOrderBy
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
observe {
|
||||
navigationRoutes(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
endCursor
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
routeName
|
||||
coldTtr {
|
||||
count
|
||||
median
|
||||
p90
|
||||
}
|
||||
warmTtr {
|
||||
count
|
||||
median
|
||||
p90
|
||||
}
|
||||
tti {
|
||||
count
|
||||
median
|
||||
p90
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
const { edges, pageInfo } = data.app.byId.observe.navigationRoutes;
|
||||
return {
|
||||
routes: edges.map(edge => edge.node),
|
||||
pageInfo,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import chalk from 'chalk';
|
||||
import debug from 'debug';
|
||||
import figures from 'figures';
|
||||
import { boolish } from 'getenv';
|
||||
import logSymbols from 'log-symbols';
|
||||
@@ -6,8 +7,7 @@ import terminalLink from 'terminal-link';
|
||||
|
||||
type Color = (...text: string[]) => string;
|
||||
|
||||
// eslint-disable-next-line eslint-import/no-extraneous-dependencies
|
||||
const nodeDebug = require('debug')('eas:log:debug') as (...args: any[]) => void;
|
||||
const nodeDebug = debug('eas:log:debug');
|
||||
|
||||
export default class Log {
|
||||
public static readonly isDebug = boolish('EXPO_DEBUG', false);
|
||||
@@ -48,7 +48,8 @@ export default class Log {
|
||||
Log.consoleLog(...args);
|
||||
} else {
|
||||
Log.updateIsLastLineNewLine(args);
|
||||
nodeDebug(...args);
|
||||
const [first, ...rest] = args;
|
||||
nodeDebug(first, ...rest);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ function makeAppVersion(
|
||||
eventCount: metrics.reduce((sum, m) => sum + m.eventCount, 0),
|
||||
uniqueUserCount: 50,
|
||||
firstSeenAt: '2025-01-01T00:00:00.000Z',
|
||||
lastSeenUserCount: 12,
|
||||
buildNumbers: [],
|
||||
updates: [],
|
||||
metrics: metrics.map(m => ({
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveNavigationRoute,
|
||||
AppObserveNavigationRoutesOrderByField,
|
||||
AppObservePlatform,
|
||||
AppPlatform,
|
||||
} from '../../graphql/generated';
|
||||
import { ObserveQuery } from '../../graphql/queries/ObserveQuery';
|
||||
import { fetchObserveNavigationRoutesAsync } from '../fetchNavigationRoutes';
|
||||
|
||||
jest.mock('../../graphql/queries/ObserveQuery');
|
||||
jest.mock('../../log');
|
||||
|
||||
function makeRoute(routeName: string): AppObserveNavigationRoute {
|
||||
return {
|
||||
__typename: 'AppObserveNavigationRoute' as const,
|
||||
routeName,
|
||||
coldTtr: { __typename: 'AppObserveNavigationStat' as const, count: 10, median: 0.5, p90: 0.9 },
|
||||
warmTtr: { __typename: 'AppObserveNavigationStat' as const, count: 8, median: 0.3, p90: 0.6 },
|
||||
tti: { __typename: 'AppObserveNavigationStat' as const, count: 12, median: 0.45, p90: 0.7 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('fetchObserveNavigationRoutesAsync', () => {
|
||||
const mockNavigationRoutesAsync = jest.mocked(ObserveQuery.navigationRoutesAsync);
|
||||
const mockGraphqlClient = {} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockNavigationRoutesAsync.mockClear();
|
||||
mockNavigationRoutesAsync.mockResolvedValue({
|
||||
routes: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes appId, time range, limit, and filter values for each platform', async () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
limit: 25,
|
||||
appVersion: '2.1.0',
|
||||
updateId: 'update-xyz',
|
||||
buildNumber: '42',
|
||||
});
|
||||
|
||||
expect(mockNavigationRoutesAsync).toHaveBeenCalledTimes(1);
|
||||
const call = mockNavigationRoutesAsync.mock.calls[0][1];
|
||||
expect(call.appId).toBe('project-123');
|
||||
expect(call.first).toBe(25);
|
||||
expect(call.filter).toEqual({
|
||||
platform: AppObservePlatform.Ios,
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
appVersion: '2.1.0',
|
||||
appUpdateId: 'update-xyz',
|
||||
appBuildNumber: '42',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults orderBy to NAVIGATION_COUNT DESC', async () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const call = mockNavigationRoutesAsync.mock.calls[0][1];
|
||||
expect(call.orderBy).toEqual({
|
||||
field: AppObserveNavigationRoutesOrderByField.NavigationCount,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not send optional filters when not provided', async () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const filter = mockNavigationRoutesAsync.mock.calls[0][1].filter;
|
||||
expect(filter).not.toHaveProperty('appVersion');
|
||||
expect(filter).not.toHaveProperty('appUpdateId');
|
||||
expect(filter).not.toHaveProperty('appBuildNumber');
|
||||
expect(filter).not.toHaveProperty('routeNames');
|
||||
});
|
||||
|
||||
it('forwards routeNames filter when provided', async () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
limit: 50,
|
||||
routeNames: ['/home', '/profile'],
|
||||
});
|
||||
|
||||
expect(mockNavigationRoutesAsync.mock.calls[0][1].filter.routeNames).toEqual([
|
||||
'/home',
|
||||
'/profile',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not send routeNames when array is empty', async () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
limit: 50,
|
||||
routeNames: [],
|
||||
});
|
||||
|
||||
expect(mockNavigationRoutesAsync.mock.calls[0][1].filter).not.toHaveProperty('routeNames');
|
||||
});
|
||||
|
||||
it('forwards after cursor when provided', async () => {
|
||||
await fetchObserveNavigationRoutesAsync(mockGraphqlClient, 'project-123', {
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios],
|
||||
limit: 50,
|
||||
after: 'cursor-abc',
|
||||
});
|
||||
|
||||
expect(mockNavigationRoutesAsync.mock.calls[0][1].after).toBe('cursor-abc');
|
||||
});
|
||||
|
||||
it('fans out across multiple platforms and tags each route with its platform', async () => {
|
||||
mockNavigationRoutesAsync.mockImplementation(async (_client, vars) => {
|
||||
if (vars.filter.platform === AppObservePlatform.Android) {
|
||||
return {
|
||||
routes: [makeRoute('/android-home')],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
};
|
||||
}
|
||||
return {
|
||||
routes: [makeRoute('/ios-home')],
|
||||
pageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'ios-cursor' },
|
||||
};
|
||||
});
|
||||
|
||||
const { routes, pageInfoByPlatform } = await fetchObserveNavigationRoutesAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
{
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios, AppPlatform.Android],
|
||||
limit: 50,
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockNavigationRoutesAsync).toHaveBeenCalledTimes(2);
|
||||
expect(routes).toHaveLength(2);
|
||||
const byName = new Map(routes.map(r => [r.route.routeName, r.platform]));
|
||||
expect(byName.get('/android-home')).toBe(AppPlatform.Android);
|
||||
expect(byName.get('/ios-home')).toBe(AppPlatform.Ios);
|
||||
expect(pageInfoByPlatform.get(AppPlatform.Ios)?.hasNextPage).toBe(true);
|
||||
expect(pageInfoByPlatform.get(AppPlatform.Android)?.hasNextPage).toBe(false);
|
||||
});
|
||||
|
||||
it('handles partial failures gracefully', async () => {
|
||||
mockNavigationRoutesAsync.mockImplementation(async (_client, vars) => {
|
||||
if (vars.filter.platform === AppObservePlatform.Android) {
|
||||
throw new Error('Network error');
|
||||
}
|
||||
return {
|
||||
routes: [makeRoute('/home')],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
};
|
||||
});
|
||||
|
||||
const { routes, pageInfoByPlatform } = await fetchObserveNavigationRoutesAsync(
|
||||
mockGraphqlClient,
|
||||
'project-123',
|
||||
{
|
||||
startTime: '2025-01-01T00:00:00.000Z',
|
||||
endTime: '2025-03-01T00:00:00.000Z',
|
||||
platforms: [AppPlatform.Ios, AppPlatform.Android],
|
||||
limit: 50,
|
||||
}
|
||||
);
|
||||
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0].platform).toBe(AppPlatform.Ios);
|
||||
expect(pageInfoByPlatform.has(AppPlatform.Android)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -71,12 +71,12 @@ describe(buildObserveMetricsTable, () => {
|
||||
"[1mMed values (event count)[22m
|
||||
|
||||
[1miOS[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
----------- ----------- ----------
|
||||
1.2.0 0.35s (110) 1.32s (90)
|
||||
[1mApp Version Cold Launch Startup TTI[22m
|
||||
----------- ----------- -----------
|
||||
1.2.0 0.35s (110) 1.32s (90)
|
||||
|
||||
[1mAndroid[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
[1mApp Version Cold Launch Startup TTI[22m
|
||||
----------- ----------- -----------
|
||||
1.1.0 0.25s (120) 1.12s (100)"
|
||||
`);
|
||||
@@ -93,9 +93,9 @@ describe(buildObserveMetricsTable, () => {
|
||||
"[1mMed values (event count)[22m
|
||||
|
||||
[1miOS[22m
|
||||
[1mApp Version Cold Launch TTI [22m
|
||||
----------- ----------- -----
|
||||
2.0.0 - (-) - (-)"
|
||||
[1mApp Version Cold Launch Startup TTI[22m
|
||||
----------- ----------- -----------
|
||||
2.0.0 - (-) - (-) "
|
||||
`);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { AppPlatform } from '../../graphql/generated';
|
||||
import { NavigationRouteWithPlatform } from '../fetchNavigationRoutes';
|
||||
import {
|
||||
buildObserveNavigationRoutesJson,
|
||||
buildObserveNavigationRoutesTable,
|
||||
isNavigationMetric,
|
||||
resolveNavigationStatKey,
|
||||
} from '../formatNavigationRoutes';
|
||||
|
||||
function makeRoute(
|
||||
routeName: string,
|
||||
platform: AppPlatform,
|
||||
overrides?: Partial<{
|
||||
coldTtr: { count: number; median: number | null; p90: number | null };
|
||||
warmTtr: { count: number; median: number | null; p90: number | null };
|
||||
tti: { count: number; median: number | null; p90: number | null };
|
||||
}>
|
||||
): NavigationRouteWithPlatform {
|
||||
return {
|
||||
platform,
|
||||
route: {
|
||||
__typename: 'AppObserveNavigationRoute' as const,
|
||||
routeName,
|
||||
coldTtr: {
|
||||
__typename: 'AppObserveNavigationStat' as const,
|
||||
count: 10,
|
||||
median: 0.5,
|
||||
p90: 0.9,
|
||||
...overrides?.coldTtr,
|
||||
},
|
||||
warmTtr: {
|
||||
__typename: 'AppObserveNavigationStat' as const,
|
||||
count: 8,
|
||||
median: 0.3,
|
||||
p90: 0.6,
|
||||
...overrides?.warmTtr,
|
||||
},
|
||||
tti: {
|
||||
__typename: 'AppObserveNavigationStat' as const,
|
||||
count: 12,
|
||||
median: 0.45,
|
||||
p90: 0.7,
|
||||
...overrides?.tti,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(isNavigationMetric, () => {
|
||||
it('returns true for each known navigation metric full name', () => {
|
||||
expect(isNavigationMetric('expo.navigation.cold_ttr')).toBe(true);
|
||||
expect(isNavigationMetric('expo.navigation.warm_ttr')).toBe(true);
|
||||
expect(isNavigationMetric('expo.navigation.tti')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for app-startup metrics or unknown names', () => {
|
||||
expect(isNavigationMetric('expo.app_startup.tti')).toBe(false);
|
||||
expect(isNavigationMetric('cold_ttr')).toBe(false);
|
||||
expect(isNavigationMetric('unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveNavigationStatKey, () => {
|
||||
it('resolves aliases', () => {
|
||||
expect(resolveNavigationStatKey('med')).toBe('median');
|
||||
expect(resolveNavigationStatKey('median')).toBe('median');
|
||||
expect(resolveNavigationStatKey('p90')).toBe('p90');
|
||||
expect(resolveNavigationStatKey('count')).toBe('count');
|
||||
expect(resolveNavigationStatKey('eventCount')).toBe('count');
|
||||
expect(resolveNavigationStatKey('event_count')).toBe('count');
|
||||
});
|
||||
|
||||
it('throws on unknown stat', () => {
|
||||
expect(() => resolveNavigationStatKey('min')).toThrow('Unknown statistic');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveNavigationRoutesTable, () => {
|
||||
it('returns a yellow warning when no routes are present', () => {
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
[],
|
||||
['expo.navigation.cold_ttr'],
|
||||
['median', 'count']
|
||||
);
|
||||
expect(output).toContain('No navigation routes found.');
|
||||
});
|
||||
|
||||
it('formats routes grouped by platform with merged (med + count) cells', () => {
|
||||
const routes = [
|
||||
makeRoute('/home', AppPlatform.Ios),
|
||||
makeRoute('/profile', AppPlatform.Ios, {
|
||||
coldTtr: { count: 5, median: 0.7, p90: 1.0 },
|
||||
}),
|
||||
makeRoute('/home', AppPlatform.Android),
|
||||
];
|
||||
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr', 'expo.navigation.warm_ttr', 'expo.navigation.tti'],
|
||||
['median', 'count'],
|
||||
{ daysBack: 7 }
|
||||
);
|
||||
|
||||
expect(output).toContain('Med values (navigation count) for the last 7 days');
|
||||
expect(output).toContain('Cold TTR');
|
||||
expect(output).toContain('Warm TTR');
|
||||
expect(output).toContain('Nav TTI');
|
||||
expect(output).toContain('/home');
|
||||
expect(output).toContain('/profile');
|
||||
expect(output).toContain('0.50s (10)');
|
||||
expect(output).toContain('0.70s (5)');
|
||||
expect(output).toContain('iOS');
|
||||
expect(output).toContain('Android');
|
||||
});
|
||||
|
||||
it('renders separate columns when count is omitted from stats', () => {
|
||||
const routes = [makeRoute('/home', AppPlatform.Ios)];
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr'],
|
||||
['median', 'p90']
|
||||
);
|
||||
|
||||
expect(output).toContain('Med, P90 values');
|
||||
expect(output).toContain('Cold TTR Med');
|
||||
expect(output).toContain('Cold TTR P90');
|
||||
expect(output).toContain('0.50s');
|
||||
expect(output).toContain('0.90s');
|
||||
});
|
||||
|
||||
it('renders count-only column when only count stat is requested', () => {
|
||||
const routes = [makeRoute('/home', AppPlatform.Ios)];
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr'],
|
||||
['count']
|
||||
);
|
||||
|
||||
expect(output).toContain('Navigation count values');
|
||||
expect(output).toContain('Cold TTR Count');
|
||||
expect(output).toContain('10');
|
||||
});
|
||||
|
||||
it('shows a next-page hint per platform when hasNextPage is true', () => {
|
||||
const routes = [makeRoute('/home', AppPlatform.Ios)];
|
||||
const pageInfoByPlatform = new Map([
|
||||
[AppPlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
|
||||
]);
|
||||
|
||||
const output = buildObserveNavigationRoutesTable(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr'],
|
||||
['median', 'count'],
|
||||
{ pageInfoByPlatform }
|
||||
);
|
||||
|
||||
expect(output).toContain('Next page (iOS): --after cursor-ios');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveNavigationRoutesJson, () => {
|
||||
it('maps routes to the requested metrics and stats, including pageInfoByPlatform', () => {
|
||||
const routes = [makeRoute('/home', AppPlatform.Ios)];
|
||||
const pageInfoByPlatform = new Map([
|
||||
[AppPlatform.Ios, { hasNextPage: true, endCursor: 'cursor-ios' }],
|
||||
]);
|
||||
|
||||
const result = buildObserveNavigationRoutesJson(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr', 'expo.navigation.tti'],
|
||||
['median', 'p90', 'count'],
|
||||
pageInfoByPlatform
|
||||
);
|
||||
|
||||
expect(result.routes).toEqual([
|
||||
{
|
||||
routeName: '/home',
|
||||
platform: AppPlatform.Ios,
|
||||
metrics: {
|
||||
'expo.navigation.cold_ttr': { median: 0.5, p90: 0.9, count: 10 },
|
||||
'expo.navigation.tti': { median: 0.45, p90: 0.7, count: 12 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(result.pageInfoByPlatform).toEqual({
|
||||
[AppPlatform.Ios]: { hasNextPage: true, endCursor: 'cursor-ios' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for stats when the underlying value is null', () => {
|
||||
const routes = [
|
||||
makeRoute('/home', AppPlatform.Ios, {
|
||||
coldTtr: { count: 0, median: null, p90: null },
|
||||
}),
|
||||
];
|
||||
|
||||
const result = buildObserveNavigationRoutesJson(
|
||||
routes,
|
||||
['expo.navigation.cold_ttr'],
|
||||
['median', 'p90', 'count'],
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(result.routes[0].metrics['expo.navigation.cold_ttr']).toEqual({
|
||||
median: null,
|
||||
p90: null,
|
||||
count: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { getMetricDisplayName, resolveMetricName } from '../metricNames';
|
||||
import {
|
||||
getMetricDisplayName,
|
||||
resolveMetricName,
|
||||
resolveNavigationMetricName,
|
||||
} from '../metricNames';
|
||||
|
||||
describe(resolveMetricName, () => {
|
||||
it('resolves short alias "tti" to full metric name', () => {
|
||||
@@ -37,15 +41,46 @@ describe(resolveMetricName, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveNavigationMetricName, () => {
|
||||
it('resolves short aliases to navigation metric full names', () => {
|
||||
expect(resolveNavigationMetricName('cold_ttr')).toBe('expo.navigation.cold_ttr');
|
||||
expect(resolveNavigationMetricName('warm_ttr')).toBe('expo.navigation.warm_ttr');
|
||||
expect(resolveNavigationMetricName('nav_tti')).toBe('expo.navigation.tti');
|
||||
});
|
||||
|
||||
it('passes through full navigation metric names unchanged', () => {
|
||||
expect(resolveNavigationMetricName('expo.navigation.cold_ttr')).toBe(
|
||||
'expo.navigation.cold_ttr'
|
||||
);
|
||||
expect(resolveNavigationMetricName('expo.navigation.warm_ttr')).toBe(
|
||||
'expo.navigation.warm_ttr'
|
||||
);
|
||||
expect(resolveNavigationMetricName('expo.navigation.tti')).toBe('expo.navigation.tti');
|
||||
});
|
||||
|
||||
it('throws on unknown alias or non-navigation metric', () => {
|
||||
expect(() => resolveNavigationMetricName('unknown')).toThrow('Unknown navigation metric');
|
||||
expect(() => resolveNavigationMetricName('expo.app_startup.tti')).toThrow(
|
||||
'Unknown navigation metric'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getMetricDisplayName, () => {
|
||||
it('returns short display name for known metrics', () => {
|
||||
it('returns short display name for known app-startup metrics', () => {
|
||||
expect(getMetricDisplayName('expo.app_startup.cold_launch_time')).toBe('Cold Launch');
|
||||
expect(getMetricDisplayName('expo.app_startup.warm_launch_time')).toBe('Warm Launch');
|
||||
expect(getMetricDisplayName('expo.app_startup.tti')).toBe('TTI');
|
||||
expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('TTR');
|
||||
expect(getMetricDisplayName('expo.app_startup.tti')).toBe('Startup TTI');
|
||||
expect(getMetricDisplayName('expo.app_startup.ttr')).toBe('Startup TTR');
|
||||
expect(getMetricDisplayName('expo.app_startup.bundle_load_time')).toBe('Bundle Load');
|
||||
});
|
||||
|
||||
it('returns short display name for known navigation metrics', () => {
|
||||
expect(getMetricDisplayName('expo.navigation.cold_ttr')).toBe('Nav Cold TTR');
|
||||
expect(getMetricDisplayName('expo.navigation.warm_ttr')).toBe('Nav Warm TTR');
|
||||
expect(getMetricDisplayName('expo.navigation.tti')).toBe('Nav TTI');
|
||||
});
|
||||
|
||||
it('returns the full metric name for unknown metrics', () => {
|
||||
expect(getMetricDisplayName('custom.metric.name')).toBe('custom.metric.name');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { AppObservePlatform, AppPlatform } from '../graphql/generated';
|
||||
import { AppPlatform } from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
import Log from '../log';
|
||||
import {
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
UpdateIdsMap,
|
||||
makeMetricsKey,
|
||||
} from './formatMetrics';
|
||||
|
||||
const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
import { appPlatformToObservePlatform } from './platforms';
|
||||
|
||||
export function validateDateFlag(value: string, flagName: string): void {
|
||||
const parsed = new Date(value);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveNavigationRoute,
|
||||
AppObserveNavigationRoutesOrderBy,
|
||||
AppObserveNavigationRoutesOrderByField,
|
||||
AppPlatform,
|
||||
PageInfo,
|
||||
} from '../graphql/generated';
|
||||
import { ObserveQuery } from '../graphql/queries/ObserveQuery';
|
||||
import Log from '../log';
|
||||
import { appPlatformToObservePlatform } from './platforms';
|
||||
|
||||
export interface NavigationRouteWithPlatform {
|
||||
platform: AppPlatform;
|
||||
route: AppObserveNavigationRoute;
|
||||
}
|
||||
|
||||
export interface FetchNavigationRoutesOptions {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platforms: AppPlatform[];
|
||||
limit: number;
|
||||
after?: string;
|
||||
appVersion?: string;
|
||||
updateId?: string;
|
||||
buildNumber?: string;
|
||||
routeNames?: string[];
|
||||
orderBy?: AppObserveNavigationRoutesOrderBy;
|
||||
}
|
||||
|
||||
export interface FetchNavigationRoutesResult {
|
||||
routes: NavigationRouteWithPlatform[];
|
||||
pageInfoByPlatform: Map<AppPlatform, PageInfo>;
|
||||
}
|
||||
|
||||
export async function fetchObserveNavigationRoutesAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchNavigationRoutesOptions
|
||||
): Promise<FetchNavigationRoutesResult> {
|
||||
const orderBy: AppObserveNavigationRoutesOrderBy = options.orderBy ?? {
|
||||
field: AppObserveNavigationRoutesOrderByField.NavigationCount,
|
||||
direction: AppObserveEventsOrderByDirection.Desc,
|
||||
};
|
||||
|
||||
const queries = options.platforms.map(async appPlatform => {
|
||||
const observePlatform = appPlatformToObservePlatform[appPlatform];
|
||||
try {
|
||||
const result = await ObserveQuery.navigationRoutesAsync(graphqlClient, {
|
||||
appId,
|
||||
filter: {
|
||||
platform: observePlatform,
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
...(options.buildNumber && { appBuildNumber: options.buildNumber }),
|
||||
...(options.routeNames?.length && { routeNames: options.routeNames }),
|
||||
},
|
||||
first: options.limit,
|
||||
...(options.after && { after: options.after }),
|
||||
orderBy,
|
||||
});
|
||||
return { appPlatform, ...result };
|
||||
} catch (error: any) {
|
||||
Log.warn(`Failed to fetch navigation routes on ${observePlatform}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(queries);
|
||||
|
||||
const routes: NavigationRouteWithPlatform[] = [];
|
||||
const pageInfoByPlatform = new Map<AppPlatform, PageInfo>();
|
||||
|
||||
for (const result of results) {
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
pageInfoByPlatform.set(result.appPlatform, result.pageInfo);
|
||||
for (const route of result.routes) {
|
||||
routes.push({ platform: result.appPlatform, route });
|
||||
}
|
||||
}
|
||||
|
||||
return { routes, pageInfoByPlatform };
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { allowedPlatformFlagValues } from './platforms';
|
||||
|
||||
export const ObserveProjectIdFlag = {
|
||||
'project-id': Flags.string({
|
||||
description: 'EAS project ID (defaults to the project ID of the current directory)',
|
||||
}),
|
||||
};
|
||||
|
||||
export const ObservePlatformFlag = {
|
||||
platform: Flags.option({
|
||||
description: 'Filter by platform',
|
||||
options: allowedPlatformFlagValues,
|
||||
})(),
|
||||
};
|
||||
|
||||
export const ObserveTimeRangeFlags = {
|
||||
start: Flags.string({
|
||||
description: 'Start of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of time range (ISO date)',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show results from the last N days (mutually exclusive with --start/--end)',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
};
|
||||
|
||||
export const ObserveAfterFlag = {
|
||||
after: Flags.string({
|
||||
description:
|
||||
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
|
||||
}),
|
||||
};
|
||||
|
||||
export const ObserveAppVersionFlag = {
|
||||
'app-version': Flags.string({
|
||||
description: 'Filter by app version',
|
||||
}),
|
||||
};
|
||||
|
||||
export const ObserveUpdateIdFlag = {
|
||||
'update-id': Flags.string({
|
||||
description: 'Filter by EAS update ID',
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { AppPlatform } from '../graphql/generated';
|
||||
import { appPlatformDisplayNames } from '../platform';
|
||||
import renderTextTable from '../utils/renderTextTable';
|
||||
import { NavigationRouteWithPlatform } from './fetchNavigationRoutes';
|
||||
import { buildTimeRangeDescription } from './formatUtils';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
|
||||
export type NavigationStatKey = 'median' | 'p90' | 'count';
|
||||
|
||||
export const NAVIGATION_STAT_ALIASES: Record<string, NavigationStatKey> = {
|
||||
med: 'median',
|
||||
median: 'median',
|
||||
p90: 'p90',
|
||||
count: 'count',
|
||||
event_count: 'count',
|
||||
eventCount: 'count',
|
||||
};
|
||||
|
||||
export const NAVIGATION_STAT_DISPLAY_NAMES: Record<NavigationStatKey, string> = {
|
||||
median: 'Med',
|
||||
p90: 'P90',
|
||||
count: 'Count',
|
||||
};
|
||||
|
||||
export function resolveNavigationStatKey(input: string): NavigationStatKey {
|
||||
const resolved = NAVIGATION_STAT_ALIASES[input];
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Unknown statistic: "${input}". Valid options: ${Object.keys(NAVIGATION_STAT_ALIASES).join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
type NavigationRouteField = 'coldTtr' | 'warmTtr' | 'tti';
|
||||
|
||||
const FULL_METRIC_NAME_TO_FIELD: Record<string, NavigationRouteField> = {
|
||||
'expo.navigation.cold_ttr': 'coldTtr',
|
||||
'expo.navigation.warm_ttr': 'warmTtr',
|
||||
'expo.navigation.tti': 'tti',
|
||||
};
|
||||
|
||||
export const NAVIGATION_METRIC_NAMES = Object.keys(FULL_METRIC_NAME_TO_FIELD);
|
||||
|
||||
export function isNavigationMetric(metricName: string): boolean {
|
||||
return metricName in FULL_METRIC_NAME_TO_FIELD;
|
||||
}
|
||||
|
||||
function formatStatValue(stat: NavigationStatKey, value: number | null | undefined): string {
|
||||
if (value == null) {
|
||||
return '-';
|
||||
}
|
||||
if (stat === 'count') {
|
||||
return String(value);
|
||||
}
|
||||
return `${value.toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function formatMergedCell(
|
||||
stat: NavigationStatKey,
|
||||
statValue: number | null | undefined,
|
||||
count: number | null | undefined
|
||||
): string {
|
||||
const formatted = formatStatValue(stat, statValue);
|
||||
const countStr = count != null ? String(count) : '-';
|
||||
return `${formatted} (${countStr})`;
|
||||
}
|
||||
|
||||
export interface NavigationRouteValuesJson {
|
||||
routeName: string;
|
||||
platform: AppPlatform;
|
||||
metrics: Record<string, Partial<Record<NavigationStatKey, number | null>>>;
|
||||
}
|
||||
|
||||
export interface ObserveNavigationRoutesJsonOutput {
|
||||
routes: NavigationRouteValuesJson[];
|
||||
pageInfoByPlatform: Record<string, { hasNextPage: boolean; endCursor: string | null }>;
|
||||
}
|
||||
|
||||
function metricStat(
|
||||
node: NavigationRouteWithPlatform,
|
||||
metricName: string
|
||||
): { count: number; median: number | null; p90: number | null } {
|
||||
const field = FULL_METRIC_NAME_TO_FIELD[metricName];
|
||||
if (!field) {
|
||||
throw new EasCommandError(`Unknown navigation metric: "${metricName}"`);
|
||||
}
|
||||
const stat = node.route[field];
|
||||
return {
|
||||
count: stat.count,
|
||||
median: stat.median ?? null,
|
||||
p90: stat.p90 ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildObserveNavigationRoutesJson(
|
||||
routes: NavigationRouteWithPlatform[],
|
||||
metricNames: string[],
|
||||
stats: NavigationStatKey[],
|
||||
pageInfoByPlatform: Map<AppPlatform, { hasNextPage: boolean; endCursor?: string | null }>
|
||||
): ObserveNavigationRoutesJsonOutput {
|
||||
const jsonRoutes: NavigationRouteValuesJson[] = routes.map(node => {
|
||||
const metrics: Record<string, Partial<Record<NavigationStatKey, number | null>>> = {};
|
||||
for (const metricName of metricNames) {
|
||||
const values = metricStat(node, metricName);
|
||||
const statValues: Partial<Record<NavigationStatKey, number | null>> = {};
|
||||
for (const stat of stats) {
|
||||
statValues[stat] = values[stat] ?? null;
|
||||
}
|
||||
metrics[metricName] = statValues;
|
||||
}
|
||||
return {
|
||||
routeName: node.route.routeName,
|
||||
platform: node.platform,
|
||||
metrics,
|
||||
};
|
||||
});
|
||||
|
||||
const pageInfoByPlatformOutput: Record<
|
||||
string,
|
||||
{ hasNextPage: boolean; endCursor: string | null }
|
||||
> = {};
|
||||
for (const [platform, pageInfo] of pageInfoByPlatform) {
|
||||
pageInfoByPlatformOutput[platform] = {
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
endCursor: pageInfo.endCursor ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return { routes: jsonRoutes, pageInfoByPlatform: pageInfoByPlatformOutput };
|
||||
}
|
||||
|
||||
export interface BuildNavigationRoutesTableOptions {
|
||||
daysBack?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
pageInfoByPlatform?: Map<AppPlatform, { hasNextPage: boolean; endCursor?: string | null }>;
|
||||
}
|
||||
|
||||
export function buildObserveNavigationRoutesTable(
|
||||
routes: NavigationRouteWithPlatform[],
|
||||
metricNames: string[],
|
||||
stats: NavigationStatKey[],
|
||||
options?: BuildNavigationRoutesTableOptions
|
||||
): string {
|
||||
if (routes.length === 0) {
|
||||
return chalk.yellow('No navigation routes found.');
|
||||
}
|
||||
|
||||
const displayStats = stats.filter(s => s !== 'count');
|
||||
const hasCount = stats.includes('count');
|
||||
|
||||
const statsDesc =
|
||||
displayStats.length > 0
|
||||
? displayStats.map(s => NAVIGATION_STAT_DISPLAY_NAMES[s]).join(', ')
|
||||
: 'Navigation count';
|
||||
const timeDesc = buildTimeRangeDescription({
|
||||
daysBack: options?.daysBack,
|
||||
startTime: options?.startTime,
|
||||
endTime: options?.endTime,
|
||||
});
|
||||
const countSuffix = hasCount && displayStats.length > 0 ? ' (navigation count)' : '';
|
||||
const summaryLine = `${statsDesc} values${countSuffix}${timeDesc ? ` ${timeDesc}` : ''}`;
|
||||
|
||||
const byPlatform = new Map<AppPlatform, NavigationRouteWithPlatform[]>();
|
||||
for (const node of routes) {
|
||||
if (!byPlatform.has(node.platform)) {
|
||||
byPlatform.set(node.platform, []);
|
||||
}
|
||||
byPlatform.get(node.platform)!.push(node);
|
||||
}
|
||||
|
||||
const metricHeaders: string[] = [];
|
||||
for (const metricName of metricNames) {
|
||||
const displayName = getMetricDisplayName(metricName);
|
||||
if (displayStats.length > 0 && hasCount) {
|
||||
for (const stat of displayStats) {
|
||||
metricHeaders.push(
|
||||
displayStats.length > 1
|
||||
? `${displayName} ${NAVIGATION_STAT_DISPLAY_NAMES[stat]}`
|
||||
: displayName
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const stat of displayStats.length > 0
|
||||
? displayStats
|
||||
: (['count'] as NavigationStatKey[])) {
|
||||
metricHeaders.push(
|
||||
displayStats.length === 0
|
||||
? `${displayName} Count`
|
||||
: `${displayName} ${NAVIGATION_STAT_DISPLAY_NAMES[stat]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const headers = ['Route', ...metricHeaders];
|
||||
|
||||
const sections: string[] = [chalk.bold(summaryLine)];
|
||||
|
||||
for (const [platform, platformRoutes] of byPlatform) {
|
||||
sections.push('');
|
||||
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
|
||||
|
||||
const rows: string[][] = platformRoutes.map(node => {
|
||||
const cells: string[] = [];
|
||||
for (const metricName of metricNames) {
|
||||
const values = metricStat(node, metricName);
|
||||
if (displayStats.length > 0 && hasCount) {
|
||||
for (const stat of displayStats) {
|
||||
cells.push(formatMergedCell(stat, values[stat], values.count));
|
||||
}
|
||||
} else {
|
||||
for (const stat of displayStats.length > 0
|
||||
? displayStats
|
||||
: (['count'] as NavigationStatKey[])) {
|
||||
cells.push(formatStatValue(stat, values[stat]));
|
||||
}
|
||||
}
|
||||
}
|
||||
return [node.route.routeName, ...cells];
|
||||
});
|
||||
|
||||
sections.push(renderTextTable(headers, rows));
|
||||
|
||||
const pageInfo = options?.pageInfoByPlatform?.get(platform);
|
||||
if (pageInfo?.hasNextPage && pageInfo.endCursor) {
|
||||
sections.push(
|
||||
`Next page (${appPlatformDisplayNames[platform]}): --after ${pageInfo.endCursor}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
@@ -8,14 +8,24 @@ export const METRIC_ALIASES: Record<string, string> = {
|
||||
bundle_load: 'expo.app_startup.bundle_load_time',
|
||||
};
|
||||
|
||||
export const NAVIGATION_METRIC_ALIASES: Record<string, string> = {
|
||||
cold_ttr: 'expo.navigation.cold_ttr',
|
||||
warm_ttr: 'expo.navigation.warm_ttr',
|
||||
nav_tti: 'expo.navigation.tti',
|
||||
};
|
||||
|
||||
const KNOWN_FULL_NAMES = new Set(Object.values(METRIC_ALIASES));
|
||||
const KNOWN_FULL_NAVIGATION_NAMES = new Set(Object.values(NAVIGATION_METRIC_ALIASES));
|
||||
|
||||
export const METRIC_SHORT_NAMES: Record<string, string> = {
|
||||
'expo.app_startup.cold_launch_time': 'Cold Launch',
|
||||
'expo.app_startup.warm_launch_time': 'Warm Launch',
|
||||
'expo.app_startup.tti': 'TTI',
|
||||
'expo.app_startup.ttr': 'TTR',
|
||||
'expo.app_startup.tti': 'Startup TTI',
|
||||
'expo.app_startup.ttr': 'Startup TTR',
|
||||
'expo.app_startup.bundle_load_time': 'Bundle Load',
|
||||
'expo.navigation.cold_ttr': 'Nav Cold TTR',
|
||||
'expo.navigation.warm_ttr': 'Nav Warm TTR',
|
||||
'expo.navigation.tti': 'Nav TTI',
|
||||
};
|
||||
|
||||
export function resolveMetricName(input: string): string {
|
||||
@@ -30,6 +40,20 @@ export function resolveMetricName(input: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveNavigationMetricName(input: string): string {
|
||||
if (NAVIGATION_METRIC_ALIASES[input]) {
|
||||
return NAVIGATION_METRIC_ALIASES[input];
|
||||
}
|
||||
if (KNOWN_FULL_NAVIGATION_NAMES.has(input)) {
|
||||
return input;
|
||||
}
|
||||
throw new EasCommandError(
|
||||
`Unknown navigation metric: "${input}". Use a full metric name (e.g. expo.navigation.cold_ttr) or a short alias: ${Object.keys(
|
||||
NAVIGATION_METRIC_ALIASES
|
||||
).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
export function getMetricDisplayName(metricName: string): string {
|
||||
return METRIC_SHORT_NAMES[metricName] ?? metricName;
|
||||
}
|
||||
@@ -51,3 +51,8 @@ export function appPlatformsFromFlag(flag: PlatformFlagValue | undefined): AppPl
|
||||
}
|
||||
return [defaultAppPlatform];
|
||||
}
|
||||
|
||||
export const appPlatformToObservePlatform: Record<AppPlatform, AppObservePlatform> = {
|
||||
[AppPlatform.Android]: AppObservePlatform.Android,
|
||||
[AppPlatform.Ios]: AppObservePlatform.Ios,
|
||||
};
|
||||
@@ -1,12 +1,11 @@
|
||||
import { ExpoConfig, getConfig, getConfigFilePaths, modifyConfigAsync } from '@expo/config';
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import fs from 'fs-extra';
|
||||
import Joi from 'joi';
|
||||
import path from 'path';
|
||||
|
||||
import { isExpoInstalled } from './projectUtils';
|
||||
import Log from '../log';
|
||||
import { spawnExpoCommand } from '../utils/expoCli';
|
||||
|
||||
export type PublicExpoConfig = Omit<
|
||||
ExpoConfig,
|
||||
@@ -41,8 +40,6 @@ export async function createOrModifyExpoConfigAsync(
|
||||
}
|
||||
}
|
||||
|
||||
let wasExpoConfigWarnPrinted = false;
|
||||
|
||||
async function getExpoConfigInternalAsync(
|
||||
projectDir: string,
|
||||
opts: ExpoConfigOptionsInternal = {}
|
||||
@@ -56,35 +53,16 @@ async function getExpoConfigInternalAsync(
|
||||
|
||||
let exp: ExpoConfig;
|
||||
if (isExpoInstalled(projectDir)) {
|
||||
try {
|
||||
const { stdout } = await spawnAsync(
|
||||
'npx',
|
||||
['expo', 'config', '--json', ...(opts.isPublicConfig ? ['--type', 'public'] : [])],
|
||||
|
||||
{
|
||||
cwd: projectDir,
|
||||
env: {
|
||||
...process.env,
|
||||
...opts.env,
|
||||
EXPO_NO_DOTENV: '1',
|
||||
},
|
||||
}
|
||||
);
|
||||
exp = JSON.parse(stdout);
|
||||
} catch (err: any) {
|
||||
if (!wasExpoConfigWarnPrinted) {
|
||||
Log.warn(
|
||||
`Failed to read the app config from the project using "npx expo config" command: ${err.message}.`
|
||||
);
|
||||
Log.warn('Falling back to the version of "@expo/config" shipped with the EAS CLI.');
|
||||
wasExpoConfigWarnPrinted = true;
|
||||
const { stdout } = await spawnExpoCommand(
|
||||
projectDir,
|
||||
['config', '--json', ...(opts.isPublicConfig ? ['--type', 'public'] : [])],
|
||||
{
|
||||
env: {
|
||||
EXPO_NO_DOTENV: '1',
|
||||
},
|
||||
}
|
||||
exp = getConfig(projectDir, {
|
||||
skipSDKVersionRequirement: true,
|
||||
...(opts.isPublicConfig ? { isPublicConfig: true } : {}),
|
||||
...(opts.skipPlugins ? { skipPlugins: true } : {}),
|
||||
}).exp;
|
||||
}
|
||||
);
|
||||
exp = JSON.parse(stdout);
|
||||
} else {
|
||||
exp = getConfig(projectDir, {
|
||||
skipSDKVersionRequirement: true,
|
||||
|
||||
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user