mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-15 03:38:08 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
714aec69c7 | ||
|
|
bec6e670de | ||
|
|
900ad765bf | ||
|
|
7c7fad4a13 | ||
|
|
7226c72122 | ||
|
|
ae6e0fd7f6 | ||
|
|
d070f73bc5 | ||
|
|
325bb29b57 | ||
|
|
82a8e3c2d0 | ||
|
|
ec310dd115 | ||
|
|
84b09c4a28 | ||
|
|
d9338799a0 |
No files matched your search
+19
-2
@@ -10,10 +10,27 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Support custom params in `observe:events` results. ([#3620](https://github.com/expo/eas-cli/pull/3620) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Add `eas/deploy` function for EAS Hosting web deployments. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Add `eas/export` function for Expo web exports. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas update:insights <groupId>` command to display launch, crash, unique-user, and payload-size metrics for an update group. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `--insights` flag to `eas update:view <groupId>` to append the same metrics below the existing output. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
|
||||
- [eas-cli] Add `eas channel:insights --channel <name> --runtime-version <version>` command to display adoption and crash metrics for a channel + runtime version. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix `metadata:push` not deleting video previews from App Store Connect when removed from config. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Warn and skip unknown preview types in `metadata:push` with a helpful suggestion when the `APP_` screenshot prefix is mistakenly used. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Delete all existing previews in a preview set before uploading to avoid Apple's "Too many app previews" error. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
|
||||
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
|
||||
- [eas-cli] Add existing capability identifiers. ([#3615](https://github.com/expo/eas-cli/pull/3615) by [@jakex7](https://github.com/jakex7))
|
||||
|
||||
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "18.7.0",
|
||||
"version": "18.8.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "18.7.0",
|
||||
"version": "18.8.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -38,14 +38,14 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/eas-build-job": "18.8.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "18.5.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.6.0",
|
||||
"@expo/steps": "18.8.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
@@ -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 { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
@@ -51,6 +52,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
const evictUsedBefore = new Date();
|
||||
const credentialsManager = new CredentialsManager(ctx);
|
||||
const workingDirectory = ctx.getReactNativeProjectDirectory();
|
||||
let fastlaneResult: { derivedDataPath: string; workspacePath: string } | undefined;
|
||||
try {
|
||||
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
|
||||
return await credentialsManager.prepare();
|
||||
@@ -146,10 +148,10 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
|
||||
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
|
||||
const scheme = resolveScheme(ctx);
|
||||
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
|
||||
await runFastlaneGym(ctx, {
|
||||
return await runFastlaneGym(ctx, {
|
||||
credentials,
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
@@ -171,6 +173,18 @@ 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 parseAndReportXcactivitylog({
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
logger: ctx.logger,
|
||||
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
|
||||
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { BuildJob } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
|
||||
import { resolveEasCommandPrefixAndEnvAsync } from '../../utils/easCli';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/easCli', () => ({
|
||||
...jest.requireActual('../../utils/easCli'),
|
||||
resolveEasCommandPrefixAndEnvAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('easBuildInternal', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
|
||||
cmd: 'npx',
|
||||
args: ['-y', 'eas-cli@latest'],
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('calls resolveEasCommandPrefixAndEnvAsync in runEasBuildInternalAsync', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const job = {
|
||||
platform: 'android',
|
||||
secrets: { robotAccessToken: 'token' },
|
||||
} as unknown as BuildJob;
|
||||
|
||||
await expect(
|
||||
runEasBuildInternalAsync({
|
||||
job,
|
||||
logger,
|
||||
env: {},
|
||||
cwd: '/tmp/project',
|
||||
})
|
||||
).rejects.toThrow('build profile is missing in a build from git-based integration.');
|
||||
|
||||
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls resolveEasCommandPrefixAndEnvAsync in resolveEnvFromBuildProfileAsync', async () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as any;
|
||||
const ctx = {
|
||||
logger,
|
||||
env: {},
|
||||
job: { platform: 'ios' },
|
||||
} as any;
|
||||
|
||||
await expect(resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' })).rejects.toThrow(
|
||||
'build profile is missing in a build from git-based integration.'
|
||||
);
|
||||
|
||||
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
BuildJob,
|
||||
EasCliNpmTags,
|
||||
Env,
|
||||
Metadata,
|
||||
sanitizeBuildJob,
|
||||
sanitizeMetadata,
|
||||
} from '@expo/eas-build-job';
|
||||
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
|
||||
import { PipeMode, bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
@@ -14,7 +7,7 @@ import Joi from 'joi';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { isAtLeastNpm7Async } from '../utils/packageManager';
|
||||
import { resolveEasCommandPrefixAndEnvAsync } from '../utils/easCli';
|
||||
|
||||
const EasBuildInternalResultSchema = Joi.object<{ job: object; metadata: object }>({
|
||||
job: Joi.object().unknown(),
|
||||
@@ -121,33 +114,6 @@ export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
|
||||
return env;
|
||||
}
|
||||
|
||||
async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
|
||||
cmd: string;
|
||||
args: string[];
|
||||
extraEnv: Env;
|
||||
}> {
|
||||
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
|
||||
if (process.env.ENVIRONMENT === 'development') {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
} else if (process.env.ENVIRONMENT === 'staging') {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function validateEasBuildInternalResult<TJob extends BuildJob>({
|
||||
oldJob,
|
||||
result,
|
||||
|
||||
@@ -173,7 +173,9 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
return path.join(this.workingdir, 'env');
|
||||
}
|
||||
public get packageManager(): PackageManager {
|
||||
return resolvePackageManager(this.getReactNativeProjectDirectory());
|
||||
return resolvePackageManager(this.getReactNativeProjectDirectory(), {
|
||||
env: this.env,
|
||||
});
|
||||
}
|
||||
public get appConfig(): Promise<ExpoConfig> {
|
||||
if (!this._appConfig) {
|
||||
|
||||
@@ -22,6 +22,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /Users/expo/Library/Keychains
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
derived_data_path("./build")
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
|
||||
output_directory("/tmp/output")
|
||||
"
|
||||
`;
|
||||
@@ -45,6 +49,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
derived_data_path("./build")
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
|
||||
output_directory("/tmp/output")
|
||||
"
|
||||
`;
|
||||
@@ -70,6 +78,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
derived_data_path("./build")
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
|
||||
output_directory("/tmp/output")
|
||||
"
|
||||
`;
|
||||
@@ -97,6 +109,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
derived_data_path("./build")
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
|
||||
output_directory("/tmp/output")
|
||||
"
|
||||
`;
|
||||
@@ -117,6 +133,9 @@ destination("generic/platform=iOS Simulator")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -134,6 +153,9 @@ destination("platform=iOS Simulator,name=iPhone 15")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -153,5 +175,8 @@ destination("generic/platform=tvOS Simulator")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("./build/result-bundle.xcresult")
|
||||
"
|
||||
`;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { vol } from 'memfs';
|
||||
import path from 'path';
|
||||
|
||||
import { createTestIosJob } from '../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { BuildContext } from '../../context';
|
||||
import { Credentials } from '../credentials/manager';
|
||||
import { DistributionType } from '../credentials/provisioningProfile';
|
||||
import { runFastlaneGym } from '../fastlane';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../tvos', () => ({
|
||||
isTVOS: jest.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
const WORKING_DIR = '/workingdir';
|
||||
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
|
||||
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
|
||||
|
||||
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
|
||||
const job: Ios.Job = { ...createTestIosJob(), simulator };
|
||||
return new BuildContext(job, {
|
||||
workingdir: WORKING_DIR,
|
||||
logger: createMockLogger(),
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
env: { __API_SERVER_URL: 'http://api.expo.test' },
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
const ARCHIVE_CREDENTIALS: Credentials = {
|
||||
keychainPath: '/Users/expo/Library/Keychains/login.keychain',
|
||||
distributionType: DistributionType.APP_STORE,
|
||||
teamId: 'TEAM123',
|
||||
applicationTargetProvisioningProfile: {} as any,
|
||||
targetProvisioningProfiles: {
|
||||
'com.example.app': {
|
||||
bundleIdentifier: 'com.example.app',
|
||||
uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
|
||||
path: '/path/to/profile1.mobileprovision',
|
||||
target: 'com.example.app',
|
||||
teamId: 'TEAM123',
|
||||
name: 'Main App Profile',
|
||||
developerCertificate: Buffer.from('cert'),
|
||||
certificateCommonName: 'iPhone Distribution',
|
||||
distributionType: DistributionType.APP_STORE,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function readDerivedDataPathFromGymfile(gymfileContent: string, workspacePath: string): string {
|
||||
const match = gymfileContent.match(/derived_data_path\("([^"]+)"\)/);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Gymfile did not contain derived_data_path("..."). Content:\n${gymfileContent}`
|
||||
);
|
||||
}
|
||||
return path.resolve(workspacePath, match[1]);
|
||||
}
|
||||
|
||||
describe(runFastlaneGym, () => {
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
vol.fromJSON({ [path.join(IOS_DIR, '.keep')]: '' });
|
||||
(spawn as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: 'archive',
|
||||
simulator: false,
|
||||
credentials: ARCHIVE_CREDENTIALS,
|
||||
buildConfiguration: 'Release',
|
||||
},
|
||||
{
|
||||
name: 'simulator',
|
||||
simulator: true,
|
||||
credentials: null,
|
||||
buildConfiguration: 'Debug',
|
||||
},
|
||||
])('$name build', ({ simulator, credentials, buildConfiguration }) => {
|
||||
it('returns paths that match the generated Gymfile template', async () => {
|
||||
const ctx = makeIosBuildContext({ simulator });
|
||||
|
||||
const result = await runFastlaneGym(ctx, {
|
||||
scheme: 'App',
|
||||
buildConfiguration,
|
||||
credentials,
|
||||
entitlements: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
derivedDataPath: path.join(IOS_DIR, 'build'),
|
||||
workspacePath: IOS_DIR,
|
||||
});
|
||||
|
||||
const gymfileContent = vol.readFileSync(path.join(IOS_DIR, 'Gymfile'), 'utf8') as string;
|
||||
expect(readDerivedDataPathFromGymfile(gymfileContent, result.workspacePath)).toBe(
|
||||
result.derivedDataPath
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('still returns EAS-convention paths when a pre-existing ios/Gymfile is present', async () => {
|
||||
vol.fromJSON({
|
||||
[path.join(IOS_DIR, 'Gymfile')]: '# user-supplied Gymfile\n',
|
||||
});
|
||||
|
||||
const ctx = makeIosBuildContext({ simulator: false });
|
||||
|
||||
const result = await runFastlaneGym(ctx, {
|
||||
scheme: 'App',
|
||||
credentials: ARCHIVE_CREDENTIALS,
|
||||
entitlements: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
derivedDataPath: path.join(IOS_DIR, 'build'),
|
||||
workspacePath: IOS_DIR,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,8 @@ describe('gymfile', () => {
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Release',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -94,6 +96,8 @@ describe('gymfile', () => {
|
||||
credentials: mockCredentials,
|
||||
scheme: 'TestScheme',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: false,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -131,6 +135,8 @@ describe('gymfile', () => {
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Release',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
entitlements: {
|
||||
@@ -192,6 +198,8 @@ describe('gymfile', () => {
|
||||
credentials: mockCredentials,
|
||||
scheme: 'MyApp',
|
||||
outputDirectory: '/tmp/output',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
});
|
||||
@@ -210,6 +218,7 @@ describe('gymfile', () => {
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Debug',
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
simulatorDestination: 'generic/platform=iOS Simulator',
|
||||
@@ -226,6 +235,7 @@ describe('gymfile', () => {
|
||||
outputFile,
|
||||
scheme: 'TestApp',
|
||||
derivedDataPath: '/tmp/derived',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: false,
|
||||
logsDirectory: '/tmp/logs',
|
||||
simulatorDestination: 'platform=iOS Simulator,name=iPhone 15',
|
||||
@@ -243,6 +253,7 @@ describe('gymfile', () => {
|
||||
scheme: 'MyTVApp',
|
||||
buildConfiguration: 'Debug',
|
||||
derivedDataPath: '/tmp/derived',
|
||||
resultBundlePath: './build/result-bundle.xcresult',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
simulatorDestination: 'generic/platform=tvOS Simulator',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { bunyan } from '@expo/logger';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import nullthrows from 'nullthrows';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import type { Credentials } from './credentials/manager';
|
||||
@@ -28,7 +29,13 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
entitlements: object | null;
|
||||
extraEnv?: Env;
|
||||
}
|
||||
): Promise<void> {
|
||||
): Promise<{
|
||||
derivedDataPath: string;
|
||||
workspacePath: string;
|
||||
}> {
|
||||
const workspacePath = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
|
||||
const derivedDataPath = path.join(workspacePath, 'build');
|
||||
|
||||
await ensureGymfileExists(ctx, {
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
@@ -43,13 +50,15 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
|
||||
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
|
||||
try {
|
||||
await runFastlane(['gym'], {
|
||||
cwd: path.join(ctx.getReactNativeProjectDirectory(), 'ios'),
|
||||
cwd: workspacePath,
|
||||
logger: ctx.logger,
|
||||
env: { ...ctx.env, ...extraEnv },
|
||||
});
|
||||
} finally {
|
||||
await buildLogger.flush();
|
||||
}
|
||||
|
||||
return { derivedDataPath, workspacePath };
|
||||
}
|
||||
|
||||
export async function runFastlaneResign<TJob extends Ios.Job>(
|
||||
@@ -122,6 +131,8 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
|
||||
}
|
||||
|
||||
ctx.logger.info('Creating Gymfile');
|
||||
const resultBundlePath = path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`);
|
||||
|
||||
if (ctx.job.simulator) {
|
||||
const isTV = await isTVOS(ctx);
|
||||
const simulatorDestination = `generic/platform=${isTV ? 'tvOS' : 'iOS'} Simulator`;
|
||||
@@ -131,6 +142,7 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
|
||||
scheme,
|
||||
buildConfiguration: buildConfiguration ?? 'release',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath,
|
||||
clean: false,
|
||||
logsDirectory,
|
||||
simulatorDestination,
|
||||
@@ -142,6 +154,8 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
outputDirectory: './build',
|
||||
derivedDataPath: './build',
|
||||
resultBundlePath,
|
||||
clean: false,
|
||||
logsDirectory,
|
||||
entitlements: entitlements ?? undefined,
|
||||
|
||||
@@ -11,6 +11,8 @@ interface ArchiveBuildOptions {
|
||||
scheme: string;
|
||||
buildConfiguration?: string;
|
||||
outputDirectory: string;
|
||||
derivedDataPath: string;
|
||||
resultBundlePath: string;
|
||||
clean: boolean;
|
||||
logsDirectory: string;
|
||||
entitlements?: object;
|
||||
@@ -21,6 +23,7 @@ interface SimulatorBuildOptions {
|
||||
scheme: string;
|
||||
buildConfiguration?: string;
|
||||
derivedDataPath: string;
|
||||
resultBundlePath: string;
|
||||
clean: boolean;
|
||||
logsDirectory: string;
|
||||
simulatorDestination: string;
|
||||
@@ -34,6 +37,8 @@ export async function createGymfileForArchiveBuild({
|
||||
buildConfiguration,
|
||||
entitlements,
|
||||
outputDirectory,
|
||||
derivedDataPath,
|
||||
resultBundlePath,
|
||||
logsDirectory,
|
||||
}: ArchiveBuildOptions): Promise<void> {
|
||||
const PROFILES: { BUNDLE_ID: string; UUID: string }[] = [];
|
||||
@@ -59,6 +64,8 @@ export async function createGymfileForArchiveBuild({
|
||||
SCHEME: scheme,
|
||||
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
|
||||
OUTPUT_DIRECTORY: outputDirectory,
|
||||
DERIVED_DATA_PATH: derivedDataPath,
|
||||
RESULT_BUNDLE_PATH: resultBundlePath,
|
||||
EXPORT_METHOD: credentials.distributionType,
|
||||
CLEAN: String(clean),
|
||||
LOGS_DIRECTORY: logsDirectory,
|
||||
@@ -74,6 +81,7 @@ export async function createGymfileForSimulatorBuild({
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
derivedDataPath,
|
||||
resultBundlePath,
|
||||
logsDirectory,
|
||||
simulatorDestination,
|
||||
}: SimulatorBuildOptions): Promise<void> {
|
||||
@@ -86,6 +94,7 @@ export async function createGymfileForSimulatorBuild({
|
||||
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
|
||||
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
|
||||
DERIVED_DATA_PATH: derivedDataPath,
|
||||
RESULT_BUNDLE_PATH: resultBundlePath,
|
||||
CLEAN: String(clean),
|
||||
LOGS_DIRECTORY: logsDirectory,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getEasFunctionGroups } from '../easFunctionGroups';
|
||||
|
||||
describe(getEasFunctionGroups, () => {
|
||||
it('includes eas/maestro_test for non-build jobs', () => {
|
||||
const ctx = {
|
||||
hasBuildJob: () => false,
|
||||
job: {},
|
||||
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
|
||||
|
||||
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
|
||||
functionGroup.getFullId()
|
||||
);
|
||||
expect(functionGroupIds).toEqual(['eas/maestro_test']);
|
||||
});
|
||||
|
||||
it('includes eas/build for build jobs', () => {
|
||||
const ctx = {
|
||||
hasBuildJob: () => true,
|
||||
job: {},
|
||||
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
|
||||
|
||||
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
|
||||
functionGroup.getFullId()
|
||||
);
|
||||
expect(functionGroupIds).toEqual(expect.arrayContaining(['eas/build', 'eas/maestro_test']));
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
|
||||
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
|
||||
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
|
||||
import { createDownloadBuildFunction } from './functions/downloadBuild';
|
||||
import { createEasDeployBuildFunction } from './functions/deploy';
|
||||
import { createEasExportBuildFunction } from './functions/export';
|
||||
import { eagerBundleBuildFunction } from './functions/eagerBundle';
|
||||
import { createFindAndUploadBuildArtifactsBuildFunction } from './functions/findAndUploadBuildArtifacts';
|
||||
import { generateGymfileFromTemplateFunction } from './functions/generateGymfileFromTemplate';
|
||||
@@ -30,11 +32,13 @@ import {
|
||||
createRestoreBuildCacheFunction,
|
||||
} from './functions/restoreBuildCache';
|
||||
import { createRestoreCacheFunction } from './functions/restoreCache';
|
||||
import { parseXcactivitylogFunction } from './functions/parseXcactivitylog';
|
||||
import { runFastlaneFunction } from './functions/runFastlane';
|
||||
import { runGradleFunction } from './functions/runGradle';
|
||||
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
|
||||
import { createSaveCacheFunction } from './functions/saveCache';
|
||||
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
|
||||
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
|
||||
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
|
||||
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
|
||||
@@ -55,6 +59,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
createEasDeployBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
createRestoreCacheFunction(),
|
||||
createRestoreBuildCacheFunction(),
|
||||
@@ -71,6 +77,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
configureIosVersionFunction(),
|
||||
generateGymfileFromTemplateFunction(),
|
||||
runFastlaneFunction(),
|
||||
parseXcactivitylogFunction(),
|
||||
createStartAgentDeviceRemoteSessionBuildFunction(),
|
||||
createStartAndroidEmulatorBuildFunction(),
|
||||
createStartCuttlefishDeviceBuildFunction(),
|
||||
createStartIosSimulatorBuildFunction(),
|
||||
|
||||
@@ -107,6 +107,29 @@ describe(createEasBuildBuildFunctionGroup, () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('wires configure_ios_credentials target_names into configure_ios_version', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
buildCredentials: { test: {} },
|
||||
});
|
||||
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
|
||||
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
|
||||
callInputs: { working_directory: './my-app' },
|
||||
});
|
||||
|
||||
const configureIosVersionStep = steps.find(s => s.displayName === 'Configure iOS version');
|
||||
const targetNamesInput = configureIosVersionStep?.inputs?.find(
|
||||
input => input.id === 'target_names'
|
||||
);
|
||||
|
||||
expect(configureIosVersionStep).toBeDefined();
|
||||
expect(targetNamesInput?.rawValue).toBe(
|
||||
'${{ steps.configure_ios_credentials.outputs.target_names }}'
|
||||
);
|
||||
});
|
||||
|
||||
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
|
||||
const buildToolsContext = createMockBuildToolsContext({
|
||||
platform: Platform.IOS,
|
||||
|
||||
@@ -251,10 +251,14 @@ function createStepsForIosBuildWithCredentials({
|
||||
installPods,
|
||||
configureEASUpdate,
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'configure_ios_credentials',
|
||||
workingDirectory,
|
||||
}),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
workingDirectory,
|
||||
callInputs: {
|
||||
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
|
||||
},
|
||||
}),
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createTestIosJob } from '../../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { configureCredentialsAsync } from '../../utils/ios/configure';
|
||||
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
|
||||
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
|
||||
import { configureIosCredentialsFunction } from '../configureIosCredentials';
|
||||
|
||||
jest.mock('../../utils/ios/configure');
|
||||
jest.mock('../../utils/ios/credentials/manager');
|
||||
|
||||
function createCredentials({
|
||||
targetNames = ['app', 'share-extension'],
|
||||
}: {
|
||||
targetNames?: string[];
|
||||
} = {}): Credentials {
|
||||
return {
|
||||
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
|
||||
keychainPath: '/tmp/keychain',
|
||||
targetProvisioningProfiles: Object.fromEntries(
|
||||
targetNames.map(targetName => [
|
||||
targetName,
|
||||
{
|
||||
path: `/tmp/${targetName}.mobileprovision`,
|
||||
target: targetName,
|
||||
bundleIdentifier: `com.example.${targetName}`,
|
||||
teamId: 'TEAMID123',
|
||||
uuid: `${targetName}-uuid`,
|
||||
name: `${targetName} profile`,
|
||||
distributionType: DistributionType.AD_HOC,
|
||||
developerCertificate: Buffer.from('certificate'),
|
||||
certificateCommonName: `${targetName} cert`,
|
||||
},
|
||||
])
|
||||
),
|
||||
distributionType: DistributionType.AD_HOC,
|
||||
teamId: 'TEAMID123',
|
||||
};
|
||||
}
|
||||
|
||||
function createRawCredentials() {
|
||||
return {
|
||||
app: {
|
||||
provisioningProfileBase64: 'cHJvZmlsZQ==',
|
||||
distributionCertificate: {
|
||||
dataBase64: 'Y2VydA==',
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe(configureIosCredentialsFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(configureCredentialsAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('exports target_names from prepared credentials', async () => {
|
||||
jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
|
||||
|
||||
const buildStep = configureIosCredentialsFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { job: createTestIosJob() },
|
||||
}),
|
||||
{
|
||||
callInputs: {
|
||||
credentials: createRawCredentials(),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(configureCredentialsAsync).toHaveBeenCalled();
|
||||
expect(buildStep.outputById.target_names.value).toBe(JSON.stringify(['app', 'widget']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { BuildWorkflow } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createTestIosJob } from '../../../__tests__/utils/job';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { updateVersionsAsync } from '../../utils/ios/configure';
|
||||
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
|
||||
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
|
||||
import { configureIosCredentialsFunction } from '../configureIosCredentials';
|
||||
import { configureIosVersionFunction } from '../configureIosVersion';
|
||||
|
||||
jest.mock('../../utils/ios/configure');
|
||||
jest.mock('../../utils/ios/credentials/manager');
|
||||
|
||||
function createCredentials({
|
||||
targetNames = ['app', 'share-extension'],
|
||||
}: {
|
||||
targetNames?: string[];
|
||||
} = {}): Credentials {
|
||||
return {
|
||||
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
|
||||
keychainPath: '/tmp/keychain',
|
||||
targetProvisioningProfiles: Object.fromEntries(
|
||||
targetNames.map(targetName => [
|
||||
targetName,
|
||||
{
|
||||
path: `/tmp/${targetName}.mobileprovision`,
|
||||
target: targetName,
|
||||
bundleIdentifier: `com.example.${targetName}`,
|
||||
teamId: 'TEAMID123',
|
||||
uuid: `${targetName}-uuid`,
|
||||
name: `${targetName} profile`,
|
||||
distributionType: DistributionType.AD_HOC,
|
||||
developerCertificate: Buffer.from('certificate'),
|
||||
certificateCommonName: `${targetName} cert`,
|
||||
},
|
||||
])
|
||||
),
|
||||
distributionType: DistributionType.AD_HOC,
|
||||
teamId: 'TEAMID123',
|
||||
};
|
||||
}
|
||||
|
||||
function createRawCredentials() {
|
||||
return {
|
||||
app: {
|
||||
provisioningProfileBase64: 'cHJvZmlsZQ==',
|
||||
distributionCertificate: {
|
||||
dataBase64: 'Y2VydA==',
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createBuildStep({
|
||||
callInputs = {},
|
||||
job = createTestIosJob(),
|
||||
}: {
|
||||
callInputs?: Record<string, unknown>;
|
||||
job?: ReturnType<typeof createTestIosJob>;
|
||||
} = {}) {
|
||||
return configureIosVersionFunction().createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: { job },
|
||||
}),
|
||||
{ callInputs }
|
||||
);
|
||||
}
|
||||
|
||||
describe(configureIosVersionFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(updateVersionsAsync).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('uses target_names without an explicit credentials input', async () => {
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: ['app', 'widget'],
|
||||
build_number: '42',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: '42',
|
||||
appVersion: undefined,
|
||||
},
|
||||
{
|
||||
targetNames: ['app', 'widget'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('uses credentials only to preserve the existing fallback path', async () => {
|
||||
jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
credentials: createRawCredentials(),
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(IosCredentialsManager.prototype.prepare).toHaveBeenCalledTimes(1);
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: ['app', 'widget'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the implicit credentials default fallback from the job secrets', async () => {
|
||||
const prepareSpy = jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
|
||||
const buildStep = createBuildStep({
|
||||
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
|
||||
callInputs: {
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(prepareSpy).toHaveBeenCalledTimes(1);
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: ['app', 'widget'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers target_names when both target_names and credentials are provided', async () => {
|
||||
const prepareSpy = jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['credentials-target'] }));
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: ['input-target'],
|
||||
credentials: createRawCredentials(),
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(prepareSpy).not.toHaveBeenCalled();
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: ['input-target'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('parses target_names exported by configure_ios_credentials through workflow interpolation', async () => {
|
||||
const prepareSpy = jest
|
||||
.spyOn(IosCredentialsManager.prototype, 'prepare')
|
||||
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: createMockLogger(),
|
||||
staticContextContent: {
|
||||
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
|
||||
},
|
||||
});
|
||||
const configureIosCredentialsStep =
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'configure_ios_credentials',
|
||||
});
|
||||
const configureIosVersionStep = configureIosVersionFunction().createBuildStepFromFunctionCall(
|
||||
globalCtx,
|
||||
{
|
||||
callInputs: {
|
||||
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await new BuildWorkflow(globalCtx, {
|
||||
buildSteps: [configureIosCredentialsStep, configureIosVersionStep],
|
||||
buildFunctions: {},
|
||||
}).executeAsync();
|
||||
|
||||
expect(prepareSpy).toHaveBeenCalledTimes(1);
|
||||
expect(configureIosCredentialsStep.outputById.target_names.value).toBe(
|
||||
JSON.stringify(['app', 'widget'])
|
||||
);
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
configureIosVersionStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: ['app', 'widget'],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an empty target_names array without crashing', async () => {
|
||||
const prepareSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: [],
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
|
||||
|
||||
expect(prepareSpy).not.toHaveBeenCalled();
|
||||
expect(updateVersionsAsync).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
buildStep.ctx.workingDirectory,
|
||||
{
|
||||
buildNumber: undefined,
|
||||
appVersion: '1.2.3',
|
||||
},
|
||||
{
|
||||
targetNames: [],
|
||||
buildConfiguration: 'Release',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid target_names', async () => {
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: ['app', 123],
|
||||
app_version: '1.2.3',
|
||||
},
|
||||
});
|
||||
|
||||
const promise = buildStep.executeAsync();
|
||||
await expect(promise).rejects.toBeInstanceOf(UserError);
|
||||
await expect(promise).rejects.toThrow('"target_names" input must be an array of strings.');
|
||||
});
|
||||
|
||||
it('uses the corrected App version error text', async () => {
|
||||
const buildStep = createBuildStep({
|
||||
callInputs: {
|
||||
target_names: ['app'],
|
||||
app_version: 'invalid-semver',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow(
|
||||
'App version provided by the "app_version" input is not a valid semver version: invalid-semver'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { runEasCliCommand } from '../../../utils/easCli';
|
||||
import { createEasDeployBuildFunction } from '../deploy';
|
||||
|
||||
jest.mock('../../../utils/easCli', () => ({
|
||||
runEasCliCommand: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockDeployStdout = JSON.stringify({
|
||||
url: 'https://example.dev',
|
||||
production: { url: 'https://example.prod' },
|
||||
aliases: [{ url: 'https://example.alias' }],
|
||||
identifier: 'abc',
|
||||
dashboardUrl: 'https://expo.dev/dashboard',
|
||||
});
|
||||
|
||||
describe(createEasDeployBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('passes deploy flags from function inputs and sets deploy_json output', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(mockDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
alias: 'preview-link',
|
||||
prod: true,
|
||||
source_maps: false,
|
||||
},
|
||||
env: { HOME: '/tmp/home' },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Running deploy command: eas deploy --non-interactive --json --export-dir dist --alias preview-link --prod'
|
||||
);
|
||||
expect(runEasCliCommand).toHaveBeenCalledWith({
|
||||
args: [
|
||||
'deploy',
|
||||
'--non-interactive',
|
||||
'--json',
|
||||
'--export-dir',
|
||||
'dist',
|
||||
'--alias',
|
||||
'preview-link',
|
||||
'--prod',
|
||||
],
|
||||
options: expect.objectContaining({
|
||||
cwd: buildStep.ctx.workingDirectory,
|
||||
env: expect.objectContaining({ HOME: '/tmp/home' }),
|
||||
logger: buildStep.ctx.logger,
|
||||
}),
|
||||
});
|
||||
expect(buildStep.outputById.deploy_json.value).toBe(mockDeployStdout);
|
||||
expect(buildStep.outputById.deploy_url.value).toBe('https://example.prod');
|
||||
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev');
|
||||
expect(buildStep.outputById.deploy_identifier.value).toBe('abc');
|
||||
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard');
|
||||
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias');
|
||||
});
|
||||
|
||||
it('forwards workflow step env to the eas deploy process', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(mockDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { source_maps: true },
|
||||
env: { HOME: '/tmp/home', EXAMPLE_WORKFLOW_ENV: 'from-workflow' },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(runEasCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: expect.arrayContaining(['--source-maps']),
|
||||
})
|
||||
);
|
||||
expect(runEasCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
HOME: '/tmp/home',
|
||||
EXAMPLE_WORKFLOW_ENV: 'from-workflow',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an actionable error message when deploy command fails', async () => {
|
||||
jest.mocked(runEasCliCommand).mockRejectedValue(new Error('deploy failed'));
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
|
||||
let errorMessage = '';
|
||||
try {
|
||||
await buildStep.executeAsync();
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
expect(errorMessage).toContain('Deploy command failed.');
|
||||
expect(errorMessage).toContain('This can happen when deploy inputs are invalid');
|
||||
expect(errorMessage).toContain(
|
||||
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.'
|
||||
);
|
||||
expect(errorMessage).toContain('Original error: deploy failed');
|
||||
});
|
||||
|
||||
it('passes custom export_dir to eas deploy', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(mockDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { export_dir: 'web-build' },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(runEasCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: expect.arrayContaining(['--export-dir', 'web-build']),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not pass --source-maps when source_maps is false', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(mockDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: { source_maps: false },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
const { args } = jest.mocked(runEasCliCommand).mock.calls[0][0];
|
||||
expect(args).not.toContain('--source-maps');
|
||||
});
|
||||
|
||||
it('sets deploy_json and skips optional outputs when stdout is not valid JSON', async () => {
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from('not-json'),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.deploy_json.value).toBe('not-json');
|
||||
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.anything() }),
|
||||
expect.stringContaining('Failed to parse')
|
||||
);
|
||||
expect(buildStep.outputById.deploy_url.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets outputs from partial deploy JSON and leaves missing fields undefined', async () => {
|
||||
const partialDeployStdout = JSON.stringify({
|
||||
url: 'https://example.dev-only',
|
||||
dashboardUrl: 'https://expo.dev/dashboard-only',
|
||||
});
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(partialDeployStdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.deploy_json.value).toBe(partialDeployStdout);
|
||||
expect(buildStep.outputById.deploy_url.value).toBe('https://example.dev-only');
|
||||
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev-only');
|
||||
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard-only');
|
||||
expect(buildStep.outputById.deploy_identifier.value).toBeUndefined();
|
||||
expect(buildStep.outputById.deploy_alias_url.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets deploy_url from aliases[0].url when production is absent but aliases exist', async () => {
|
||||
const stdout = JSON.stringify({
|
||||
url: 'https://example.deployment',
|
||||
aliases: [{ url: 'https://example.alias-first' }],
|
||||
identifier: 'partial-alias',
|
||||
});
|
||||
jest.mocked(runEasCliCommand).mockResolvedValue({
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(''),
|
||||
pid: 1,
|
||||
output: [],
|
||||
status: 0,
|
||||
signal: null,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.outputById.deploy_url.value).toBe('https://example.alias-first');
|
||||
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.deployment');
|
||||
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias-first');
|
||||
expect(buildStep.outputById.deploy_identifier.value).toBe('partial-alias');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
jest.mock('../../../utils/project', () => ({
|
||||
runExpoCliCommand: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../utils/packageManager', () => ({
|
||||
...jest.requireActual('../../../utils/packageManager'),
|
||||
resolvePackageManager: jest.fn(),
|
||||
}));
|
||||
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { PackageManager, resolvePackageManager } from '../../../utils/packageManager';
|
||||
import { runExpoCliCommand } from '../../../utils/project';
|
||||
import { createEasExportBuildFunction } from '../export';
|
||||
|
||||
describe(createEasExportBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.NPM);
|
||||
jest.mocked(runExpoCliCommand).mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
it('runs expo export via runExpoCliCommand with default output_dir and platform web', async () => {
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
env: { HOME: '/tmp/home' },
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
|
||||
'Running export command: expo export --output-dir dist --platform web'
|
||||
);
|
||||
expect(runExpoCliCommand).toHaveBeenCalledWith({
|
||||
packageManager: PackageManager.NPM,
|
||||
args: ['export', '--output-dir', 'dist', '--platform', 'web'],
|
||||
options: {
|
||||
cwd: buildStep.ctx.workingDirectory,
|
||||
env: expect.objectContaining({ HOME: '/tmp/home' }),
|
||||
logger: buildStep.ctx.logger,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
});
|
||||
expect(buildStep.outputById.export_dir.value).toBe('dist');
|
||||
});
|
||||
|
||||
it('passes optional flags matching expo export CLI', async () => {
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
output_dir: 'web-out',
|
||||
dev: true,
|
||||
minify: false,
|
||||
dump_assetmap: true,
|
||||
ssg: false,
|
||||
api_only: true,
|
||||
platform: 'web',
|
||||
},
|
||||
});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(runExpoCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
args: [
|
||||
'export',
|
||||
'--output-dir',
|
||||
'web-out',
|
||||
'--platform',
|
||||
'web',
|
||||
'--dev',
|
||||
'--no-minify',
|
||||
'--dump-assetmap',
|
||||
'--no-ssg',
|
||||
'--api-only',
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(buildStep.outputById.export_dir.value).toBe('web-out');
|
||||
});
|
||||
|
||||
it('uses resolved package manager (e.g. yarn)', async () => {
|
||||
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.YARN);
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(runExpoCliCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ packageManager: PackageManager.YARN })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when export command fails', async () => {
|
||||
jest.mocked(runExpoCliCommand).mockRejectedValue(new Error('export failed'));
|
||||
|
||||
const logger = createMockLogger();
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger,
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
});
|
||||
|
||||
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow('Export command failed: export failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { parseAndReportXcactivitylog } from '../../utils/ios/xcactivitylog';
|
||||
import { parseXcactivitylogFunction } from '../parseXcactivitylog';
|
||||
|
||||
jest.mock('../../utils/ios/xcactivitylog');
|
||||
|
||||
describe(parseXcactivitylogFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('passes proxyBaseUrl from env into parseAndReportXcactivitylog', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
globalContext.updateEnv({ EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com' });
|
||||
|
||||
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
proxyBaseUrl: 'https://cache.example.com',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes xclogparserVersion as undefined when the input is not provided', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
xclogparserVersion: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves derived_data_path and workspace_path inputs against stepCtx.workingDirectory', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {
|
||||
derived_data_path: 'my-app/ios/Build',
|
||||
workspace_path: 'my-app/ios',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
derivedDataPath: expect.stringMatching(/\/my-app\/ios\/Build$/),
|
||||
workspacePath: expect.stringMatching(/\/my-app\/ios$/),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts absolute paths for derived_data_path and workspace_path', async () => {
|
||||
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
|
||||
|
||||
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
|
||||
callInputs: {
|
||||
derived_data_path: '/abs/derived',
|
||||
workspace_path: '/abs/workspace',
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
derivedDataPath: '/abs/derived',
|
||||
workspacePath: '/abs/workspace',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import assert from 'assert';
|
||||
|
||||
import { configureCredentialsAsync } from '../utils/ios/configure';
|
||||
@@ -26,9 +31,14 @@ export function configureIosCredentialsFunction(): BuildFunction {
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs }) => {
|
||||
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'target_names',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, outputs }) => {
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
|
||||
stripUnknown: true,
|
||||
convert: true,
|
||||
abortEarly: false,
|
||||
@@ -50,6 +60,7 @@ export function configureIosCredentialsFunction(): BuildFunction {
|
||||
inputs.build_configuration.value as string | undefined
|
||||
),
|
||||
});
|
||||
outputs.target_names.set(JSON.stringify(Object.keys(credentials.targetProvisioningProfiles)));
|
||||
|
||||
stepCtx.logger.info('Successfully configured iOS credentials');
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { Ios, UserError } from '@expo/eas-build-job';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import assert from 'assert';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { updateVersionsAsync } from '../utils/ios/configure';
|
||||
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
|
||||
@@ -17,10 +18,15 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'credentials',
|
||||
required: true,
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
defaultValue: '${ eas.job.secrets.buildCredentials }',
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'target_names',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'build_configuration',
|
||||
required: false,
|
||||
@@ -38,19 +44,6 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs }) => {
|
||||
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
|
||||
stripUnknown: true,
|
||||
convert: true,
|
||||
abortEarly: false,
|
||||
});
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const credentialsManager = new IosCredentialsManager(value);
|
||||
const credentials = await credentialsManager.prepare(stepCtx.logger);
|
||||
|
||||
assert(stepCtx.global.staticContext.job, 'Job is not defined');
|
||||
const job = stepCtx.global.staticContext.job as Ios.Job;
|
||||
|
||||
@@ -59,8 +52,9 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
const appVersion =
|
||||
(inputs.app_version.value as string | undefined) ?? job.version?.appVersion;
|
||||
if (appVersion && !semver.valid(appVersion)) {
|
||||
throw new Error(
|
||||
`App verrsion provided by the "app_version" input is not a valid semver version: ${appVersion}`
|
||||
throw new UserError(
|
||||
'EAS_CONFIGURE_IOS_VERSION_INVALID_APP_VERSION',
|
||||
`App version provided by the "app_version" input is not a valid semver version: ${appVersion}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +65,33 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetNamesInput = inputs.target_names.value;
|
||||
let targetNames: string[];
|
||||
if (targetNamesInput !== undefined) {
|
||||
const parsed = z.array(z.string()).safeParse(targetNamesInput);
|
||||
if (!parsed.success) {
|
||||
throw new UserError(
|
||||
'EAS_CONFIGURE_IOS_VERSION_INVALID_TARGET_NAMES',
|
||||
'"target_names" input must be an array of strings.',
|
||||
{ cause: parsed.error }
|
||||
);
|
||||
}
|
||||
targetNames = parsed.data;
|
||||
} else {
|
||||
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
|
||||
stripUnknown: true,
|
||||
convert: true,
|
||||
abortEarly: false,
|
||||
});
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const credentialsManager = new IosCredentialsManager(value);
|
||||
const credentials = await credentialsManager.prepare(stepCtx.logger);
|
||||
targetNames = Object.keys(credentials.targetProvisioningProfiles);
|
||||
}
|
||||
|
||||
stepCtx.logger.info('Setting iOS version...');
|
||||
if (buildNumber) {
|
||||
stepCtx.logger.info(`Build number: ${buildNumber}`);
|
||||
@@ -87,7 +108,7 @@ export function configureIosVersionFunction(): BuildFunction {
|
||||
appVersion,
|
||||
},
|
||||
{
|
||||
targetNames: Object.keys(credentials.targetProvisioningProfiles),
|
||||
targetNames,
|
||||
buildConfiguration: resolveBuildConfiguration(
|
||||
job,
|
||||
inputs.build_configuration.value as string | undefined
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { PipeMode, bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { runEasCliCommand } from '../../utils/easCli';
|
||||
|
||||
export function createEasDeployBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'deploy',
|
||||
name: 'Deploy',
|
||||
__metricsId: 'eas/deploy',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'alias',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'prod',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'source_maps',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
// Match eas/export default `output_dir`
|
||||
BuildStepInput.createProvider({
|
||||
id: 'export_dir',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
defaultValue: 'dist',
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_json',
|
||||
required: true,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_url',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_deployment_url',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_identifier',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_dashboard_url',
|
||||
required: false,
|
||||
}),
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'deploy_alias_url',
|
||||
required: false,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs, env }) => {
|
||||
const alias = inputs.alias.value as string | undefined;
|
||||
const prod = inputs.prod.value as boolean | undefined;
|
||||
const sourceMaps = inputs.source_maps.value as boolean | undefined;
|
||||
const exportDir = inputs.export_dir.value as string;
|
||||
|
||||
const deployCommand = getDeployCommand({
|
||||
exportDir,
|
||||
alias,
|
||||
prod,
|
||||
sourceMaps,
|
||||
});
|
||||
stepsCtx.logger.info(`Running deploy command: eas ${deployCommand.join(' ')}`);
|
||||
|
||||
try {
|
||||
const result = await runEasCliCommand({
|
||||
args: deployCommand,
|
||||
options: {
|
||||
cwd: stepsCtx.workingDirectory,
|
||||
env,
|
||||
logger: stepsCtx.logger,
|
||||
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
|
||||
},
|
||||
});
|
||||
|
||||
const deployJson = result.stdout.toString();
|
||||
outputs.deploy_json.set(deployJson);
|
||||
const parsedDeploymentOutput = parseDeploymentOutput({
|
||||
deployJson,
|
||||
logger: stepsCtx.logger,
|
||||
});
|
||||
outputs.deploy_url.set(parsedDeploymentOutput.deploy_url);
|
||||
outputs.deploy_deployment_url.set(parsedDeploymentOutput.deploy_deployment_url);
|
||||
outputs.deploy_identifier.set(parsedDeploymentOutput.deploy_identifier);
|
||||
outputs.deploy_dashboard_url.set(parsedDeploymentOutput.deploy_dashboard_url);
|
||||
outputs.deploy_alias_url.set(parsedDeploymentOutput.deploy_alias_url);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'unknown error';
|
||||
throw new UserError(
|
||||
'EAS_DEPLOY_FAILED',
|
||||
'Deploy command failed.\n\n' +
|
||||
`This can happen when deploy inputs are invalid, the export directory "${exportDir}" is missing, or EAS CLI authentication/network checks fail.\n` +
|
||||
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.\n' +
|
||||
`Original error: ${errorMessage}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getDeployCommand({
|
||||
exportDir,
|
||||
alias,
|
||||
prod,
|
||||
sourceMaps,
|
||||
}: {
|
||||
exportDir: string;
|
||||
alias?: string;
|
||||
prod?: boolean;
|
||||
sourceMaps?: boolean;
|
||||
}): string[] {
|
||||
const deployCommand = ['deploy', '--non-interactive', '--json', '--export-dir', exportDir];
|
||||
if (alias) {
|
||||
deployCommand.push('--alias', alias);
|
||||
}
|
||||
if (prod) {
|
||||
deployCommand.push('--prod');
|
||||
}
|
||||
if (sourceMaps) {
|
||||
deployCommand.push('--source-maps');
|
||||
}
|
||||
return deployCommand;
|
||||
}
|
||||
|
||||
function parseDeploymentOutput({ deployJson, logger }: { deployJson: string; logger: bunyan }): {
|
||||
deploy_url?: string;
|
||||
deploy_deployment_url?: string;
|
||||
deploy_identifier?: string;
|
||||
deploy_dashboard_url?: string;
|
||||
deploy_alias_url?: string;
|
||||
} {
|
||||
try {
|
||||
const deployObject = JSON.parse(deployJson);
|
||||
return {
|
||||
deploy_url:
|
||||
deployObject.production?.url || deployObject.aliases?.[0]?.url || deployObject.url,
|
||||
deploy_deployment_url: deployObject.url,
|
||||
deploy_identifier: deployObject.identifier,
|
||||
deploy_dashboard_url: deployObject.dashboardUrl,
|
||||
deploy_alias_url: deployObject.aliases?.[0]?.url,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Failed to parse "eas deploy" JSON output. Some outputs expected from this step will be undefined.'
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function eagerBundleBuildFunction(): BuildFunction {
|
||||
throw new Error('Eager bundle is not supported for SDK version < 52');
|
||||
}
|
||||
|
||||
const packageManager = resolvePackageManager(stepsCtx.workingDirectory);
|
||||
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
|
||||
const resolvedEASUpdateRuntimeVersion = inputs.resolved_eas_update_runtime_version.value as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
|
||||
import { resolvePackageManager } from '../../utils/packageManager';
|
||||
import { runExpoCliCommand } from '../../utils/project';
|
||||
|
||||
export function createEasExportBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'export',
|
||||
name: 'Export',
|
||||
__metricsId: 'eas/export',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'output_dir',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
defaultValue: 'dist',
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'dev',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'minify',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'dump_assetmap',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'ssg',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'api_only',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'platform',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
defaultValue: 'web',
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
id: 'export_dir',
|
||||
required: true,
|
||||
}),
|
||||
],
|
||||
fn: async (stepsCtx, { inputs, outputs, env }) => {
|
||||
const outputDir = inputs.output_dir.value as string;
|
||||
const dev = inputs.dev.value as boolean | undefined;
|
||||
const minify = inputs.minify.value as boolean | undefined;
|
||||
const dumpAssetmap = inputs.dump_assetmap.value as boolean | undefined;
|
||||
const ssg = inputs.ssg.value as boolean | undefined;
|
||||
const apiOnly = inputs.api_only.value as boolean | undefined;
|
||||
const platform = inputs.platform.value as string;
|
||||
|
||||
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
|
||||
const exportCommand = getExportCommand({
|
||||
outputDir,
|
||||
dev,
|
||||
minify,
|
||||
dumpAssetmap,
|
||||
ssg,
|
||||
apiOnly,
|
||||
platform,
|
||||
});
|
||||
stepsCtx.logger.info(`Running export command: expo ${exportCommand.join(' ')}`);
|
||||
|
||||
try {
|
||||
await runExpoCliCommand({
|
||||
packageManager,
|
||||
args: exportCommand,
|
||||
options: {
|
||||
cwd: stepsCtx.workingDirectory,
|
||||
env,
|
||||
logger: stepsCtx.logger,
|
||||
stdio: 'pipe',
|
||||
},
|
||||
});
|
||||
outputs.export_dir.set(outputDir);
|
||||
} catch (error) {
|
||||
throw new UserError(
|
||||
'EXPO_EXPORT_FAILED',
|
||||
`Export command failed: ${error instanceof Error ? error.message : 'unknown error'}`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getExportCommand({
|
||||
outputDir,
|
||||
dev,
|
||||
minify,
|
||||
dumpAssetmap,
|
||||
ssg,
|
||||
apiOnly,
|
||||
platform,
|
||||
}: {
|
||||
outputDir: string;
|
||||
dev?: boolean;
|
||||
minify?: boolean;
|
||||
dumpAssetmap?: boolean;
|
||||
ssg?: boolean;
|
||||
apiOnly?: boolean;
|
||||
platform: string;
|
||||
}): string[] {
|
||||
const exportCommand = ['export', '--output-dir', outputDir, '--platform', platform];
|
||||
if (dev) {
|
||||
exportCommand.push('--dev');
|
||||
}
|
||||
if (minify === false) {
|
||||
exportCommand.push('--no-minify');
|
||||
}
|
||||
if (dumpAssetmap) {
|
||||
exportCommand.push('--dump-assetmap');
|
||||
}
|
||||
if (ssg === false) {
|
||||
exportCommand.push('--no-ssg');
|
||||
}
|
||||
if (apiOnly) {
|
||||
exportCommand.push('--api-only');
|
||||
}
|
||||
return exportCommand;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
|
||||
import { templateString } from '@expo/template-file';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
|
||||
@@ -36,6 +37,10 @@ const DEFAULT_CREDENTIALS_TEMPLATE = `
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("<%- LOGS_DIRECTORY %>")
|
||||
|
||||
derived_data_path("<%- DERIVED_DATA_PATH %>")
|
||||
result_bundle(true)
|
||||
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
|
||||
|
||||
output_directory("<%- OUTPUT_DIRECTORY %>")
|
||||
`;
|
||||
|
||||
@@ -55,6 +60,9 @@ const DEFAULT_SIMULATOR_TEMPLATE = `
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("<%- LOGS_DIRECTORY %>")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
|
||||
`;
|
||||
|
||||
export function generateGymfileFromTemplateFunction(): BuildFunction {
|
||||
@@ -181,6 +189,7 @@ export function generateGymfileFromTemplateFunction(): BuildFunction {
|
||||
ICLOUD_CONTAINER_ENVIRONMENT,
|
||||
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
|
||||
DERIVED_DATA_PATH: './build',
|
||||
RESULT_BUNDLE_PATH: path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`),
|
||||
...(PROFILES ? { PROFILES } : {}),
|
||||
...(credentials
|
||||
? {
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function installNodeModules(
|
||||
env: BuildStepEnv
|
||||
): Promise<void> {
|
||||
const { logger } = stepCtx;
|
||||
const packageManager = resolvePackageManager(stepCtx.workingDirectory);
|
||||
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
|
||||
const packagerRunDir = findPackagerRootDir(stepCtx.workingDirectory);
|
||||
|
||||
if (packagerRunDir !== stepCtx.workingDirectory) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
} from '@expo/steps';
|
||||
import path from 'path';
|
||||
|
||||
import { parseAndReportXcactivitylog } from '../utils/ios/xcactivitylog';
|
||||
|
||||
export function parseXcactivitylogFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'parse_xcactivitylog',
|
||||
name: 'Analyze build performance',
|
||||
__metricsId: 'eas/parse_xcactivitylog',
|
||||
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'derived_data_path',
|
||||
required: false,
|
||||
defaultValue: 'ios/build',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'workspace_path',
|
||||
required: false,
|
||||
defaultValue: 'ios',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'xclogparser_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async (stepCtx, { inputs, env }) => {
|
||||
const derivedDataPath = inputs.derived_data_path.value as string;
|
||||
const workspacePath = inputs.workspace_path.value as string;
|
||||
const version = inputs.xclogparser_version.value as string | undefined;
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: path.resolve(stepCtx.workingDirectory, derivedDataPath),
|
||||
workspacePath: path.resolve(stepCtx.workingDirectory, workspacePath),
|
||||
xclogparserVersion: version,
|
||||
logger: stepCtx.logger,
|
||||
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function createPrebuildBuildFunction(): BuildFunction {
|
||||
fn: async (stepCtx, { inputs, env }) => {
|
||||
const { logger } = stepCtx;
|
||||
const appleTeamId = inputs.apple_team_id.value as string | undefined;
|
||||
const packageManager = resolvePackageManager(stepCtx.workingDirectory);
|
||||
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
|
||||
const defaultPlatform = process.platform === 'darwin' ? 'ios' : 'android';
|
||||
|
||||
const job = stepCtx.global.staticContext.job;
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import childProcess from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
|
||||
const SRC_DIR = '/tmp/agent-device-src';
|
||||
const RUN_DIR = '/tmp/agent-device';
|
||||
const DAEMON_LOG = path.join(RUN_DIR, 'daemon.log');
|
||||
const TUNNEL_LOG = path.join(RUN_DIR, 'cloudflared.log');
|
||||
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartAgentDeviceRemoteSessionBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'start_agent_device_remote_session',
|
||||
name: 'Start agent device remote session',
|
||||
__metricsId: 'eas/start_agent_device_remote_session',
|
||||
inputProviders: [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env }) => {
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}).`);
|
||||
|
||||
logger.info(`Preparing runtime directory at ${RUN_DIR}.`);
|
||||
await fs.promises.mkdir(RUN_DIR, { recursive: true });
|
||||
|
||||
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.');
|
||||
await ensureCloudflaredInstalledAsync({ env, logger });
|
||||
|
||||
logger.info('Ensuring bun is installed.');
|
||||
await ensureBunInstalledAsync({ env, logger });
|
||||
|
||||
logger.info(
|
||||
packageVersion
|
||||
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
|
||||
: `Cloning agent-device (latest) into ${SRC_DIR}.`
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
|
||||
logger.info('Installing agent-device dependencies.');
|
||||
await spawn('bun', ['install', '--production'], {
|
||||
cwd: SRC_DIR,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`Launching agent-device daemon (log file: ${DAEMON_LOG}).`);
|
||||
await spawnDetachedAsync({
|
||||
command: 'bun run src/daemon.ts',
|
||||
cwd: SRC_DIR,
|
||||
logFile: DAEMON_LOG,
|
||||
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
|
||||
await waitForFileAsync({
|
||||
filePath: DAEMON_JSON_PATH,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'agent-device daemon',
|
||||
});
|
||||
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} (log file: ${TUNNEL_LOG}).`
|
||||
);
|
||||
await spawnDetachedAsync({
|
||||
command: `cloudflared tunnel --url "http://localhost:${daemonPort}"`,
|
||||
logFile: TUNNEL_LOG,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Waiting for a public tunnel URL.');
|
||||
const tunnelUrl = await waitForMatchInLogAsync({
|
||||
logFile: TUNNEL_LOG,
|
||||
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'cloudflared tunnel',
|
||||
});
|
||||
logger.info(`Tunnel is ready at ${tunnelUrl}.`);
|
||||
|
||||
logger.info('Emitting agent-device credentials for the CLI to pick up:');
|
||||
logger.info(`export AGENT_DEVICE_DAEMON_BASE_URL="${tunnelUrl}"`);
|
||||
logger.info(`export AGENT_DEVICE_DAEMON_AUTH_TOKEN="${daemonToken}"`);
|
||||
|
||||
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
|
||||
// Keep the turtle job alive so the daemon and tunnel stay reachable
|
||||
// until stopDeviceRunSession cancels the run.
|
||||
await new Promise<never>(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureCloudflaredInstalledAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
|
||||
}
|
||||
|
||||
async function ensureBunInstalledAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
await ensureBrewPackageInstalledAsync({ name: 'bun', env, logger });
|
||||
}
|
||||
|
||||
async function ensureBrewPackageInstalledAsync({
|
||||
name,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
name: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
await spawn(
|
||||
'bash',
|
||||
['-c', `command -v ${name} >/dev/null 2>&1 || HOMEBREW_NO_AUTO_UPDATE=1 brew install ${name}`],
|
||||
{ env, logger }
|
||||
);
|
||||
}
|
||||
|
||||
async function cloneAgentDeviceAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
const branchArgs = packageVersion ? ['--branch', `v${packageVersion}`] : [];
|
||||
await spawn('git', ['clone', '--depth', '1', ...branchArgs, AGENT_DEVICE_REPO_URL, SRC_DIR], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
async function spawnDetachedAsync({
|
||||
command,
|
||||
cwd,
|
||||
logFile,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
logFile: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
// Launch the process fully detached so this function returns immediately and
|
||||
// the grandchild survives the step. Stdio goes to a log file so the daemon
|
||||
// output can be polled, and we unref so Node doesn't wait on it.
|
||||
const fd = fs.openSync(logFile, 'a');
|
||||
try {
|
||||
const child = childProcess.spawn('bash', ['-c', command], {
|
||||
cwd,
|
||||
env,
|
||||
detached: true,
|
||||
stdio: ['ignore', fd, fd],
|
||||
});
|
||||
if (!child.pid) {
|
||||
throw new Error(`Failed to spawn detached process: ${command}`);
|
||||
}
|
||||
child.unref();
|
||||
logger.info(`Started detached process (pid ${child.pid}).`);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMatchInLogAsync({
|
||||
logFile,
|
||||
pattern,
|
||||
timeoutMs,
|
||||
description,
|
||||
}: {
|
||||
logFile: string;
|
||||
pattern: RegExp;
|
||||
timeoutMs: number;
|
||||
description: string;
|
||||
}): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const content = await readFileOrEmptyAsync(logFile);
|
||||
const match = pattern.exec(content);
|
||||
if (match) {
|
||||
return match[1] ?? match[0];
|
||||
}
|
||||
await sleepAsync(1_000);
|
||||
}
|
||||
const tail = await readFileOrEmptyAsync(logFile);
|
||||
throw new Error(
|
||||
`Timed out waiting for ${description} to start. Last log contents:\n${tail || '<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 Error(`Timed out waiting for ${description} to write ${filePath}.`);
|
||||
}
|
||||
|
||||
async function readFileOrEmptyAsync(filePath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.promises.readFile(filePath, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function readDaemonInfo(filePath: string): { port: number; token: string } {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
typeof (parsed as { httpPort: unknown }).httpPort !== 'number' ||
|
||||
typeof (parsed as { token: unknown }).token !== 'string'
|
||||
) {
|
||||
throw new Error(`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`);
|
||||
}
|
||||
const { httpPort, token } = parsed as { httpPort: number; token: string };
|
||||
return { port: httpPort, token };
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../../../__tests__/utils/logger';
|
||||
import {
|
||||
buildTargetMetrics,
|
||||
isCompileStep,
|
||||
collectTopLevelCompileSteps,
|
||||
formatReport,
|
||||
parseAndReportXcactivitylog,
|
||||
} from '../xcactivitylog';
|
||||
|
||||
jest.mock('@expo/downloader');
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
// Minimal fixture: two modules with compile steps
|
||||
const FIXTURE_TWO_MODULES = {
|
||||
schema: { name: 'TestProject' },
|
||||
subSteps: [
|
||||
{
|
||||
title: 'Build target ModuleA',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 5.0,
|
||||
startTimestamp: 100.0,
|
||||
endTimestamp: 103.0,
|
||||
signature: 'CompileC foo.c',
|
||||
subSteps: [],
|
||||
},
|
||||
{
|
||||
detailStepType: 'other',
|
||||
signature: 'SwiftCompile normal bar.swift',
|
||||
duration: 3.0,
|
||||
startTimestamp: 101.0,
|
||||
endTimestamp: 104.0,
|
||||
subSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Build target ModuleB',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'compileAssetsCatalog',
|
||||
duration: 2.0,
|
||||
startTimestamp: 200.0,
|
||||
endTimestamp: 202.0,
|
||||
signature: 'CompileAssetCatalog',
|
||||
subSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Build target TinyModule',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 0.1,
|
||||
startTimestamp: 300.0,
|
||||
endTimestamp: 300.1,
|
||||
signature: 'CompileC tiny.c',
|
||||
subSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Fixture: empty data
|
||||
const FIXTURE_EMPTY = {
|
||||
schema: { name: 'EmptyProject' },
|
||||
subSteps: [],
|
||||
};
|
||||
|
||||
// Fixture: no compile steps (only non-compile sub-steps)
|
||||
const FIXTURE_NO_COMPILE = {
|
||||
schema: { name: 'NoCompileProject' },
|
||||
subSteps: [
|
||||
{
|
||||
title: 'Build target SomeTarget',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'linkCommand',
|
||||
duration: 5.0,
|
||||
startTimestamp: 100.0,
|
||||
endTimestamp: 105.0,
|
||||
signature: 'Ld something',
|
||||
subSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Fixture: missing timestamps
|
||||
const FIXTURE_MISSING_TIMESTAMPS = {
|
||||
schema: { name: 'MissingTimestamps' },
|
||||
subSteps: [
|
||||
{
|
||||
title: 'Build target PartialModule',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 3.0,
|
||||
signature: 'CompileC partial.c',
|
||||
subSteps: [],
|
||||
// no startTimestamp/endTimestamp
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Fixture: nested compile steps (compile step inside non-compile step)
|
||||
const FIXTURE_NESTED = {
|
||||
schema: { name: 'NestedProject' },
|
||||
subSteps: [
|
||||
{
|
||||
title: 'Build target NestedModule',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'scriptExecution',
|
||||
duration: 10.0,
|
||||
startTimestamp: 100.0,
|
||||
endTimestamp: 110.0,
|
||||
signature: 'PhaseScriptExecution',
|
||||
subSteps: [
|
||||
{
|
||||
detailStepType: 'cCompilation',
|
||||
duration: 2.0,
|
||||
startTimestamp: 101.0,
|
||||
endTimestamp: 103.0,
|
||||
signature: 'CompileC nested.c',
|
||||
subSteps: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockedDownloadFile = jest.mocked(downloadFile);
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
function mockFilesystem({
|
||||
tempDir = '/tmp/xclogparser-123',
|
||||
jsonOutput = JSON.stringify(FIXTURE_TWO_MODULES),
|
||||
}: {
|
||||
tempDir?: string;
|
||||
jsonOutput?: string;
|
||||
} = {}) {
|
||||
jest.spyOn(fs, 'mkdtemp').mockImplementation(async () => tempDir);
|
||||
jest.spyOn(fs, 'chmod').mockImplementation(async () => undefined);
|
||||
jest.spyOn(fs, 'readFile').mockImplementation(async () => jsonOutput);
|
||||
jest.spyOn(fs, 'rm').mockImplementation(async () => undefined);
|
||||
|
||||
return {
|
||||
reportPath: path.join(tempDir, 'xcactivitylog.json'),
|
||||
tempDir,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseAndReportXcactivitylog', () => {
|
||||
beforeEach(() => {
|
||||
mockedDownloadFile.mockReset();
|
||||
mockedSpawn.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('downloads xclogparser from GCS and logs only the final report on success', async () => {
|
||||
const logger = createMockLogger();
|
||||
const { tempDir, reportPath } = mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenCalledWith(
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'),
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'unzip',
|
||||
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'), '-d', tempDir],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
expect(mockedSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
path.join(tempDir, 'xclogparser'),
|
||||
[
|
||||
'parse',
|
||||
'--derived_data',
|
||||
'/tmp/derived-data',
|
||||
'--workspace',
|
||||
'/tmp/workspace',
|
||||
'--reporter',
|
||||
'json',
|
||||
'--output',
|
||||
reportPath,
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
expect(fs.readFile).toHaveBeenCalledWith(reportPath, 'utf8');
|
||||
expect(logger.info).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Xcode Build — Compile Metrics by Module')
|
||||
);
|
||||
expect(logger.debug).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tries the proxy URL first and falls back to the direct GCS URL', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile
|
||||
.mockRejectedValueOnce(new Error('proxy failed'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
proxyBaseUrl: 'https://cache.example.com',
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the direct GCS URL when the proxy download succeeds but unpacking fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(new Error('invalid zip'))
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
proxyBaseUrl: 'https://cache.example.com',
|
||||
});
|
||||
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
|
||||
{ retry: 3, timeout: 20_000 }
|
||||
);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.any(Error) }),
|
||||
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Xcode Build — Compile Metrics by Module')
|
||||
);
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips reporting and logs debug when the JSON output is invalid', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem({ jsonOutput: '{not valid json' });
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
})
|
||||
).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.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips reporting and logs debug when the JSON output does not match the expected schema', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem({
|
||||
jsonOutput: JSON.stringify({
|
||||
schema: { name: 'BadProject' },
|
||||
subSteps: [{ title: 123, subSteps: [] }],
|
||||
}),
|
||||
});
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
})
|
||||
).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.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows cleanup failures without emitting additional logs', async () => {
|
||||
const logger = createMockLogger();
|
||||
mockFilesystem();
|
||||
jest.spyOn(fs, 'rm').mockImplementation(async () => {
|
||||
throw new Error('cleanup failed');
|
||||
});
|
||||
|
||||
mockedDownloadFile.mockResolvedValue(undefined);
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
|
||||
await expect(
|
||||
parseAndReportXcactivitylog({
|
||||
derivedDataPath: '/tmp/derived-data',
|
||||
workspacePath: '/tmp/workspace',
|
||||
logger,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledTimes(1);
|
||||
expect(logger.debug).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCompileStep', () => {
|
||||
it('identifies cCompilation', () => {
|
||||
expect(isCompileStep({ detailStepType: 'cCompilation', signature: '' })).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies compileAssetsCatalog', () => {
|
||||
expect(isCompileStep({ detailStepType: 'compileAssetsCatalog', signature: '' })).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies compileStoryboard', () => {
|
||||
expect(isCompileStep({ detailStepType: 'compileStoryboard', signature: '' })).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies SwiftCompile by signature prefix', () => {
|
||||
expect(
|
||||
isCompileStep({ detailStepType: 'other', signature: 'SwiftCompile normal foo.swift' })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('identifies SwiftGeneratePch by signature prefix', () => {
|
||||
expect(isCompileStep({ detailStepType: 'other', signature: 'SwiftGeneratePch normal' })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-compile steps', () => {
|
||||
expect(isCompileStep({ detailStepType: 'linkCommand', signature: 'Ld something' })).toBe(false);
|
||||
});
|
||||
|
||||
it('handles missing fields gracefully', () => {
|
||||
expect(isCompileStep({})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectTopLevelCompileSteps', () => {
|
||||
it('collects direct compile children', () => {
|
||||
const target = FIXTURE_TWO_MODULES.subSteps[0]; // ModuleA
|
||||
const result = collectTopLevelCompileSteps(target);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('finds nested compile steps through non-compile parents', () => {
|
||||
const target = FIXTURE_NESTED.subSteps[0]; // NestedModule
|
||||
const result = collectTopLevelCompileSteps(target);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].detailStepType).toBe('cCompilation');
|
||||
});
|
||||
|
||||
it('returns empty array when no compile steps', () => {
|
||||
const target = FIXTURE_NO_COMPILE.subSteps[0];
|
||||
const result = collectTopLevelCompileSteps(target);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTargetMetrics', () => {
|
||||
it('returns metrics sorted by taskSeconds descending', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
for (let i = 1; i < results.length; i++) {
|
||||
expect(results[i - 1].taskSeconds).toBeGreaterThanOrEqual(results[i].taskSeconds);
|
||||
}
|
||||
});
|
||||
|
||||
it('computes correct taskSeconds for ModuleA', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
|
||||
const moduleA = results.find(r => r.moduleName === 'ModuleA');
|
||||
expect(moduleA).toBeDefined();
|
||||
expect(moduleA!.taskSeconds).toBeCloseTo(8.0); // 5.0 + 3.0
|
||||
});
|
||||
|
||||
it('computes correct wallSpan for ModuleA with overlapping intervals', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
|
||||
const moduleA = results.find(r => r.moduleName === 'ModuleA');
|
||||
expect(moduleA).toBeDefined();
|
||||
// intervals: [100, 103] and [101, 104] → wall span = 104 - 100 = 4.0
|
||||
expect(moduleA!.wallSpan).toBeCloseTo(4.0);
|
||||
});
|
||||
|
||||
it('filters out modules below minTaskSeconds threshold', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
|
||||
const tinyModule = results.find(r => r.moduleName === 'TinyModule');
|
||||
expect(tinyModule).toBeUndefined(); // 0.1s < 0.5s default threshold
|
||||
});
|
||||
|
||||
it('allows custom minTaskSeconds threshold', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES, { minTaskSeconds: 0 });
|
||||
const tinyModule = results.find(r => r.moduleName === 'TinyModule');
|
||||
expect(tinyModule).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns empty results for empty data', () => {
|
||||
const { results, totalTaskSeconds } = buildTargetMetrics(FIXTURE_EMPTY);
|
||||
expect(results).toHaveLength(0);
|
||||
expect(totalTaskSeconds).toBe(0);
|
||||
});
|
||||
|
||||
it('handles missing timestamps gracefully', () => {
|
||||
const { results } = buildTargetMetrics(FIXTURE_MISSING_TIMESTAMPS);
|
||||
const mod = results.find(r => r.moduleName === 'PartialModule');
|
||||
expect(mod).toBeDefined();
|
||||
expect(mod!.taskSeconds).toBeCloseTo(3.0);
|
||||
expect(mod!.wallSpan).toBe(0); // no valid intervals
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatReport', () => {
|
||||
it('produces table with header and module rows', () => {
|
||||
const report = formatReport(FIXTURE_TWO_MODULES);
|
||||
expect(report).toContain('Xcode Build — Compile Metrics by Module');
|
||||
expect(report).toContain('ModuleA');
|
||||
expect(report).toContain('ModuleB');
|
||||
expect(report).toContain('TOTAL');
|
||||
expect(report).toContain('% Task');
|
||||
});
|
||||
|
||||
it('does not include modules below threshold', () => {
|
||||
const report = formatReport(FIXTURE_TWO_MODULES);
|
||||
expect(report).not.toContain('TinyModule');
|
||||
});
|
||||
|
||||
it('handles empty data without crashing', () => {
|
||||
const report = formatReport(FIXTURE_EMPTY);
|
||||
expect(report).toContain('Xcode Build — Compile Metrics by Module');
|
||||
expect(report).toContain('TOTAL');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
import downloadFile from '@expo/downloader';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.46';
|
||||
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).
|
||||
*/
|
||||
export async function parseAndReportXcactivitylog({
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
xclogparserVersion = DEFAULT_XCLOGPARSER_VERSION,
|
||||
logger,
|
||||
proxyBaseUrl,
|
||||
}: {
|
||||
derivedDataPath: string;
|
||||
workspacePath: string;
|
||||
xclogparserVersion?: string;
|
||||
logger: bunyan;
|
||||
proxyBaseUrl?: string;
|
||||
}): Promise<void> {
|
||||
let tempDir: string | undefined;
|
||||
try {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xclogparser-'));
|
||||
const xclogparserPath = await downloadXclogparser(
|
||||
tempDir,
|
||||
xclogparserVersion,
|
||||
logger,
|
||||
proxyBaseUrl
|
||||
);
|
||||
const jsonOutputPath = await runXclogparser({
|
||||
binaryPath: xclogparserPath,
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
outputDir: tempDir,
|
||||
});
|
||||
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');
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadXclogparser(
|
||||
tempDir: string,
|
||||
version: string,
|
||||
logger: bunyan,
|
||||
proxyBaseUrl?: string
|
||||
): Promise<string> {
|
||||
const zipName = getXclogparserZipName(version);
|
||||
const zipPath = path.join(tempDir, zipName);
|
||||
const directUrl = `${XCLOGPARSER_DOWNLOAD_URL}/${zipName}`;
|
||||
const proxiedUrl = getProxiedDownloadUrl({ directUrl, proxyBaseUrl });
|
||||
|
||||
if (proxiedUrl) {
|
||||
const proxiedDownloadResult = await asyncResult(
|
||||
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl })
|
||||
);
|
||||
if (!proxiedDownloadResult.ok) {
|
||||
logger.debug(
|
||||
{ err: proxiedDownloadResult.reason },
|
||||
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
|
||||
);
|
||||
await cleanupXclogparserArtifacts({ tempDir, zipPath });
|
||||
} else {
|
||||
return proxiedDownloadResult.value;
|
||||
}
|
||||
}
|
||||
|
||||
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl });
|
||||
}
|
||||
|
||||
async function downloadAndUnpackXclogparser({
|
||||
tempDir,
|
||||
zipPath,
|
||||
sourceUrl,
|
||||
}: {
|
||||
tempDir: string;
|
||||
zipPath: string;
|
||||
sourceUrl: string;
|
||||
}): Promise<string> {
|
||||
await downloadFile(sourceUrl, zipPath, { retry: 3, timeout: XCLOGPARSER_DOWNLOAD_TIMEOUT_MS });
|
||||
|
||||
return await unpackXclogparser({ tempDir, zipPath });
|
||||
}
|
||||
|
||||
async function unpackXclogparser({
|
||||
tempDir,
|
||||
zipPath,
|
||||
}: {
|
||||
tempDir: string;
|
||||
zipPath: string;
|
||||
}): Promise<string> {
|
||||
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe' });
|
||||
|
||||
const binaryPath = path.join(tempDir, 'xclogparser');
|
||||
await fs.chmod(binaryPath, 0o755);
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
async function cleanupXclogparserArtifacts({
|
||||
tempDir,
|
||||
zipPath,
|
||||
}: {
|
||||
tempDir: string;
|
||||
zipPath: string;
|
||||
}): Promise<void> {
|
||||
await asyncResult(fs.rm(zipPath, { force: true }));
|
||||
await asyncResult(fs.rm(path.join(tempDir, 'xclogparser'), { force: true }));
|
||||
}
|
||||
|
||||
async function runXclogparser({
|
||||
binaryPath,
|
||||
derivedDataPath,
|
||||
workspacePath,
|
||||
outputDir,
|
||||
}: {
|
||||
binaryPath: string;
|
||||
derivedDataPath: string;
|
||||
workspacePath: string;
|
||||
outputDir: string;
|
||||
}): Promise<string> {
|
||||
const outputPath = path.join(outputDir, XCLOGPARSER_OUTPUT_FILENAME);
|
||||
|
||||
await spawn(
|
||||
binaryPath,
|
||||
[
|
||||
'parse',
|
||||
'--derived_data',
|
||||
derivedDataPath,
|
||||
'--workspace',
|
||||
workspacePath,
|
||||
'--reporter',
|
||||
'json',
|
||||
'--output',
|
||||
outputPath,
|
||||
],
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
const XcactivitylogStepSchemaZ = z.object({
|
||||
title: z.string().optional(),
|
||||
detailStepType: z.string().optional(),
|
||||
signature: z.string().optional(),
|
||||
duration: z.number().optional(),
|
||||
startTimestamp: z.number().optional(),
|
||||
endTimestamp: z.number().optional(),
|
||||
get subSteps() {
|
||||
return z.array(XcactivitylogStepSchemaZ).optional();
|
||||
},
|
||||
});
|
||||
|
||||
const XcactivitylogDataSchemaZ = z.object({
|
||||
schema: z.union([z.string(), z.object({ name: z.string().optional() })]).optional(),
|
||||
subSteps: z.array(XcactivitylogStepSchemaZ).optional(),
|
||||
});
|
||||
|
||||
type XcactivitylogStep = z.infer<typeof XcactivitylogStepSchemaZ>;
|
||||
type XcactivitylogData = z.infer<typeof XcactivitylogDataSchemaZ>;
|
||||
|
||||
interface TargetMetric {
|
||||
moduleName: string;
|
||||
taskSeconds: number;
|
||||
wallSpan: number;
|
||||
activeWallTime: number;
|
||||
}
|
||||
|
||||
interface Interval {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const COMPILE_DETAIL_TYPES = new Set(['cCompilation', 'compileAssetsCatalog', 'compileStoryboard']);
|
||||
|
||||
const COMPILE_SIGNATURE_PREFIXES = ['SwiftCompile ', 'SwiftGeneratePch '];
|
||||
|
||||
function getXclogparserZipName(version: string): string {
|
||||
return `XCLogParser-macOS-x86-64-arm64-${version}.zip`;
|
||||
}
|
||||
|
||||
function getProxiedDownloadUrl({
|
||||
directUrl,
|
||||
proxyBaseUrl,
|
||||
}: {
|
||||
directUrl: string;
|
||||
proxyBaseUrl?: string;
|
||||
}): string | null {
|
||||
if (!proxyBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(directUrl);
|
||||
return directUrl.replace(
|
||||
`${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
`${proxyBaseUrl}/${parsedUrl.host}`
|
||||
);
|
||||
}
|
||||
|
||||
export function isCompileStep(step: Partial<XcactivitylogStep>): boolean {
|
||||
const detailStepType = step.detailStepType ?? '';
|
||||
const signature = step.signature ?? '';
|
||||
|
||||
if (COMPILE_DETAIL_TYPES.has(detailStepType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return COMPILE_SIGNATURE_PREFIXES.some(prefix => signature.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function collectTopLevelCompileSteps(
|
||||
step: XcactivitylogStep,
|
||||
results: XcactivitylogStep[] = []
|
||||
): XcactivitylogStep[] {
|
||||
for (const child of step.subSteps ?? []) {
|
||||
if (isCompileStep(child)) {
|
||||
results.push(child);
|
||||
continue;
|
||||
}
|
||||
collectTopLevelCompileSteps(child, results);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function intervalFromStep(step: XcactivitylogStep): Interval | null {
|
||||
if (typeof step.startTimestamp !== 'number' || typeof step.endTimestamp !== 'number') {
|
||||
return null;
|
||||
}
|
||||
return { start: step.startTimestamp, end: step.endTimestamp };
|
||||
}
|
||||
|
||||
function computeActiveWallTime(intervals: Interval[]): number {
|
||||
if (intervals.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const sorted = [...intervals].sort((a, b) => a.start - b.start);
|
||||
let activeWallTime = 0;
|
||||
let currentStart = sorted[0].start;
|
||||
let currentEnd = sorted[0].end;
|
||||
|
||||
for (const interval of sorted.slice(1)) {
|
||||
if (interval.start <= currentEnd) {
|
||||
currentEnd = Math.max(currentEnd, interval.end);
|
||||
continue;
|
||||
}
|
||||
activeWallTime += currentEnd - currentStart;
|
||||
currentStart = interval.start;
|
||||
currentEnd = interval.end;
|
||||
}
|
||||
|
||||
activeWallTime += currentEnd - currentStart;
|
||||
return activeWallTime;
|
||||
}
|
||||
|
||||
export function buildTargetMetrics(
|
||||
data: XcactivitylogData,
|
||||
{ minTaskSeconds = 0.5 }: { minTaskSeconds?: number } = {}
|
||||
): { results: TargetMetric[]; totalTaskSeconds: number } {
|
||||
const results: TargetMetric[] = [];
|
||||
|
||||
for (const step of data.subSteps ?? []) {
|
||||
const title = step.title ?? '';
|
||||
if (!title.startsWith('Build target ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const moduleName = title.replace('Build target ', '');
|
||||
const compileSteps = collectTopLevelCompileSteps(step);
|
||||
const taskSeconds = compileSteps.reduce((sum, s) => sum + (s.duration ?? 0), 0);
|
||||
|
||||
if (taskSeconds < minTaskSeconds) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const intervals = compileSteps.map(intervalFromStep).filter((i): i is Interval => i !== null);
|
||||
const minStart = intervals.length > 0 ? Math.min(...intervals.map(i => i.start)) : 0;
|
||||
const maxEnd = intervals.length > 0 ? Math.max(...intervals.map(i => i.end)) : 0;
|
||||
const wallSpan = intervals.length > 0 ? maxEnd - minStart : 0;
|
||||
const activeWallTime = computeActiveWallTime(intervals);
|
||||
|
||||
results.push({ moduleName, taskSeconds, wallSpan, activeWallTime });
|
||||
}
|
||||
|
||||
results.sort((a, b) => {
|
||||
if (b.taskSeconds !== a.taskSeconds) {
|
||||
return b.taskSeconds - a.taskSeconds;
|
||||
}
|
||||
if (b.wallSpan !== a.wallSpan) {
|
||||
return b.wallSpan - a.wallSpan;
|
||||
}
|
||||
return a.moduleName.localeCompare(b.moduleName);
|
||||
});
|
||||
|
||||
const totalTaskSeconds = results.reduce((sum, r) => sum + r.taskSeconds, 0);
|
||||
|
||||
return { results, totalTaskSeconds };
|
||||
}
|
||||
|
||||
function formatSeconds(value: number): string {
|
||||
return `${value.toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export function formatReport(data: XcactivitylogData): string {
|
||||
const { results, totalTaskSeconds } = buildTargetMetrics(data);
|
||||
|
||||
const nameWidth = Math.max(6, ...results.map(r => r.moduleName.length)) + 2;
|
||||
const taskWidth = 10;
|
||||
const pctWidth = 8;
|
||||
const wallWidth = 10;
|
||||
const barMaxWidth = 20;
|
||||
const maxTaskSeconds = results[0]?.taskSeconds ?? 1;
|
||||
|
||||
const header =
|
||||
'┌─' +
|
||||
'─'.repeat(nameWidth) +
|
||||
'─┬────────────┬──────────┬────────────┬─' +
|
||||
'─'.repeat(barMaxWidth) +
|
||||
'─┐';
|
||||
const divider =
|
||||
'├─' +
|
||||
'─'.repeat(nameWidth) +
|
||||
'─┼────────────┼──────────┼────────────┼─' +
|
||||
'─'.repeat(barMaxWidth) +
|
||||
'─┤';
|
||||
const footer =
|
||||
'└─' +
|
||||
'─'.repeat(nameWidth) +
|
||||
'─┴────────────┴──────────┴────────────┴─' +
|
||||
'─'.repeat(barMaxWidth) +
|
||||
'─┘';
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('');
|
||||
lines.push('Xcode Build — Compile Metrics by Module');
|
||||
lines.push(
|
||||
`Schema: ${typeof data.schema === 'string' ? data.schema : (data.schema?.name ?? 'unknown')}`
|
||||
);
|
||||
lines.push('% Task = share of total Compile Task Seconds');
|
||||
lines.push('Wall = first compile start to last compile end');
|
||||
lines.push('');
|
||||
lines.push(header);
|
||||
lines.push(
|
||||
'│ ' +
|
||||
'Module'.padEnd(nameWidth) +
|
||||
' │ ' +
|
||||
'Task'.padStart(taskWidth) +
|
||||
' │ ' +
|
||||
'% Task'.padStart(pctWidth) +
|
||||
' │ ' +
|
||||
'Wall'.padStart(wallWidth) +
|
||||
' │ ' +
|
||||
' '.repeat(barMaxWidth) +
|
||||
' │'
|
||||
);
|
||||
lines.push(divider);
|
||||
|
||||
for (const result of results) {
|
||||
const pct = totalTaskSeconds === 0 ? 0 : (result.taskSeconds / totalTaskSeconds) * 100;
|
||||
const barLength = Math.round((result.taskSeconds / maxTaskSeconds) * barMaxWidth);
|
||||
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
|
||||
|
||||
lines.push(
|
||||
'│ ' +
|
||||
result.moduleName.padEnd(nameWidth) +
|
||||
' │ ' +
|
||||
formatSeconds(result.taskSeconds).padStart(taskWidth) +
|
||||
' │ ' +
|
||||
`${pct.toFixed(1)}%`.padStart(pctWidth) +
|
||||
' │ ' +
|
||||
formatSeconds(result.wallSpan).padStart(wallWidth) +
|
||||
' │ ' +
|
||||
bar +
|
||||
' │'
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(divider);
|
||||
lines.push(
|
||||
'│ ' +
|
||||
'TOTAL'.padEnd(nameWidth) +
|
||||
' │ ' +
|
||||
formatSeconds(totalTaskSeconds).padStart(taskWidth) +
|
||||
' │ ' +
|
||||
'100.0%'.padStart(pctWidth) +
|
||||
' │ ' +
|
||||
'n/a'.padStart(wallWidth) +
|
||||
' │ ' +
|
||||
' '.repeat(barMaxWidth) +
|
||||
' │'
|
||||
);
|
||||
lines.push(footer);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -20,5 +20,9 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain <%- KEYCHAIN_PATH %>\\""
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("<%- LOGS_DIRECTORY %>")
|
||||
|
||||
derived_data_path("<%- DERIVED_DATA_PATH %>")
|
||||
result_bundle(true)
|
||||
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
|
||||
|
||||
output_directory("<%- OUTPUT_DIRECTORY %>")
|
||||
`;
|
||||
@@ -13,4 +13,7 @@ destination("<%- SCHEME_SIMULATOR_DESTINATION %>")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("<%- LOGS_DIRECTORY %>")
|
||||
|
||||
result_bundle(true)
|
||||
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
|
||||
`;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { EasCliNpmTags } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { resolveEasCommandPrefixAndEnvAsync, runEasCliCommand } from '../easCli';
|
||||
import { isAtLeastNpm7Async } from '../packageManager';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.mock('../packageManager', () => ({
|
||||
...jest.requireActual('../packageManager'),
|
||||
isAtLeastNpm7Async: jest.fn(async () => true),
|
||||
}));
|
||||
|
||||
describe(resolveEasCommandPrefixAndEnvAsync, () => {
|
||||
const originalEnvironment = process.env.ENVIRONMENT;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ENVIRONMENT = originalEnvironment;
|
||||
});
|
||||
|
||||
it('resolves easd in development when easd --help succeeds', async () => {
|
||||
process.env.ENVIRONMENT = 'development';
|
||||
jest.mocked(spawn).mockResolvedValueOnce({ status: 0 } as any);
|
||||
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
|
||||
expect(result).toEqual({
|
||||
cmd: 'easd',
|
||||
args: [],
|
||||
extraEnv: {},
|
||||
});
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'easd',
|
||||
['--help'],
|
||||
expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves staging tag in development when easd --help fails', async () => {
|
||||
process.env.ENVIRONMENT = 'development';
|
||||
jest.mocked(spawn).mockResolvedValueOnce({ status: 1 } as any);
|
||||
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves staging tag in development when easd probe throws', async () => {
|
||||
process.env.ENVIRONMENT = 'development';
|
||||
jest.mocked(spawn).mockRejectedValueOnce(new Error('easd not installed') as any);
|
||||
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves staging tag and EXPO_STAGING env in staging environment', async () => {
|
||||
process.env.ENVIRONMENT = 'staging';
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves production tag by default', async () => {
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('omits -y when npm is older than v7', async () => {
|
||||
jest.mocked(isAtLeastNpm7Async).mockResolvedValueOnce(false);
|
||||
process.env.ENVIRONMENT = 'production';
|
||||
const result = await resolveEasCommandPrefixAndEnvAsync();
|
||||
expect(result).toEqual({
|
||||
cmd: 'npx',
|
||||
args: [`eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(runEasCliCommand, () => {
|
||||
const originalEnvironment = process.env.ENVIRONMENT;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(spawn).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ENVIRONMENT = originalEnvironment;
|
||||
});
|
||||
|
||||
it('merges caller env with resolved extra env', async () => {
|
||||
process.env.ENVIRONMENT = 'staging';
|
||||
|
||||
await runEasCliCommand({
|
||||
args: ['deploy', '--json'],
|
||||
options: {
|
||||
cwd: '/tmp/project',
|
||||
env: { FOO: 'bar' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npx',
|
||||
expect.arrayContaining([`eas-cli@${EasCliNpmTags.STAGING}`, 'deploy', '--json']),
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/project',
|
||||
env: expect.objectContaining({
|
||||
FOO: 'bar',
|
||||
EXPO_STAGING: '1',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
@@ -5,6 +6,7 @@ import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import {
|
||||
PackageManager,
|
||||
findPackagerRootDir,
|
||||
getPackageVersionFromPackageJson,
|
||||
resolvePackageManager,
|
||||
@@ -23,23 +25,23 @@ describe(resolvePackageManager, () => {
|
||||
});
|
||||
|
||||
it('returns yarn when no lockfiles exist', async () => {
|
||||
expect(resolvePackageManager(rootDir)).toBe('yarn');
|
||||
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
|
||||
});
|
||||
|
||||
it('returns npm when only package-json.lock exist', async () => {
|
||||
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
|
||||
expect(resolvePackageManager(rootDir)).toBe('npm');
|
||||
expect(resolvePackageManager(rootDir, { env: {} })).toBe('npm');
|
||||
});
|
||||
|
||||
it('returns yarn when only yarn.lock exists', async () => {
|
||||
await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content');
|
||||
expect(resolvePackageManager(rootDir)).toBe('yarn');
|
||||
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
|
||||
});
|
||||
|
||||
it('returns yarn when both lockfiles exists', async () => {
|
||||
await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content');
|
||||
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
|
||||
expect(resolvePackageManager(rootDir)).toBe('yarn');
|
||||
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
|
||||
});
|
||||
|
||||
it('returns npm within a monorepo', async () => {
|
||||
@@ -55,7 +57,7 @@ describe(resolvePackageManager, () => {
|
||||
name: '@monorepo/expo-app',
|
||||
});
|
||||
|
||||
expect(resolvePackageManager(nestedDir)).toBe('npm');
|
||||
expect(resolvePackageManager(nestedDir, { env: {} })).toBe('npm');
|
||||
});
|
||||
|
||||
it('returns yarn with an invalid monorepo', async () => {
|
||||
@@ -67,7 +69,51 @@ describe(resolvePackageManager, () => {
|
||||
await fs.mkdirp(nestedDir);
|
||||
await fs.writeFile(path.join(nestedDir, 'package.json'), 'content');
|
||||
|
||||
expect(resolvePackageManager(nestedDir)).toBe('yarn');
|
||||
expect(resolvePackageManager(nestedDir, { env: {} })).toBe('yarn');
|
||||
});
|
||||
|
||||
it('returns yarn when no lockfile and env var is an empty string', () => {
|
||||
expect(
|
||||
resolvePackageManager(rootDir, {
|
||||
env: { EAS_FALLBACK_PACKAGE_MANAGER: '' },
|
||||
})
|
||||
).toBe(PackageManager.YARN);
|
||||
});
|
||||
|
||||
it('returns bun from EAS_FALLBACK_PACKAGE_MANAGER when no lockfile', () => {
|
||||
expect(resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bun' } })).toBe(
|
||||
PackageManager.BUN
|
||||
);
|
||||
});
|
||||
|
||||
it('returns pnpm from EAS_FALLBACK_PACKAGE_MANAGER when no lockfile', () => {
|
||||
expect(resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'pnpm' } })).toBe(
|
||||
PackageManager.PNPM
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores EAS_FALLBACK_PACKAGE_MANAGER when a lockfile exists', async () => {
|
||||
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
|
||||
expect(
|
||||
resolvePackageManager(rootDir, {
|
||||
env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' }, // invalid, but should not even be read
|
||||
})
|
||||
).toBe(PackageManager.NPM);
|
||||
});
|
||||
|
||||
it('throws a UserError on unsupported EAS_FALLBACK_PACKAGE_MANAGER value', () => {
|
||||
expect(() =>
|
||||
resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' } })
|
||||
).toThrow(errors.UserError);
|
||||
try {
|
||||
resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' } });
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(errors.UserError);
|
||||
const error = e as errors.UserError;
|
||||
expect(error.errorCode).toBe('EAS_INVALID_FALLBACK_PACKAGE_MANAGER');
|
||||
expect(error.message).toContain('bunn');
|
||||
expect(error.message).toContain('yarn, npm, pnpm, bun');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import spawn, { SpawnOptions, SpawnResult } from '@expo/turtle-spawn';
|
||||
import { EasCliNpmTags, Env } from '@expo/eas-build-job';
|
||||
|
||||
import { isAtLeastNpm7Async } from './packageManager';
|
||||
|
||||
async function probeEasdAsync(): Promise<boolean> {
|
||||
try {
|
||||
const result = await spawn('easd', ['--help'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return result.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
|
||||
cmd: string;
|
||||
args: string[];
|
||||
extraEnv: Env;
|
||||
}> {
|
||||
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
|
||||
if (process.env.ENVIRONMENT === 'development') {
|
||||
if (await probeEasdAsync()) {
|
||||
return { cmd: 'easd', args: [], extraEnv: {} };
|
||||
}
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
} else if (process.env.ENVIRONMENT === 'staging') {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
|
||||
extraEnv: { EXPO_STAGING: '1' },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
cmd: 'npx',
|
||||
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
|
||||
extraEnv: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function runEasCliCommand({
|
||||
args,
|
||||
options,
|
||||
}: {
|
||||
args: string[];
|
||||
options: SpawnOptions;
|
||||
}): Promise<SpawnResult> {
|
||||
const { logger, ...spawnOptions } = options;
|
||||
const { cmd, args: commandPrefixArgs, extraEnv } = await resolveEasCommandPrefixAndEnvAsync();
|
||||
return await spawn(cmd, [...commandPrefixArgs, ...args], {
|
||||
...spawnOptions,
|
||||
logger,
|
||||
env: { ...spawnOptions.env, ...extraEnv },
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import * as PackageManagerUtils from '@expo/package-manager';
|
||||
import { type BuildStepEnv } from '@expo/steps';
|
||||
import spawnAsync from '@expo/turtle-spawn';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
@@ -11,7 +13,10 @@ export enum PackageManager {
|
||||
BUN = 'bun',
|
||||
}
|
||||
|
||||
export function resolvePackageManager(directory: string): PackageManager {
|
||||
export function resolvePackageManager(
|
||||
directory: string,
|
||||
{ env }: { env: BuildStepEnv }
|
||||
): PackageManager {
|
||||
try {
|
||||
const manager = PackageManagerUtils.resolvePackageManager(directory);
|
||||
if (manager === 'npm') {
|
||||
@@ -20,12 +25,24 @@ export function resolvePackageManager(directory: string): PackageManager {
|
||||
return PackageManager.PNPM;
|
||||
} else if (manager === 'bun') {
|
||||
return PackageManager.BUN;
|
||||
} else {
|
||||
} else if (manager === 'yarn') {
|
||||
return PackageManager.YARN;
|
||||
}
|
||||
} catch {
|
||||
return PackageManager.YARN;
|
||||
} catch {}
|
||||
|
||||
const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER;
|
||||
if (fallback) {
|
||||
const parsed = z.enum(PackageManager).safeParse(fallback);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
const allowed = Object.values(PackageManager).join(', ');
|
||||
throw new errors.UserError(
|
||||
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER',
|
||||
`Invalid EAS_FALLBACK_PACKAGE_MANAGER value "${fallback}" (expected one of: ${allowed}).`
|
||||
);
|
||||
}
|
||||
return PackageManager.YARN;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "18.6.0",
|
||||
"version": "18.8.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.6.0"
|
||||
"@expo/steps": "18.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "18.6.0",
|
||||
"version": "18.8.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -403,6 +403,7 @@ describe('Ios.JobSchema', () => {
|
||||
);
|
||||
expect(value).not.toMatchObject(managedJob);
|
||||
});
|
||||
|
||||
test('validates channel', () => {
|
||||
const managedJob = {
|
||||
secrets: {
|
||||
|
||||
@@ -41,6 +41,7 @@ export enum BuildPhase {
|
||||
INSTALL_PODS = 'INSTALL_PODS',
|
||||
CONFIGURE_XCODE_PROJECT = 'CONFIGURE_XCODE_PROJECT',
|
||||
RUN_FASTLANE = 'RUN_FASTLANE',
|
||||
PARSE_XCACTIVITYLOG = 'PARSE_XCACTIVITYLOG',
|
||||
|
||||
// HOOKS
|
||||
PRE_INSTALL_HOOK = 'PRE_INSTALL_HOOK',
|
||||
@@ -108,6 +109,7 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
|
||||
[BuildPhase.INSTALL_PODS]: 'Install pods',
|
||||
[BuildPhase.CONFIGURE_XCODE_PROJECT]: 'Configure Xcode project',
|
||||
[BuildPhase.RUN_FASTLANE]: 'Run fastlane',
|
||||
[BuildPhase.PARSE_XCACTIVITYLOG]: 'Analyze build performance',
|
||||
|
||||
// HOOKS
|
||||
[BuildPhase.PRE_INSTALL_HOOK]: 'Pre-install hook',
|
||||
@@ -172,6 +174,7 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
|
||||
[BuildPhase.INSTALL_PODS]: 'install-pods',
|
||||
[BuildPhase.CONFIGURE_XCODE_PROJECT]: 'configure-xcode-project',
|
||||
[BuildPhase.RUN_FASTLANE]: 'run-fastlane',
|
||||
[BuildPhase.PARSE_XCACTIVITYLOG]: 'parse-xcactivitylog',
|
||||
|
||||
// HOOKS
|
||||
[BuildPhase.PRE_INSTALL_HOOK]: 'pre-install-hook',
|
||||
|
||||
+147
-90
@@ -84,6 +84,7 @@ eas --help COMMAND
|
||||
* [`eas channel:create [NAME]`](#eas-channelcreate-name)
|
||||
* [`eas channel:delete [NAME]`](#eas-channeldelete-name)
|
||||
* [`eas channel:edit [NAME]`](#eas-channeledit-name)
|
||||
* [`eas channel:insights`](#eas-channelinsights)
|
||||
* [`eas channel:list`](#eas-channellist)
|
||||
* [`eas channel:pause [NAME]`](#eas-channelpause-name)
|
||||
* [`eas channel:resume [NAME]`](#eas-channelresume-name)
|
||||
@@ -133,6 +134,7 @@ eas --help COMMAND
|
||||
* [`eas update:configure`](#eas-updateconfigure)
|
||||
* [`eas update:delete GROUPID`](#eas-updatedelete-groupid)
|
||||
* [`eas update:edit [GROUPID]`](#eas-updateedit-groupid)
|
||||
* [`eas update:insights GROUPID`](#eas-updateinsights-groupid)
|
||||
* [`eas update:list`](#eas-updatelist)
|
||||
* [`eas update:republish`](#eas-updaterepublish)
|
||||
* [`eas update:revert-update-rollout`](#eas-updaterevert-update-rollout)
|
||||
@@ -178,7 +180,7 @@ ALIASES
|
||||
$ eas login
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/login.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/account/login.ts)_
|
||||
|
||||
## `eas account:logout`
|
||||
|
||||
@@ -195,7 +197,7 @@ ALIASES
|
||||
$ eas logout
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/account/logout.ts)_
|
||||
|
||||
## `eas account:usage [ACCOUNT_NAME]`
|
||||
|
||||
@@ -217,7 +219,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.7.0/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/account/usage.ts)_
|
||||
|
||||
## `eas account:view`
|
||||
|
||||
@@ -234,7 +236,7 @@ ALIASES
|
||||
$ eas whoami
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/account/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/account/view.ts)_
|
||||
|
||||
## `eas analytics [STATUS]`
|
||||
|
||||
@@ -248,7 +250,7 @@ DESCRIPTION
|
||||
display or change analytics settings
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/analytics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/analytics.ts)_
|
||||
|
||||
## `eas autocomplete [SHELL]`
|
||||
|
||||
@@ -300,7 +302,7 @@ DESCRIPTION
|
||||
create a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/branch/create.ts)_
|
||||
|
||||
## `eas branch:delete [NAME]`
|
||||
|
||||
@@ -321,7 +323,7 @@ DESCRIPTION
|
||||
delete a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/branch/delete.ts)_
|
||||
|
||||
## `eas branch:list`
|
||||
|
||||
@@ -341,7 +343,7 @@ DESCRIPTION
|
||||
list all branches
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/branch/list.ts)_
|
||||
|
||||
## `eas branch:rename`
|
||||
|
||||
@@ -361,7 +363,7 @@ DESCRIPTION
|
||||
rename a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/branch/rename.ts)_
|
||||
|
||||
## `eas branch:view [NAME]`
|
||||
|
||||
@@ -384,7 +386,7 @@ DESCRIPTION
|
||||
view a branch
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/branch/view.ts)_
|
||||
|
||||
## `eas build`
|
||||
|
||||
@@ -422,7 +424,7 @@ DESCRIPTION
|
||||
start a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/index.ts)_
|
||||
|
||||
## `eas build:cancel [BUILD_ID]`
|
||||
|
||||
@@ -442,7 +444,7 @@ DESCRIPTION
|
||||
cancel a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/cancel.ts)_
|
||||
|
||||
## `eas build:configure`
|
||||
|
||||
@@ -460,7 +462,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.7.0/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/configure.ts)_
|
||||
|
||||
## `eas build:delete [BUILD_ID]`
|
||||
|
||||
@@ -480,7 +482,7 @@ DESCRIPTION
|
||||
delete a build
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/delete.ts)_
|
||||
|
||||
## `eas build:dev`
|
||||
|
||||
@@ -502,7 +504,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.7.0/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/dev.ts)_
|
||||
|
||||
## `eas build:download`
|
||||
|
||||
@@ -523,7 +525,7 @@ DESCRIPTION
|
||||
download simulator/emulator builds for a given fingerprint hash
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/build/download.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/download.ts)_
|
||||
|
||||
## `eas build:inspect`
|
||||
|
||||
@@ -561,7 +563,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.7.0/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/inspect.ts)_
|
||||
|
||||
## `eas build:list`
|
||||
|
||||
@@ -601,7 +603,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.7.0/packages/eas-cli/src/commands/build/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/list.ts)_
|
||||
|
||||
## `eas build:resign`
|
||||
|
||||
@@ -631,7 +633,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.7.0/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/resign.ts)_
|
||||
|
||||
## `eas build:run`
|
||||
|
||||
@@ -657,7 +659,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.7.0/packages/eas-cli/src/commands/build/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/run.ts)_
|
||||
|
||||
## `eas build:submit`
|
||||
|
||||
@@ -709,7 +711,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.7.0/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/version/get.ts)_
|
||||
|
||||
## `eas build:version:set`
|
||||
|
||||
@@ -727,7 +729,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.7.0/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/version/set.ts)_
|
||||
|
||||
## `eas build:version:sync`
|
||||
|
||||
@@ -745,7 +747,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.7.0/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/version/sync.ts)_
|
||||
|
||||
## `eas build:view [BUILD_ID]`
|
||||
|
||||
@@ -762,7 +764,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.7.0/packages/eas-cli/src/commands/build/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/build/view.ts)_
|
||||
|
||||
## `eas channel:create [NAME]`
|
||||
|
||||
@@ -783,7 +785,7 @@ DESCRIPTION
|
||||
create a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/create.ts)_
|
||||
|
||||
## `eas channel:delete [NAME]`
|
||||
|
||||
@@ -804,7 +806,7 @@ DESCRIPTION
|
||||
Delete a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/delete.ts)_
|
||||
|
||||
## `eas channel:edit [NAME]`
|
||||
|
||||
@@ -826,7 +828,31 @@ 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.7.0/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/edit.ts)_
|
||||
|
||||
## `eas channel:insights`
|
||||
|
||||
display adoption, crash, and unique-user insights for a channel + runtime version
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas channel:insights --channel <value> --runtime-version <value> [--days <value> | --start <value> | --end
|
||||
<value>] [--json] [--non-interactive]
|
||||
|
||||
FLAGS
|
||||
--channel=<value> (required) Name of the channel.
|
||||
--days=<value> Show insights from the last N days (default 7, mutually exclusive with --start/--end).
|
||||
--end=<value> End of insights time range (ISO date).
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--runtime-version=<value> (required) Runtime version to query insights for.
|
||||
--start=<value> Start of insights time range (ISO date).
|
||||
|
||||
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.8.1/packages/eas-cli/src/commands/channel/insights.ts)_
|
||||
|
||||
## `eas channel:list`
|
||||
|
||||
@@ -846,7 +872,7 @@ DESCRIPTION
|
||||
list all channels
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/list.ts)_
|
||||
|
||||
## `eas channel:pause [NAME]`
|
||||
|
||||
@@ -868,7 +894,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.7.0/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/pause.ts)_
|
||||
|
||||
## `eas channel:resume [NAME]`
|
||||
|
||||
@@ -890,7 +916,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.7.0/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/resume.ts)_
|
||||
|
||||
## `eas channel:rollout [CHANNEL]`
|
||||
|
||||
@@ -925,7 +951,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.7.0/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/rollout.ts)_
|
||||
|
||||
## `eas channel:view [NAME]`
|
||||
|
||||
@@ -948,7 +974,7 @@ DESCRIPTION
|
||||
view a channel
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/channel/view.ts)_
|
||||
|
||||
## `eas config`
|
||||
|
||||
@@ -969,7 +995,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.7.0/packages/eas-cli/src/commands/config.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/config.ts)_
|
||||
|
||||
## `eas credentials`
|
||||
|
||||
@@ -986,7 +1012,7 @@ DESCRIPTION
|
||||
manage credentials
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/credentials/index.ts)_
|
||||
|
||||
## `eas credentials:configure-build`
|
||||
|
||||
@@ -1004,7 +1030,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.7.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/credentials/configure-build.ts)_
|
||||
|
||||
## `eas deploy [options]`
|
||||
|
||||
@@ -1033,7 +1059,7 @@ ALIASES
|
||||
$ eas worker:deploy
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/deploy/index.ts)_
|
||||
|
||||
## `eas deploy:alias`
|
||||
|
||||
@@ -1058,7 +1084,7 @@ ALIASES
|
||||
$ eas deploy:promote
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/deploy/alias/index.ts)_
|
||||
|
||||
## `eas deploy:alias:delete [ALIAS_NAME]`
|
||||
|
||||
@@ -1079,7 +1105,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.7.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
|
||||
|
||||
## `eas deploy:delete [DEPLOYMENT_ID]`
|
||||
|
||||
@@ -1100,7 +1126,7 @@ ALIASES
|
||||
$ eas worker:delete
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/deploy/delete.ts)_
|
||||
|
||||
## `eas deploy:promote`
|
||||
|
||||
@@ -1137,7 +1163,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.7.0/packages/eas-cli/src/commands/device/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/create.ts)_
|
||||
|
||||
## `eas device:delete`
|
||||
|
||||
@@ -1157,7 +1183,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.7.0/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/delete.ts)_
|
||||
|
||||
## `eas device:list`
|
||||
|
||||
@@ -1178,7 +1204,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.7.0/packages/eas-cli/src/commands/device/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/list.ts)_
|
||||
|
||||
## `eas device:rename`
|
||||
|
||||
@@ -1199,7 +1225,7 @@ DESCRIPTION
|
||||
rename a registered device
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/rename.ts)_
|
||||
|
||||
## `eas device:view [UDID]`
|
||||
|
||||
@@ -1213,7 +1239,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.7.0/packages/eas-cli/src/commands/device/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/device/view.ts)_
|
||||
|
||||
## `eas diagnostics`
|
||||
|
||||
@@ -1227,7 +1253,7 @@ DESCRIPTION
|
||||
display environment info
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/diagnostics.ts)_
|
||||
|
||||
## `eas env:create [ENVIRONMENT]`
|
||||
|
||||
@@ -1259,7 +1285,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.7.0/packages/eas-cli/src/commands/env/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/create.ts)_
|
||||
|
||||
## `eas env:delete [ENVIRONMENT]`
|
||||
|
||||
@@ -1285,7 +1311,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.7.0/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/delete.ts)_
|
||||
|
||||
## `eas env:exec ENVIRONMENT BASH_COMMAND`
|
||||
|
||||
@@ -1307,7 +1333,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.7.0/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/exec.ts)_
|
||||
|
||||
## `eas env:get [ENVIRONMENT]`
|
||||
|
||||
@@ -1335,7 +1361,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.7.0/packages/eas-cli/src/commands/env/get.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/get.ts)_
|
||||
|
||||
## `eas env:list [ENVIRONMENT]`
|
||||
|
||||
@@ -1363,7 +1389,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.7.0/packages/eas-cli/src/commands/env/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/list.ts)_
|
||||
|
||||
## `eas env:pull [ENVIRONMENT]`
|
||||
|
||||
@@ -1386,7 +1412,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.7.0/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/pull.ts)_
|
||||
|
||||
## `eas env:push [ENVIRONMENT]`
|
||||
|
||||
@@ -1408,7 +1434,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.7.0/packages/eas-cli/src/commands/env/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/push.ts)_
|
||||
|
||||
## `eas env:update [ENVIRONMENT]`
|
||||
|
||||
@@ -1442,7 +1468,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.7.0/packages/eas-cli/src/commands/env/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/env/update.ts)_
|
||||
|
||||
## `eas fingerprint:compare [HASH1] [HASH2]`
|
||||
|
||||
@@ -1486,7 +1512,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.7.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/fingerprint/compare.ts)_
|
||||
|
||||
## `eas fingerprint:generate`
|
||||
|
||||
@@ -1517,7 +1543,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.7.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/fingerprint/generate.ts)_
|
||||
|
||||
## `eas help [COMMAND]`
|
||||
|
||||
@@ -1626,7 +1652,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.7.0/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/metadata/lint.ts)_
|
||||
|
||||
## `eas metadata:pull`
|
||||
|
||||
@@ -1644,7 +1670,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.7.0/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/metadata/pull.ts)_
|
||||
|
||||
## `eas metadata:push`
|
||||
|
||||
@@ -1662,7 +1688,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.7.0/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/metadata/push.ts)_
|
||||
|
||||
## `eas new [PATH]`
|
||||
|
||||
@@ -1714,7 +1740,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.7.0/packages/eas-cli/src/commands/open.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/open.ts)_
|
||||
|
||||
## `eas project:info`
|
||||
|
||||
@@ -1728,7 +1754,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.7.0/packages/eas-cli/src/commands/project/info.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/project/info.ts)_
|
||||
|
||||
## `eas project:init`
|
||||
|
||||
@@ -1751,7 +1777,7 @@ ALIASES
|
||||
$ eas init
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/init.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/project/init.ts)_
|
||||
|
||||
## `eas project:new [PATH]`
|
||||
|
||||
@@ -1775,7 +1801,7 @@ ALIASES
|
||||
$ eas new
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/new.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/project/new.ts)_
|
||||
|
||||
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
|
||||
|
||||
@@ -1793,7 +1819,7 @@ ALIASES
|
||||
$ eas onboarding
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/project/onboarding.ts)_
|
||||
|
||||
## `eas submit`
|
||||
|
||||
@@ -1826,7 +1852,7 @@ ALIASES
|
||||
$ eas build:submit
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/submit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/submit.ts)_
|
||||
|
||||
## `eas update`
|
||||
|
||||
@@ -1869,7 +1895,7 @@ DESCRIPTION
|
||||
publish an update group
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/index.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/index.ts)_
|
||||
|
||||
## `eas update:configure`
|
||||
|
||||
@@ -1890,7 +1916,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.7.0/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/configure.ts)_
|
||||
|
||||
## `eas update:delete GROUPID`
|
||||
|
||||
@@ -1911,7 +1937,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.7.0/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/delete.ts)_
|
||||
|
||||
## `eas update:edit [GROUPID]`
|
||||
|
||||
@@ -1936,7 +1962,34 @@ 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.7.0/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/edit.ts)_
|
||||
|
||||
## `eas update:insights GROUPID`
|
||||
|
||||
display launch, crash, unique-user, and size insights for an update group
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas update:insights GROUPID [--platform ios|android] [--days <value> | --start <value> | --end <value>] [--json]
|
||||
[--non-interactive]
|
||||
|
||||
ARGUMENTS
|
||||
GROUPID The ID of an update group.
|
||||
|
||||
FLAGS
|
||||
--days=<value> Show insights from the last N days (default 7, mutually exclusive with --start/--end).
|
||||
--end=<value> End of insights time range (ISO date).
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
|
||||
--non-interactive Run the command in non-interactive mode.
|
||||
--platform=<option> Filter to a single platform.
|
||||
<options: ios|android>
|
||||
--start=<value> Start of insights time range (ISO date).
|
||||
|
||||
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.8.1/packages/eas-cli/src/commands/update/insights.ts)_
|
||||
|
||||
## `eas update:list`
|
||||
|
||||
@@ -1963,7 +2016,7 @@ DESCRIPTION
|
||||
view the recent updates
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/list.ts)_
|
||||
|
||||
## `eas update:republish`
|
||||
|
||||
@@ -2001,7 +2054,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.7.0/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/republish.ts)_
|
||||
|
||||
## `eas update:revert-update-rollout`
|
||||
|
||||
@@ -2029,7 +2082,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.7.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
|
||||
|
||||
## `eas update:roll-back-to-embedded`
|
||||
|
||||
@@ -2059,7 +2112,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.7.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
|
||||
|
||||
## `eas update:rollback`
|
||||
|
||||
@@ -2080,7 +2133,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.7.0/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/rollback.ts)_
|
||||
|
||||
## `eas update:view GROUPID`
|
||||
|
||||
@@ -2088,19 +2141,23 @@ update group details
|
||||
|
||||
```
|
||||
USAGE
|
||||
$ eas update:view GROUPID [--json]
|
||||
$ eas update:view GROUPID [--insights] [--days <value> | --start <value> | --end <value>] [--json]
|
||||
|
||||
ARGUMENTS
|
||||
GROUPID The ID of an update group.
|
||||
|
||||
FLAGS
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--days=<value> Show insights from the last N days (default 7). Only used with --insights.
|
||||
--end=<value> End of insights time range (ISO date). Only used with --insights.
|
||||
--insights Also show insights (launches, crash rate, unique users, payload size) for the update group.
|
||||
--json Enable JSON output, non-JSON messages will be printed to stderr.
|
||||
--start=<value> Start of insights time range (ISO date). Only used with --insights.
|
||||
|
||||
DESCRIPTION
|
||||
update group details
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/update/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/update/view.ts)_
|
||||
|
||||
## `eas upload`
|
||||
|
||||
@@ -2121,7 +2178,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.7.0/packages/eas-cli/src/commands/upload.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/upload.ts)_
|
||||
|
||||
## `eas webhook:create`
|
||||
|
||||
@@ -2143,7 +2200,7 @@ DESCRIPTION
|
||||
create a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/create.ts)_
|
||||
|
||||
## `eas webhook:delete [ID]`
|
||||
|
||||
@@ -2163,7 +2220,7 @@ DESCRIPTION
|
||||
delete a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/delete.ts)_
|
||||
|
||||
## `eas webhook:list`
|
||||
|
||||
@@ -2182,7 +2239,7 @@ DESCRIPTION
|
||||
list webhooks
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/list.ts)_
|
||||
|
||||
## `eas webhook:update`
|
||||
|
||||
@@ -2205,7 +2262,7 @@ DESCRIPTION
|
||||
update a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/update.ts)_
|
||||
|
||||
## `eas webhook:view ID`
|
||||
|
||||
@@ -2222,7 +2279,7 @@ DESCRIPTION
|
||||
view a webhook
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/webhook/view.ts)_
|
||||
|
||||
## `eas whoami`
|
||||
|
||||
@@ -2343,7 +2400,7 @@ DESCRIPTION
|
||||
to cancel.
|
||||
```
|
||||
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/cancel.ts)_
|
||||
|
||||
## `eas workflow:create [NAME]`
|
||||
|
||||
@@ -2363,7 +2420,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.7.0/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/create.ts)_
|
||||
|
||||
## `eas workflow:logs [ID]`
|
||||
|
||||
@@ -2387,7 +2444,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.7.0/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/logs.ts)_
|
||||
|
||||
## `eas workflow:run FILE`
|
||||
|
||||
@@ -2427,7 +2484,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.7.0/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/run.ts)_
|
||||
|
||||
## `eas workflow:runs`
|
||||
|
||||
@@ -2449,7 +2506,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.7.0/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/runs.ts)_
|
||||
|
||||
## `eas workflow:status [WORKFLOW_RUN_ID]`
|
||||
|
||||
@@ -2477,7 +2534,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.7.0/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/status.ts)_
|
||||
|
||||
## `eas workflow:validate PATH`
|
||||
|
||||
@@ -2497,7 +2554,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.7.0/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/validate.ts)_
|
||||
|
||||
## `eas workflow:view [ID]`
|
||||
|
||||
@@ -2519,5 +2576,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.7.0/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v18.8.1/packages/eas-cli/src/commands/workflow/view.ts)_
|
||||
<!-- commandsstop -->
|
||||
Generated
+1194
-6
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "18.7.0",
|
||||
"version": "18.8.1",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -47,8 +47,8 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/eas-json": "18.6.0",
|
||||
"@expo/eas-build-job": "18.8.0",
|
||||
"@expo/eas-json": "18.8.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "18.5.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.6.0",
|
||||
"@expo/steps": "18.8.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
|
||||
@@ -64,6 +64,24 @@ export function getWorkflowRunUrl(
|
||||
).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Links to the raw job-run page; prefer a higher-level URL (e.g. the workflow run
|
||||
* or the feature-specific dashboard) that gives users more context. Use this only for internal
|
||||
* tooling where no richer URL exists.
|
||||
*/
|
||||
export function getBareJobRunUrl(
|
||||
accountName: string,
|
||||
projectName: string,
|
||||
jobRunId: string
|
||||
): string {
|
||||
return new URL(
|
||||
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
|
||||
projectName
|
||||
)}/job-runs/${encodeURIComponent(jobRunId)}`,
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getProjectGitHubSettingsUrl(accountName: string, projectName: string): string {
|
||||
return new URL(
|
||||
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { ChannelRuntimeInsights } from '../../../graphql/queries/ChannelInsightsQuery';
|
||||
import {
|
||||
buildChannelInsightsJson,
|
||||
buildChannelInsightsTable,
|
||||
toChannelInsightsSummary,
|
||||
} from '../formatInsights';
|
||||
|
||||
function makeInsights(
|
||||
overrides: {
|
||||
embeddedUpdateTotalUniqueUsers?: number;
|
||||
mostPopularUpdates?: {
|
||||
id: string;
|
||||
group: string;
|
||||
message: string | null;
|
||||
runtimeVersion: string;
|
||||
platform: string;
|
||||
totalUniqueUsers: number;
|
||||
}[];
|
||||
} = {}
|
||||
): ChannelRuntimeInsights {
|
||||
return {
|
||||
__typename: 'UpdateChannelRuntimeInsights',
|
||||
id: 'rti-id',
|
||||
embeddedUpdateTotalUniqueUsers: overrides.embeddedUpdateTotalUniqueUsers ?? 250,
|
||||
mostPopularUpdates: (
|
||||
overrides.mostPopularUpdates ?? [
|
||||
{
|
||||
id: 'u1',
|
||||
group: 'group-a',
|
||||
message: 'first',
|
||||
runtimeVersion: '1.0.0',
|
||||
platform: 'ios',
|
||||
totalUniqueUsers: 800,
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
group: 'group-b',
|
||||
message: 'second',
|
||||
runtimeVersion: '1.0.0',
|
||||
platform: 'android',
|
||||
totalUniqueUsers: 200,
|
||||
},
|
||||
]
|
||||
).map(u => ({
|
||||
__typename: 'Update' as const,
|
||||
id: u.id,
|
||||
group: u.group,
|
||||
message: u.message,
|
||||
runtimeVersion: u.runtimeVersion,
|
||||
platform: u.platform,
|
||||
insights: {
|
||||
__typename: 'UpdateInsights' as const,
|
||||
id: `${u.id}-insights`,
|
||||
totalUniqueUsers: u.totalUniqueUsers,
|
||||
},
|
||||
})),
|
||||
uniqueUsersOverTime: {
|
||||
__typename: 'UniqueUsersOverTimeData',
|
||||
data: {
|
||||
__typename: 'LineChartData',
|
||||
labels: ['2026-04-09', '2026-04-10'],
|
||||
datasets: [{ __typename: 'LineDataset', id: 'd1', label: 'iOS', data: [100, 200] }],
|
||||
},
|
||||
},
|
||||
cumulativeMetricsOverTime: {
|
||||
__typename: 'ChannelRuntimeCumulativeMetricsOverTimeData',
|
||||
data: {
|
||||
__typename: 'LineChartData',
|
||||
labels: ['2026-04-09', '2026-04-10'],
|
||||
datasets: [{ __typename: 'LineDataset', id: 'cd1', label: 'Launches', data: [10, 20] }],
|
||||
},
|
||||
metricsAtLastTimestamp: [
|
||||
{ __typename: 'LineDatapoint', id: 'launches', label: 'Launches', data: 1234 },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const TIMESPAN = {
|
||||
startTime: '2026-04-09T00:00:00.000Z',
|
||||
endTime: '2026-04-16T00:00:00.000Z',
|
||||
daysBack: 7,
|
||||
};
|
||||
|
||||
describe(toChannelInsightsSummary, () => {
|
||||
it('ranks most popular updates by their order in the response', () => {
|
||||
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
|
||||
expect(summary.mostPopularUpdates.map(u => u.rank)).toEqual([1, 2]);
|
||||
expect(summary.mostPopularUpdates[0].groupId).toBe('group-a');
|
||||
});
|
||||
|
||||
it('sums OTA totalUniqueUsers across most popular updates', () => {
|
||||
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
|
||||
expect(summary.otaTotalUniqueUsers).toBe(1000);
|
||||
});
|
||||
|
||||
it('passes through embedded update unique users', () => {
|
||||
const summary = toChannelInsightsSummary(
|
||||
'production',
|
||||
'1.0.0',
|
||||
makeInsights({ embeddedUpdateTotalUniqueUsers: 42 }),
|
||||
TIMESPAN
|
||||
);
|
||||
expect(summary.embeddedUpdateTotalUniqueUsers).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildChannelInsightsJson, () => {
|
||||
it('returns a serializable object', () => {
|
||||
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
|
||||
const json = buildChannelInsightsJson(summary) as any;
|
||||
expect(json.channel).toBe('production');
|
||||
expect(json.runtimeVersion).toBe('1.0.0');
|
||||
expect(json.timespan.daysBack).toBe(7);
|
||||
expect(json.mostPopularUpdates).toHaveLength(2);
|
||||
expect(json.cumulativeMetricsAtLastTimestamp[0].label).toBe('Launches');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildChannelInsightsTable, () => {
|
||||
it('renders channel, runtime, embedded users and most popular updates', () => {
|
||||
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
|
||||
const table = buildChannelInsightsTable(summary);
|
||||
expect(table).toMatch(/Channel/);
|
||||
expect(table).toMatch(/production/);
|
||||
expect(table).toMatch(/1\.0\.0/);
|
||||
expect(table).toMatch(/Embedded update users/);
|
||||
expect(table).toMatch(/group-a/);
|
||||
expect(table).toMatch(/Most popular updates/);
|
||||
});
|
||||
|
||||
it('shows a friendly message when there are no popular updates', () => {
|
||||
const summary = toChannelInsightsSummary(
|
||||
'production',
|
||||
'1.0.0',
|
||||
makeInsights({ mostPopularUpdates: [] }),
|
||||
TIMESPAN
|
||||
);
|
||||
const table = buildChannelInsightsTable(summary);
|
||||
expect(table).toMatch(/No update launches recorded/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { ChannelRuntimeInsights } from '../../graphql/queries/ChannelInsightsQuery';
|
||||
import { formatTimespan } from '../../insights/formatTimespan';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import renderTextTable from '../../utils/renderTextTable';
|
||||
|
||||
export interface ChannelInsightsTimespan {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
daysBack?: number;
|
||||
}
|
||||
|
||||
export interface ChannelMostPopularUpdate {
|
||||
rank: number;
|
||||
groupId: string;
|
||||
message: string | null;
|
||||
platform: string;
|
||||
totalUniqueUsers: number;
|
||||
}
|
||||
|
||||
export interface ChannelInsightsSummary {
|
||||
channelName: string;
|
||||
runtimeVersion: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
daysBack?: number;
|
||||
embeddedUpdateTotalUniqueUsers: number;
|
||||
otaTotalUniqueUsers: number;
|
||||
mostPopularUpdates: ChannelMostPopularUpdate[];
|
||||
cumulativeMetricsAtLastTimestamp: { id: string; label: string; data: number }[];
|
||||
uniqueUsersOverTime: {
|
||||
labels: string[];
|
||||
datasets: { id: string; label: string; data: (number | null)[] }[];
|
||||
};
|
||||
cumulativeMetricsOverTime: {
|
||||
labels: string[];
|
||||
datasets: { id: string; label: string; data: (number | null)[] }[];
|
||||
};
|
||||
}
|
||||
|
||||
export function toChannelInsightsSummary(
|
||||
channelName: string,
|
||||
runtimeVersion: string,
|
||||
insights: ChannelRuntimeInsights,
|
||||
timespan: ChannelInsightsTimespan
|
||||
): ChannelInsightsSummary {
|
||||
const mostPopular: ChannelMostPopularUpdate[] = insights.mostPopularUpdates.map((u, i) => ({
|
||||
rank: i + 1,
|
||||
groupId: u.group,
|
||||
message: u.message ?? null,
|
||||
platform: u.platform,
|
||||
totalUniqueUsers: u.insights.totalUniqueUsers,
|
||||
}));
|
||||
|
||||
const otaTotalUniqueUsers = mostPopular.reduce((sum, u) => sum + u.totalUniqueUsers, 0);
|
||||
|
||||
return {
|
||||
channelName,
|
||||
runtimeVersion,
|
||||
startTime: timespan.startTime,
|
||||
endTime: timespan.endTime,
|
||||
daysBack: timespan.daysBack,
|
||||
embeddedUpdateTotalUniqueUsers: insights.embeddedUpdateTotalUniqueUsers,
|
||||
otaTotalUniqueUsers,
|
||||
mostPopularUpdates: mostPopular,
|
||||
cumulativeMetricsAtLastTimestamp: insights.cumulativeMetricsOverTime.metricsAtLastTimestamp.map(
|
||||
m => ({ id: m.id, label: m.label, data: m.data })
|
||||
),
|
||||
uniqueUsersOverTime: {
|
||||
labels: insights.uniqueUsersOverTime.data.labels,
|
||||
datasets: insights.uniqueUsersOverTime.data.datasets.map(d => ({
|
||||
id: d.id,
|
||||
label: d.label,
|
||||
data: d.data,
|
||||
})),
|
||||
},
|
||||
cumulativeMetricsOverTime: {
|
||||
labels: insights.cumulativeMetricsOverTime.data.labels,
|
||||
datasets: insights.cumulativeMetricsOverTime.data.datasets.map(d => ({
|
||||
id: d.id,
|
||||
label: d.label,
|
||||
data: d.data,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChannelInsightsJson(summary: ChannelInsightsSummary): object {
|
||||
return {
|
||||
channel: summary.channelName,
|
||||
runtimeVersion: summary.runtimeVersion,
|
||||
timespan: {
|
||||
start: summary.startTime,
|
||||
end: summary.endTime,
|
||||
...(summary.daysBack !== undefined ? { daysBack: summary.daysBack } : {}),
|
||||
},
|
||||
embeddedUpdateTotalUniqueUsers: summary.embeddedUpdateTotalUniqueUsers,
|
||||
otaTotalUniqueUsers: summary.otaTotalUniqueUsers,
|
||||
mostPopularUpdates: summary.mostPopularUpdates,
|
||||
cumulativeMetricsAtLastTimestamp: summary.cumulativeMetricsAtLastTimestamp,
|
||||
uniqueUsersOverTime: summary.uniqueUsersOverTime,
|
||||
cumulativeMetricsOverTime: summary.cumulativeMetricsOverTime,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChannelInsightsTable(summary: ChannelInsightsSummary): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
sections.push(chalk.bold('Channel insights:'));
|
||||
sections.push(
|
||||
formatFields([
|
||||
{ label: 'Channel', value: summary.channelName },
|
||||
{ label: 'Runtime version', value: summary.runtimeVersion },
|
||||
{ label: 'Time range', value: formatTimespan(summary) },
|
||||
{
|
||||
label: 'Embedded update users',
|
||||
value: summary.embeddedUpdateTotalUniqueUsers.toLocaleString(),
|
||||
},
|
||||
{ label: 'OTA update users', value: summary.otaTotalUniqueUsers.toLocaleString() },
|
||||
])
|
||||
);
|
||||
|
||||
if (summary.cumulativeMetricsAtLastTimestamp.length > 0) {
|
||||
sections.push('');
|
||||
sections.push(chalk.bold('Cumulative metrics at last timestamp:'));
|
||||
sections.push(
|
||||
formatFields(
|
||||
summary.cumulativeMetricsAtLastTimestamp.map(m => ({
|
||||
label: m.label,
|
||||
value: m.data.toLocaleString(),
|
||||
}))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (summary.mostPopularUpdates.length > 0) {
|
||||
sections.push('');
|
||||
sections.push(chalk.bold(`Most popular updates${formatTrailingTimespan(summary)}:`));
|
||||
sections.push(renderMostPopularTable(summary.mostPopularUpdates));
|
||||
} else {
|
||||
sections.push('');
|
||||
sections.push(chalk.yellow('No update launches recorded for this channel and runtime.'));
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
function formatTrailingTimespan(summary: ChannelInsightsSummary): string {
|
||||
return summary.daysBack ? ` (last ${summary.daysBack} days)` : '';
|
||||
}
|
||||
|
||||
function renderMostPopularTable(rows: ChannelMostPopularUpdate[]): string {
|
||||
return renderTextTable(
|
||||
['#', 'Group ID', 'Platform', 'Unique users', 'Message'],
|
||||
rows.map(r => [
|
||||
String(r.rank),
|
||||
r.groupId,
|
||||
r.platform,
|
||||
r.totalUniqueUsers.toLocaleString(),
|
||||
r.message ?? '',
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { ChannelInsightsQuery } from '../../../graphql/queries/ChannelInsightsQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ChannelInsights from '../insights';
|
||||
|
||||
jest.mock('../../../graphql/queries/ChannelInsightsQuery', () => ({
|
||||
ChannelInsightsQuery: { viewChannelRuntimeInsightsAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockViewChannelRuntimeInsightsAsync = jest.mocked(
|
||||
ChannelInsightsQuery.viewChannelRuntimeInsightsAsync
|
||||
);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
const insightsResponse = {
|
||||
__typename: 'UpdateChannelRuntimeInsights' as const,
|
||||
id: 'rti',
|
||||
embeddedUpdateTotalUniqueUsers: 50,
|
||||
mostPopularUpdates: [
|
||||
{
|
||||
__typename: 'Update' as const,
|
||||
id: 'u1',
|
||||
group: 'group-a',
|
||||
message: 'first',
|
||||
runtimeVersion: '1.0.0',
|
||||
platform: 'ios',
|
||||
insights: { __typename: 'UpdateInsights' as const, id: 'u1-i', totalUniqueUsers: 200 },
|
||||
},
|
||||
],
|
||||
uniqueUsersOverTime: {
|
||||
__typename: 'UniqueUsersOverTimeData' as const,
|
||||
data: {
|
||||
__typename: 'LineChartData' as const,
|
||||
labels: ['2026-04-09'],
|
||||
datasets: [{ __typename: 'LineDataset' as const, id: 'd1', label: 'iOS', data: [50] }],
|
||||
},
|
||||
},
|
||||
cumulativeMetricsOverTime: {
|
||||
__typename: 'ChannelRuntimeCumulativeMetricsOverTimeData' as const,
|
||||
data: {
|
||||
__typename: 'LineChartData' as const,
|
||||
labels: ['2026-04-09'],
|
||||
datasets: [{ __typename: 'LineDataset' as const, id: 'cd1', label: 'Launches', data: [42] }],
|
||||
},
|
||||
metricsAtLastTimestamp: [
|
||||
{ __typename: 'LineDatapoint' as const, id: 'launches', label: 'Launches', data: 42 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe(ChannelInsights, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'project-1';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockViewChannelRuntimeInsightsAsync.mockResolvedValue(insightsResponse);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ChannelInsights {
|
||||
const command = new ChannelInsights(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('passes appId, channel, runtimeVersion and default 7-day timespan to the query', async () => {
|
||||
const now = new Date('2026-04-16T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['--channel', 'production', '--runtime-version', '1.0.0']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockViewChannelRuntimeInsightsAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: 'production',
|
||||
runtimeVersion: '1.0.0',
|
||||
startTime: '2026-04-09T12:00:00.000Z',
|
||||
endTime: '2026-04-16T12:00:00.000Z',
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('emits JSON when --json is passed', async () => {
|
||||
const command = createCommand([
|
||||
'--channel',
|
||||
'production',
|
||||
'--runtime-version',
|
||||
'1.0.0',
|
||||
'--json',
|
||||
'--non-interactive',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: 'production',
|
||||
runtimeVersion: '1.0.0',
|
||||
embeddedUpdateTotalUniqueUsers: 50,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('requires --channel and --runtime-version', async () => {
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import {
|
||||
buildChannelInsightsJson,
|
||||
buildChannelInsightsTable,
|
||||
toChannelInsightsSummary,
|
||||
} from '../../channel/insights/formatInsights';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { ChannelInsightsQuery } from '../../graphql/queries/ChannelInsightsQuery';
|
||||
import { resolveInsightsTimeRange } from '../../insights/timeRange';
|
||||
import Log from '../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class ChannelInsights extends EasCommand {
|
||||
static override description =
|
||||
'display adoption, crash, and unique-user insights for a channel + runtime version';
|
||||
|
||||
static override flags = {
|
||||
channel: Flags.string({
|
||||
description: 'Name of the channel.',
|
||||
required: true,
|
||||
}),
|
||||
'runtime-version': Flags.string({
|
||||
description: 'Runtime version to query insights for.',
|
||||
required: true,
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description:
|
||||
'Show insights from the last N days (default 7, mutually exclusive with --start/--end).',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
start: Flags.string({
|
||||
description: 'Start of insights time range (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of insights time range (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(ChannelInsights);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(ChannelInsights, { nonInteractive });
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveInsightsTimeRange(flags);
|
||||
|
||||
const insights = await ChannelInsightsQuery.viewChannelRuntimeInsightsAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName: flags.channel,
|
||||
runtimeVersion: flags['runtime-version'],
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
|
||||
const summary = toChannelInsightsSummary(flags.channel, flags['runtime-version'], insights, {
|
||||
startTime,
|
||||
endTime,
|
||||
daysBack,
|
||||
});
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildChannelInsightsJson(summary));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildChannelInsightsTable(summary));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { getBareJobRunUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
|
||||
import {
|
||||
AppPlatform,
|
||||
DeviceRunSessionStatus,
|
||||
DeviceRunSessionType,
|
||||
JobRunStatus,
|
||||
} from '../../graphql/generated';
|
||||
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
|
||||
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
|
||||
import Log, { link } from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
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',
|
||||
};
|
||||
|
||||
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>;
|
||||
|
||||
type ReadinessResult = { ready: true; message: string } | { ready: false };
|
||||
type ReadinessChecker = (logMessages: readonly string[]) => ReadinessResult;
|
||||
|
||||
export default class SimulatorStart extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'[EXPERIMENTAL] start a remote simulator session on EAS and get the credentials to connect to it with the CLI tool of your choice';
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Device platform',
|
||||
options: ['android', 'ios'] as const,
|
||||
required: true,
|
||||
})(),
|
||||
type: Flags.option({
|
||||
description: 'Type of device run session to create',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
default: DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice],
|
||||
})(),
|
||||
'package-version': Flags.string({
|
||||
description:
|
||||
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
|
||||
}),
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(SimulatorStart);
|
||||
|
||||
const {
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(SimulatorStart, {
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios;
|
||||
|
||||
const createSpinner = ora('🚀 Creating device run session').start();
|
||||
let deviceRunSessionId: string;
|
||||
try {
|
||||
const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
platform,
|
||||
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
|
||||
packageVersion: flags['package-version'],
|
||||
});
|
||||
deviceRunSessionId = session.id;
|
||||
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
|
||||
const jobRunUrl = getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, jobRunId);
|
||||
createSpinner.succeed(
|
||||
`Device run session created (id: ${deviceRunSessionId}) ${link(jobRunUrl)}`
|
||||
);
|
||||
} catch (err) {
|
||||
createSpinner.fail('Failed to create device run session');
|
||||
throw err;
|
||||
}
|
||||
|
||||
const checkReadiness = getReadinessCheckerForType(flags.type);
|
||||
|
||||
const pollSpinner = ora(`⏳ Waiting for ${flags.type} daemon to start`).start();
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
let result: ReadinessResult = { ready: false };
|
||||
|
||||
try {
|
||||
while (Date.now() < deadline) {
|
||||
const session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId);
|
||||
|
||||
if (
|
||||
session.status === DeviceRunSessionStatus.Errored ||
|
||||
session.status === DeviceRunSessionStatus.Stopped
|
||||
) {
|
||||
throw new Error(
|
||||
`Device run session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} daemon was ready.`
|
||||
);
|
||||
}
|
||||
|
||||
const jobRunStatus = session.turtleJobRun?.status;
|
||||
if (
|
||||
jobRunStatus === JobRunStatus.Errored ||
|
||||
jobRunStatus === JobRunStatus.Canceled ||
|
||||
jobRunStatus === JobRunStatus.Finished
|
||||
) {
|
||||
throw new Error(
|
||||
`Turtle job run for device run session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} daemon was ready.`
|
||||
);
|
||||
}
|
||||
|
||||
const logMessages = await fetchLogMessagesAsync(session.turtleJobRun?.logFileUrls ?? []);
|
||||
result = checkReadiness(logMessages);
|
||||
if (result.ready) {
|
||||
pollSpinner.succeed(`🎉 ${flags.type} daemon is ready`);
|
||||
break;
|
||||
}
|
||||
|
||||
await sleepAsync(POLL_INTERVAL_MS);
|
||||
}
|
||||
} catch (err) {
|
||||
pollSpinner.fail(`Failed while polling for ${flags.type} daemon logs`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!result.ready) {
|
||||
pollSpinner.fail(`Timed out waiting for ${flags.type} daemon to start`);
|
||||
throw new Error(
|
||||
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} daemon to start.`
|
||||
);
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(`🔑 Run the following in your shell to attach to ${flags.type}:`);
|
||||
Log.newLine();
|
||||
Log.log(result.message);
|
||||
}
|
||||
}
|
||||
|
||||
function getReadinessCheckerForType(type: string): ReadinessChecker {
|
||||
switch (type) {
|
||||
case DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice]:
|
||||
return checkAgentDeviceReadiness;
|
||||
default:
|
||||
throw new Error(`Unsupported device run session type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
const AGENT_DEVICE_BASE_URL_ENV_VAR = 'AGENT_DEVICE_DAEMON_BASE_URL';
|
||||
const AGENT_DEVICE_AUTH_TOKEN_ENV_VAR = 'AGENT_DEVICE_DAEMON_AUTH_TOKEN';
|
||||
|
||||
function checkAgentDeviceReadiness(logMessages: readonly string[]): ReadinessResult {
|
||||
let baseUrl: string | undefined;
|
||||
let authToken: string | undefined;
|
||||
for (const msg of logMessages) {
|
||||
baseUrl = baseUrl ?? extractExportedEnvValue(msg, AGENT_DEVICE_BASE_URL_ENV_VAR);
|
||||
authToken = authToken ?? extractExportedEnvValue(msg, AGENT_DEVICE_AUTH_TOKEN_ENV_VAR);
|
||||
if (baseUrl && authToken) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (baseUrl && authToken) {
|
||||
return {
|
||||
ready: true,
|
||||
message: [
|
||||
`export ${AGENT_DEVICE_BASE_URL_ENV_VAR}='${baseUrl}'`,
|
||||
`export ${AGENT_DEVICE_AUTH_TOKEN_ENV_VAR}='${authToken}'`,
|
||||
].join('\n'),
|
||||
};
|
||||
}
|
||||
return { ready: false };
|
||||
}
|
||||
|
||||
async function fetchLogMessagesAsync(logUrls: readonly string[]): Promise<string[]> {
|
||||
const messages: string[] = [];
|
||||
for (const url of logUrls) {
|
||||
const text = await fetchLogTextAsync(url);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
for (const line of text.split('\n')) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
messages.push(extractLogMessage(line));
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
async function fetchLogTextAsync(url: string): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
return await response.text();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractLogMessage(line: string): string {
|
||||
// Turtle job run logs are JSONL (bunyan-shaped), e.g.
|
||||
// {"msg":"export FOO=\"bar\"","time":"...","logId":"..."}
|
||||
// Fall back to the raw line if it's not JSON or doesn't have a string msg.
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('{')) {
|
||||
return line;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && 'msg' in parsed) {
|
||||
const msg = (parsed as { msg: unknown }).msg;
|
||||
if (typeof msg === 'string') {
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// not JSON, fall through
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function extractExportedEnvValue(text: string, varName: string): string | undefined {
|
||||
// Matches: export NAME=value | export NAME="value" | export NAME='value'
|
||||
const pattern = new RegExp(`export\\s+${escapeRegExp(varName)}=(?:"([^"]*)"|'([^']*)'|(\\S+))`);
|
||||
const match = pattern.exec(text);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return match[1] ?? match[2] ?? match[3];
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { UpdateInsightsQuery } from '../../../graphql/queries/UpdateInsightsQuery';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import UpdateInsights from '../insights';
|
||||
|
||||
jest.mock('../../../graphql/queries/UpdateInsightsQuery', () => ({
|
||||
UpdateInsightsQuery: { viewUpdateGroupInsightsAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockViewUpdateGroupInsightsAsync = jest.mocked(
|
||||
UpdateInsightsQuery.viewUpdateGroupInsightsAsync
|
||||
);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
function makeUpdateResponse(platform: 'ios' | 'android', totalInstalls = 990): any {
|
||||
return {
|
||||
__typename: 'Update',
|
||||
id: `${platform}-update`,
|
||||
platform,
|
||||
insights: {
|
||||
__typename: 'UpdateInsights',
|
||||
id: `${platform}-i`,
|
||||
totalUniqueUsers: 100,
|
||||
cumulativeAverageMetrics: {
|
||||
__typename: 'CumulativeAverageMetrics',
|
||||
launchAssetCount: 3,
|
||||
averageUpdatePayloadBytes: 1024,
|
||||
},
|
||||
cumulativeMetrics: {
|
||||
__typename: 'CumulativeMetrics',
|
||||
metricsAtLastTimestamp: {
|
||||
__typename: 'CumulativeMetricsTotals',
|
||||
totalInstalls,
|
||||
totalFailedInstalls: 10,
|
||||
},
|
||||
data: {
|
||||
__typename: 'UpdatesMetricsData',
|
||||
labels: ['2026-04-09'],
|
||||
installsDataset: {
|
||||
__typename: 'CumulativeUpdatesDataset',
|
||||
id: `${platform}-i`,
|
||||
label: 'Installs',
|
||||
cumulative: [totalInstalls],
|
||||
difference: [totalInstalls],
|
||||
},
|
||||
failedInstallsDataset: {
|
||||
__typename: 'CumulativeUpdatesDataset',
|
||||
id: `${platform}-f`,
|
||||
label: 'Failed installs',
|
||||
cumulative: [10],
|
||||
difference: [10],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const updatesResponse = [makeUpdateResponse('android', 500), makeUpdateResponse('ios', 990)];
|
||||
|
||||
describe(UpdateInsights, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockViewUpdateGroupInsightsAsync.mockResolvedValue(updatesResponse);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): UpdateInsights {
|
||||
const command = new UpdateInsights(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('uses default 7-day window when --days/--start/--end are not provided', async () => {
|
||||
const now = new Date('2026-04-16T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['group-1']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
groupId: 'group-1',
|
||||
startTime: '2026-04-09T12:00:00.000Z',
|
||||
endTime: '2026-04-16T12:00:00.000Z',
|
||||
})
|
||||
);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses --days when explicitly provided', async () => {
|
||||
const now = new Date('2026-04-16T12:00:00.000Z');
|
||||
jest.useFakeTimers({ now });
|
||||
|
||||
const command = createCommand(['group-1', '--days', '14']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
startTime: '2026-04-02T12:00:00.000Z',
|
||||
endTime: '2026-04-16T12:00:00.000Z',
|
||||
})
|
||||
);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses --start and --end when provided', async () => {
|
||||
const command = createCommand([
|
||||
'group-1',
|
||||
'--start',
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
'--end',
|
||||
'2026-02-01T00:00:00.000Z',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({
|
||||
startTime: '2026-01-01T00:00:00.000Z',
|
||||
endTime: '2026-02-01T00:00:00.000Z',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects --days combined with --start', async () => {
|
||||
const command = createCommand([
|
||||
'group-1',
|
||||
'--days',
|
||||
'7',
|
||||
'--start',
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
]);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('filters to a single platform when --platform is passed', async () => {
|
||||
const command = createCommand(['group-1', '--platform', 'ios', '--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
const json = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
|
||||
expect(json.platforms.map((p: any) => p.platform)).toEqual(['ios']);
|
||||
});
|
||||
|
||||
it('throws when --platform does not match any update in the group', async () => {
|
||||
mockViewUpdateGroupInsightsAsync.mockResolvedValue([makeUpdateResponse('android', 500)]);
|
||||
const command = createCommand(['group-1', '--platform', 'ios']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/no ios update/);
|
||||
});
|
||||
|
||||
it('emits JSON when --json is passed', async () => {
|
||||
const command = createCommand(['group-1', '--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
const json = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
|
||||
expect(json.groupId).toBe('group-1');
|
||||
expect(json.platforms.map((p: any) => p.platform)).toEqual(['android', 'ios']);
|
||||
expect(json.platforms[0].totals.installs).toBe(500);
|
||||
expect(json.platforms[1].totals.installs).toBe(990);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { UpdateInsightsQuery } from '../../../graphql/queries/UpdateInsightsQuery';
|
||||
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
|
||||
import Log from '../../../log';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import UpdateView from '../view';
|
||||
|
||||
jest.mock('../../../graphql/queries/UpdateQuery', () => ({
|
||||
UpdateQuery: { viewUpdateGroupAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/UpdateInsightsQuery', () => ({
|
||||
UpdateInsightsQuery: { viewUpdateGroupInsightsAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockViewUpdateGroupAsync = jest.mocked(UpdateQuery.viewUpdateGroupAsync);
|
||||
const mockViewUpdateGroupInsightsAsync = jest.mocked(
|
||||
UpdateInsightsQuery.viewUpdateGroupInsightsAsync
|
||||
);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
const mockLogLog = jest.mocked(Log.log);
|
||||
|
||||
const updateGroup = [
|
||||
{
|
||||
__typename: 'Update' as const,
|
||||
id: 'u1',
|
||||
group: 'group-1',
|
||||
branch: { __typename: 'UpdateBranch' as const, id: 'b1', name: 'main' },
|
||||
actor: null,
|
||||
createdAt: '2026-04-09T00:00:00.000Z',
|
||||
message: 'first',
|
||||
runtimeVersion: '1.0.0',
|
||||
platform: 'ios',
|
||||
manifestPermalink: 'https://expo.dev/manifest/u1',
|
||||
isRollBackToEmbedded: false,
|
||||
rolloutPercentage: null,
|
||||
codeSigningInfo: null,
|
||||
gitCommitHash: null,
|
||||
} as any,
|
||||
];
|
||||
|
||||
function makeUpdateWithInsights(platform: 'ios' | 'android', totalInstalls = 990): any {
|
||||
return {
|
||||
__typename: 'Update',
|
||||
id: `${platform}-update`,
|
||||
platform,
|
||||
insights: {
|
||||
__typename: 'UpdateInsights',
|
||||
id: `${platform}-i`,
|
||||
totalUniqueUsers: 100,
|
||||
cumulativeAverageMetrics: {
|
||||
__typename: 'CumulativeAverageMetrics',
|
||||
launchAssetCount: 3,
|
||||
averageUpdatePayloadBytes: 1024,
|
||||
},
|
||||
cumulativeMetrics: {
|
||||
__typename: 'CumulativeMetrics',
|
||||
metricsAtLastTimestamp: {
|
||||
__typename: 'CumulativeMetricsTotals',
|
||||
totalInstalls,
|
||||
totalFailedInstalls: 10,
|
||||
},
|
||||
data: {
|
||||
__typename: 'UpdatesMetricsData',
|
||||
labels: ['2026-04-09'],
|
||||
installsDataset: {
|
||||
__typename: 'CumulativeUpdatesDataset',
|
||||
id: `${platform}-i`,
|
||||
label: 'Installs',
|
||||
cumulative: [totalInstalls],
|
||||
difference: [totalInstalls],
|
||||
},
|
||||
failedInstallsDataset: {
|
||||
__typename: 'CumulativeUpdatesDataset',
|
||||
id: `${platform}-f`,
|
||||
label: 'Failed installs',
|
||||
cumulative: [10],
|
||||
difference: [10],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const insightsResponse = [makeUpdateWithInsights('ios', 990)];
|
||||
|
||||
describe(UpdateView, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockViewUpdateGroupAsync.mockResolvedValue(updateGroup);
|
||||
mockViewUpdateGroupInsightsAsync.mockResolvedValue(insightsResponse);
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): UpdateView {
|
||||
const command = new UpdateView(argv, mockConfig);
|
||||
// @ts-expect-error getContextAsync is protected
|
||||
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('does not fetch insights when --insights is not set', async () => {
|
||||
const command = createCommand(['group-1']);
|
||||
await command.runAsync();
|
||||
expect(mockViewUpdateGroupInsightsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects --days without --insights', async () => {
|
||||
const command = createCommand(['group-1', '--days', '7']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/--insights/);
|
||||
});
|
||||
|
||||
it('rejects --start without --insights', async () => {
|
||||
const command = createCommand(['group-1', '--start', '2026-04-01']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/--insights/);
|
||||
});
|
||||
|
||||
it('fetches insights when --insights is set', async () => {
|
||||
const command = createCommand(['group-1', '--insights']);
|
||||
await command.runAsync();
|
||||
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ groupId: 'group-1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('outputs flat update array under --json without --insights', async () => {
|
||||
const command = createCommand(['group-1', '--json']);
|
||||
await command.runAsync();
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
const arg = mockPrintJsonOnlyOutput.mock.calls[0][0];
|
||||
expect(Array.isArray(arg)).toBe(true);
|
||||
});
|
||||
|
||||
it('renders an insights section in the table output when --insights is set without --json', async () => {
|
||||
const command = createCommand(['group-1', '--insights']);
|
||||
await command.runAsync();
|
||||
const logged = mockLogLog.mock.calls.map(call => String(call[0] ?? '')).join('\n');
|
||||
expect(logged).toMatch(/Update group insights/);
|
||||
});
|
||||
|
||||
it('wraps output as { updates, insights } under --json --insights', async () => {
|
||||
const command = createCommand(['group-1', '--json', '--insights']);
|
||||
await command.runAsync();
|
||||
const arg = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
|
||||
expect(arg.updates).toBeDefined();
|
||||
expect(arg.insights).toBeDefined();
|
||||
expect(arg.insights.platforms[0].totals.installs).toBe(990);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { UpdateInsightsQuery } from '../../graphql/queries/UpdateInsightsQuery';
|
||||
import { INSIGHTS_DEFAULT_DAYS_BACK, resolveInsightsTimeRange } from '../../insights/timeRange';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
buildUpdateInsightsJson,
|
||||
buildUpdateInsightsTable,
|
||||
toUpdateInsightsSummary,
|
||||
} from '../../update/insights/formatInsights';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class UpdateInsights extends EasCommand {
|
||||
static override description =
|
||||
'display launch, crash, unique-user, and size insights for an update group';
|
||||
|
||||
static override args = {
|
||||
groupId: Args.string({
|
||||
required: true,
|
||||
description: 'The ID of an update group.',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
platform: Flags.option({
|
||||
description: 'Filter to a single platform.',
|
||||
options: ['ios', 'android'] as const,
|
||||
})(),
|
||||
days: Flags.integer({
|
||||
description: `Show insights from the last N days (default ${INSIGHTS_DEFAULT_DAYS_BACK}, mutually exclusive with --start/--end).`,
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
start: Flags.string({
|
||||
description: 'Start of insights time range (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of insights time range (ISO date).',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { groupId },
|
||||
flags,
|
||||
} = await this.parse(UpdateInsights);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(UpdateInsights, { nonInteractive });
|
||||
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const { daysBack, startTime, endTime } = resolveInsightsTimeRange(flags);
|
||||
|
||||
const allUpdates = await UpdateInsightsQuery.viewUpdateGroupInsightsAsync(graphqlClient, {
|
||||
groupId,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
|
||||
const updates = flags.platform
|
||||
? allUpdates.filter(u => u.platform === flags.platform)
|
||||
: allUpdates;
|
||||
if (updates.length === 0) {
|
||||
throw new Error(
|
||||
`Update group "${groupId}" has no ${flags.platform} update (available platforms: ${allUpdates
|
||||
.map(u => u.platform)
|
||||
.sort()
|
||||
.join(', ')}).`
|
||||
);
|
||||
}
|
||||
|
||||
const summary = toUpdateInsightsSummary(groupId, updates, { startTime, endTime, daysBack });
|
||||
|
||||
if (json) {
|
||||
printJsonOnlyOutput(buildUpdateInsightsJson(summary));
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildUpdateInsightsTable(summary));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
import { Args } from '@oclif/core';
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
|
||||
import { UpdateInsightsQuery } from '../../graphql/queries/UpdateInsightsQuery';
|
||||
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
|
||||
import { resolveInsightsTimeRange } from '../../insights/timeRange';
|
||||
import Log from '../../log';
|
||||
import {
|
||||
UpdateInsightsSummary,
|
||||
buildUpdateInsightsJson,
|
||||
buildUpdateInsightsTable,
|
||||
toUpdateInsightsSummary,
|
||||
} from '../../update/insights/formatInsights';
|
||||
import {
|
||||
formatUpdateGroup,
|
||||
getUpdateGroupDescriptions,
|
||||
@@ -23,6 +31,24 @@ export default class UpdateView extends EasCommand {
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
insights: Flags.boolean({
|
||||
description:
|
||||
'Also show insights (launches, crash rate, unique users, payload size) for the update group.',
|
||||
default: false,
|
||||
}),
|
||||
days: Flags.integer({
|
||||
description: 'Show insights from the last N days (default 7). Only used with --insights.',
|
||||
min: 1,
|
||||
exclusive: ['start', 'end'],
|
||||
}),
|
||||
start: Flags.string({
|
||||
description: 'Start of insights time range (ISO date). Only used with --insights.',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
end: Flags.string({
|
||||
description: 'End of insights time range (ISO date). Only used with --insights.',
|
||||
exclusive: ['days'],
|
||||
}),
|
||||
...EasJsonOnlyFlag,
|
||||
};
|
||||
|
||||
@@ -33,9 +59,13 @@ export default class UpdateView extends EasCommand {
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { groupId },
|
||||
flags: { json: jsonFlag },
|
||||
flags: { json: jsonFlag, insights: insightsFlag, days, start, end },
|
||||
} = await this.parse(UpdateView);
|
||||
|
||||
if (!insightsFlag && (days !== undefined || start !== undefined || end !== undefined)) {
|
||||
throw new Error('--days, --start, and --end can only be used with --insights.');
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(UpdateView, { nonInteractive: true });
|
||||
@@ -46,14 +76,40 @@ export default class UpdateView extends EasCommand {
|
||||
|
||||
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, { groupId });
|
||||
|
||||
let insightsSummary: UpdateInsightsSummary | null = null;
|
||||
if (insightsFlag) {
|
||||
const { daysBack, startTime, endTime } = resolveInsightsTimeRange({ days, start, end });
|
||||
const updatesWithInsights = await UpdateInsightsQuery.viewUpdateGroupInsightsAsync(
|
||||
graphqlClient,
|
||||
{ groupId, startTime, endTime }
|
||||
);
|
||||
insightsSummary = toUpdateInsightsSummary(groupId, updatesWithInsights, {
|
||||
startTime,
|
||||
endTime,
|
||||
daysBack,
|
||||
});
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(updatesByGroup));
|
||||
if (insightsSummary) {
|
||||
printJsonOnlyOutput({
|
||||
updates: getUpdateJsonInfosForUpdates(updatesByGroup),
|
||||
insights: buildUpdateInsightsJson(insightsSummary),
|
||||
});
|
||||
} else {
|
||||
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(updatesByGroup));
|
||||
}
|
||||
} else {
|
||||
const [updateGroupDescription] = getUpdateGroupDescriptions([updatesByGroup]);
|
||||
|
||||
Log.log(chalk.bold('Update group:'));
|
||||
|
||||
Log.log(formatUpdateGroup(updateGroupDescription));
|
||||
|
||||
if (insightsSummary) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(buildUpdateInsightsTable(insightsSummary));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,29 @@ const mockItems = {
|
||||
},
|
||||
};
|
||||
|
||||
const buildBundleInfo = (linked: {
|
||||
appGroups: Array<{ id: string }>;
|
||||
merchantIds: Array<{ id: string }>;
|
||||
cloudContainers: Array<{ id: string }>;
|
||||
}): any => ({
|
||||
attributes: {
|
||||
bundleIdCapabilities: [
|
||||
{
|
||||
isType: (t: string) => t === 'APPLE_PAY',
|
||||
attributes: { merchantIds: linked.merchantIds },
|
||||
},
|
||||
{
|
||||
isType: (t: string) => t === 'APP_GROUPS',
|
||||
attributes: { appGroups: linked.appGroups },
|
||||
},
|
||||
{
|
||||
isType: (t: string) => t === 'ICLOUD',
|
||||
attributes: { cloudContainers: linked.cloudContainers },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
function mockCapabilities(Apple: any): void {
|
||||
const mockGetAsync = (existingItems: any[]): jest.Mock =>
|
||||
jest.fn(() => Promise.resolve(existingItems));
|
||||
@@ -35,6 +58,16 @@ function mockCapabilities(Apple: any): void {
|
||||
Apple.CloudContainer.getAsync = mockGetAsync([mockItems.cloudContainer.expo]);
|
||||
|
||||
Apple.CloudContainer.createAsync = mockCreateAsync('XXX-icloud-2');
|
||||
|
||||
Apple.BundleId.infoAsync = jest.fn(() =>
|
||||
Promise.resolve(
|
||||
buildBundleInfo({
|
||||
merchantIds: [mockItems.merchant.expo],
|
||||
appGroups: [mockItems.appGroup.expo],
|
||||
cloudContainers: [mockItems.cloudContainer.expo],
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function mockCapabilitiesAlreadyCreated(Apple: any): void {
|
||||
@@ -52,6 +85,23 @@ function mockCapabilitiesAlreadyCreated(Apple: any): void {
|
||||
mockItems.cloudContainer.expo,
|
||||
{ id: 'XXX-icloud-2', attributes: { identifier: 'iCloud.bacon' } },
|
||||
]);
|
||||
|
||||
Apple.BundleId.infoAsync.mockResolvedValue(
|
||||
buildBundleInfo({
|
||||
merchantIds: [
|
||||
mockItems.merchant.expo,
|
||||
{ id: 'XXX-merch-2', attributes: { identifier: 'merchant.bacon' } },
|
||||
],
|
||||
appGroups: [
|
||||
mockItems.appGroup.expo,
|
||||
{ id: 'XXX-group-2', attributes: { identifier: 'group.bacon' } },
|
||||
],
|
||||
cloudContainers: [
|
||||
mockItems.cloudContainer.expo,
|
||||
{ id: 'XXX-icloud-2', attributes: { identifier: 'iCloud.bacon' } },
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
|
||||
|
||||
@@ -44,6 +44,20 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
|
||||
|
||||
const updateRequest: UpdateCapabilityRequest = [];
|
||||
|
||||
// Fetch the bundle with its linked capability identifiers to check for already linked identifiers.
|
||||
const bundleWithRelationships = await BundleId.infoAsync(bundleId.context, {
|
||||
id: bundleId.id,
|
||||
query: {
|
||||
includes: [
|
||||
'bundleIdCapabilities',
|
||||
'bundleIdCapabilities.appGroups',
|
||||
'bundleIdCapabilities.merchantIds',
|
||||
'bundleIdCapabilities.cloudContainers',
|
||||
],
|
||||
},
|
||||
});
|
||||
const linkedBundleCapabilities = bundleWithRelationships.attributes.bundleIdCapabilities ?? [];
|
||||
|
||||
// Iterate through the supported capabilities to build the request.
|
||||
for (const classifier of CapabilityIdMapping) {
|
||||
const CapabilityModel = classifier.capabilityIdModel;
|
||||
@@ -73,15 +87,29 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
|
||||
// Get a list of all of the capability IDs that are already created on the server.
|
||||
const existingIds = await CapabilityModel.getAsync(bundleId.context);
|
||||
|
||||
// A list of server IDs for linking.
|
||||
const capabilityIdOpaqueIds = [];
|
||||
|
||||
const capabilitiesWithoutRemoteModels = capabilityIds.filter(
|
||||
localId => existingIds.find(model => model.attributes.identifier === localId) === undefined
|
||||
// Opaque ids of identifiers already linked to this capability.
|
||||
const remoteLinkedIds = linkedBundleCapabilities.find(c => c.isType(classifier.capability))
|
||||
?.attributes as Record<string, { id: string }[] | undefined> | undefined;
|
||||
const alreadyLinkedOpaqueIds = new Set<string>(
|
||||
(remoteLinkedIds?.[CapabilityModel.type] ?? []).map(model => model.id)
|
||||
);
|
||||
|
||||
// A list of server IDs for linking.
|
||||
const capabilityIdOpaqueIds: string[] = [];
|
||||
|
||||
// Iterate through all the local IDs and see if they exist on the server.
|
||||
for (const localId of capabilitiesWithoutRemoteModels) {
|
||||
let remoteIdModel = undefined;
|
||||
for (const localId of capabilityIds) {
|
||||
let remoteIdModel = existingIds.find(model => model.attributes.identifier === localId);
|
||||
|
||||
// Identifier already exists.
|
||||
if (remoteIdModel) {
|
||||
if (!alreadyLinkedOpaqueIds.has(remoteIdModel.id)) {
|
||||
// Link the existing identifier to this bundle.
|
||||
linkedIds.push(remoteIdModel.attributes.identifier);
|
||||
capabilityIdOpaqueIds.push(remoteIdModel.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Log.isDebug) {
|
||||
Log.log(`Creating capability ID: ${localId} (${CapabilityModel.type})`);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
|
||||
import {
|
||||
EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE,
|
||||
withUpgradeRequiredErrorHandlingAsync,
|
||||
} from '../client';
|
||||
|
||||
function makeError(message: string, extensions?: Record<string, unknown>): CombinedError {
|
||||
return new CombinedError({
|
||||
graphQLErrors: [{ message, extensions } as any],
|
||||
});
|
||||
}
|
||||
|
||||
describe(withUpgradeRequiredErrorHandlingAsync, () => {
|
||||
it('returns data when the promise resolves successfully', async () => {
|
||||
const result = await withUpgradeRequiredErrorHandlingAsync(
|
||||
Promise.resolve({ data: { foo: 'bar' } } as any),
|
||||
{ featureName: 'feature' }
|
||||
);
|
||||
expect(result).toEqual({ foo: 'bar' });
|
||||
});
|
||||
|
||||
it('throws an upgrade-required error when the server returns the matching errorCode', async () => {
|
||||
const error = makeError('Schema changed', { errorCode: EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE });
|
||||
await expect(
|
||||
withUpgradeRequiredErrorHandlingAsync(Promise.resolve({ error } as any), {
|
||||
featureName: 'EAS Update insights',
|
||||
})
|
||||
).rejects.toThrow(/EAS Update insights is not supported by this version of eas-cli/);
|
||||
});
|
||||
|
||||
it('throws an upgrade-required error when the server returns a "Cannot query field" validation error', async () => {
|
||||
const error = makeError('Cannot query field "newField" on type "UpdateInsights".');
|
||||
await expect(
|
||||
withUpgradeRequiredErrorHandlingAsync(Promise.resolve({ error } as any), {
|
||||
featureName: 'feature',
|
||||
})
|
||||
).rejects.toThrow(/not supported by this version of eas-cli/);
|
||||
});
|
||||
|
||||
it('rethrows other GraphQL errors as-is', async () => {
|
||||
const error = makeError('Random other error', { errorCode: 'SOMETHING_ELSE' });
|
||||
await expect(
|
||||
withUpgradeRequiredErrorHandlingAsync(Promise.resolve({ error } as any), {
|
||||
featureName: 'feature',
|
||||
})
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { CombinedError as GraphqlError, OperationResult } from '@urql/core';
|
||||
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import Log from '../log';
|
||||
|
||||
export const EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE = 'EAS_CLI_UPGRADE_REQUIRED';
|
||||
|
||||
export async function withErrorHandlingAsync<T>(promise: Promise<OperationResult<T>>): Promise<T> {
|
||||
const { data, error } = await promise;
|
||||
|
||||
@@ -30,4 +33,42 @@ export async function withErrorHandlingAsync<T>(promise: Promise<OperationResult
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps `withErrorHandlingAsync` for queries that hit endpoints which may evolve in
|
||||
* ways that require a newer eas-cli. The server signals this by returning a GraphQL
|
||||
* error with `extensions.errorCode === EAS_CLI_UPGRADE_REQUIRED`. As a fallback we
|
||||
* also detect schema validation errors of the form `Cannot query field "X" on type "Y"`,
|
||||
* which surface when a field has been removed without a coded error.
|
||||
*
|
||||
* In either case we re-throw an `EasCommandError` instructing the user to upgrade.
|
||||
*/
|
||||
export async function withUpgradeRequiredErrorHandlingAsync<T>(
|
||||
promise: Promise<OperationResult<T>>,
|
||||
{ featureName }: { featureName: string }
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await withErrorHandlingAsync(promise);
|
||||
} catch (error) {
|
||||
if (isUpgradeRequiredError(error)) {
|
||||
throw new EasCommandError(
|
||||
`${featureName} is not supported by this version of eas-cli. ` +
|
||||
`Upgrade to the latest version: \`npm install -g eas-cli@latest\`.`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isUpgradeRequiredError(error: unknown): boolean {
|
||||
if (!(error instanceof GraphqlError)) {
|
||||
return false;
|
||||
}
|
||||
return error.graphQLErrors.some(e => {
|
||||
if (e?.extensions?.errorCode === EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE) {
|
||||
return true;
|
||||
}
|
||||
return /Cannot query field ".+" on type ".+"/.test(e?.message ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
export { GraphqlError };
|
||||
+215
-2
@@ -646,6 +646,8 @@ export type AccountMutation = {
|
||||
cancelAllSubscriptionsImmediately: Account;
|
||||
/** Cancel scheduled subscription change */
|
||||
cancelScheduledSubscriptionChange: Account;
|
||||
/** Buys or revokes account's additional agent credits, charging the account the appropriate amount if needed. */
|
||||
changeAdditionalAgentCreditsCount: Account;
|
||||
/** Buys or revokes account's additional concurrencies, charging the account the appropriate amount if needed. */
|
||||
changeAdditionalConcurrenciesCount: Account;
|
||||
/** Upgrades or downgrades the active subscription to the newPlanIdentifier, which must be one of the EAS plans (i.e., Production or Enterprise). */
|
||||
@@ -686,6 +688,12 @@ export type AccountMutationCancelScheduledSubscriptionChangeArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type AccountMutationChangeAdditionalAgentCreditsCountArgs = {
|
||||
accountID: Scalars['ID']['input'];
|
||||
newAdditionalAgentCreditsCount: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type AccountMutationChangeAdditionalConcurrenciesCountArgs = {
|
||||
accountID: Scalars['ID']['input'];
|
||||
newAdditionalConcurrenciesCount: Scalars['Int']['input'];
|
||||
@@ -775,8 +783,11 @@ export type AccountNotificationPreferenceInput = {
|
||||
export type AccountOnboardingStats = {
|
||||
__typename?: 'AccountOnboardingStats';
|
||||
firstBuildCompletedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
firstProjectCreatedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
firstSubmissionCompletedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
firstUpdateCreatedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
hasConfiguredUpdate: Scalars['Boolean']['output'];
|
||||
hasConfiguredWorkflow: Scalars['Boolean']['output'];
|
||||
hasTeamMembers: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
@@ -2839,6 +2850,23 @@ export type AppleDeviceRegistrationRequestMutationCreateAppleDeviceRegistrationR
|
||||
appleTeamId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
/** Publicly visible data for an AppleDeviceRegistrationRequest. */
|
||||
export type AppleDeviceRegistrationRequestPublicData = {
|
||||
__typename?: 'AppleDeviceRegistrationRequestPublicData';
|
||||
id: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
export type AppleDeviceRegistrationRequestPublicDataQuery = {
|
||||
__typename?: 'AppleDeviceRegistrationRequestPublicDataQuery';
|
||||
/** Get AppleDeviceRegistrationRequest public data by ID */
|
||||
byId?: Maybe<AppleDeviceRegistrationRequestPublicData>;
|
||||
};
|
||||
|
||||
|
||||
export type AppleDeviceRegistrationRequestPublicDataQueryByIdArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type AppleDeviceRegistrationRequestQuery = {
|
||||
__typename?: 'AppleDeviceRegistrationRequestQuery';
|
||||
byId: AppleDeviceRegistrationRequest;
|
||||
@@ -4074,6 +4102,7 @@ export type ConvexTeamConnectionMutation = {
|
||||
__typename?: 'ConvexTeamConnectionMutation';
|
||||
createConvexTeamConnection: ConvexTeamConnection;
|
||||
deleteConvexTeamConnection: ConvexTeamConnection;
|
||||
sendConvexTeamInviteToVerifiedEmail: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
|
||||
@@ -4086,6 +4115,11 @@ export type ConvexTeamConnectionMutationDeleteConvexTeamConnectionArgs = {
|
||||
convexTeamConnectionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type ConvexTeamConnectionMutationSendConvexTeamInviteToVerifiedEmailArgs = {
|
||||
input: SendConvexTeamInviteToVerifiedEmailInput;
|
||||
};
|
||||
|
||||
export enum CrashSampleFor {
|
||||
Newest = 'NEWEST',
|
||||
Oldest = 'OLDEST'
|
||||
@@ -4156,6 +4190,17 @@ export type CreateConvexTeamConnectionInput = {
|
||||
deploymentRegion: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type CreateDeviceRunSessionInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
/**
|
||||
* The version of the package backing the device run session (e.g. "0.1.3-alpha.3").
|
||||
* If omitted, consumers treat the session as pinned to "latest".
|
||||
*/
|
||||
packageVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
platform: AppPlatform;
|
||||
type: DeviceRunSessionType;
|
||||
};
|
||||
|
||||
export type CreateEchoChatInput = {
|
||||
agentMetadata?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
agentType?: InputMaybe<EchoAgentType>;
|
||||
@@ -4184,6 +4229,14 @@ export type CreateEchoMessagePartInput = {
|
||||
type: EchoMessagePartType;
|
||||
};
|
||||
|
||||
export type CreateEchoProjectIconInput = {
|
||||
accentColor?: InputMaybe<Scalars['String']['input']>;
|
||||
model?: InputMaybe<Scalars['String']['input']>;
|
||||
prompt?: InputMaybe<Scalars['String']['input']>;
|
||||
source: EchoProjectIconSource;
|
||||
url: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type CreateEchoProjectInput = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
currentPreviewEchoVersionId?: InputMaybe<Scalars['ID']['input']>;
|
||||
@@ -4745,6 +4798,65 @@ export type DeploymentsMutationDeleteWorkerDeploymentByIdentifierArgs = {
|
||||
deploymentIdentifier: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type DeviceRunSession = {
|
||||
__typename?: 'DeviceRunSession';
|
||||
app: App;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
finishedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
initiatingActor?: Maybe<Actor>;
|
||||
/**
|
||||
* The version of the package backing the device run session. Null means the session is
|
||||
* pinned to "latest" at the consumer side.
|
||||
*/
|
||||
packageVersion?: Maybe<Scalars['String']['output']>;
|
||||
platform: AppPlatform;
|
||||
startedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
status: DeviceRunSessionStatus;
|
||||
turtleJobRun?: Maybe<JobRun>;
|
||||
type: DeviceRunSessionType;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type DeviceRunSessionMutation = {
|
||||
__typename?: 'DeviceRunSessionMutation';
|
||||
/** Create a device run session */
|
||||
createDeviceRunSession: DeviceRunSession;
|
||||
/** Stop a device run session */
|
||||
stopDeviceRunSession: DeviceRunSession;
|
||||
};
|
||||
|
||||
|
||||
export type DeviceRunSessionMutationCreateDeviceRunSessionArgs = {
|
||||
deviceRunSessionInput: CreateDeviceRunSessionInput;
|
||||
};
|
||||
|
||||
|
||||
export type DeviceRunSessionMutationStopDeviceRunSessionArgs = {
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type DeviceRunSessionQuery = {
|
||||
__typename?: 'DeviceRunSessionQuery';
|
||||
byId: DeviceRunSession;
|
||||
};
|
||||
|
||||
|
||||
export type DeviceRunSessionQueryByIdArgs = {
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum DeviceRunSessionStatus {
|
||||
Errored = 'ERRORED',
|
||||
InProgress = 'IN_PROGRESS',
|
||||
New = 'NEW',
|
||||
Stopped = 'STOPPED'
|
||||
}
|
||||
|
||||
export enum DeviceRunSessionType {
|
||||
AgentDevice = 'AGENT_DEVICE'
|
||||
}
|
||||
|
||||
export type DiscordUser = {
|
||||
__typename?: 'DiscordUser';
|
||||
discordIdentifier: Scalars['String']['output'];
|
||||
@@ -5074,6 +5186,7 @@ export type EchoProject = {
|
||||
description?: Maybe<Scalars['String']['output']>;
|
||||
displayName?: Maybe<Scalars['String']['output']>;
|
||||
echoChats: EchoChatConnection;
|
||||
echoProjectIcon?: Maybe<EchoProjectIconGalleryItem>;
|
||||
echoVersions: EchoVersionConnection;
|
||||
/** Environment variables for this Echo project */
|
||||
environmentVariables: Array<EnvironmentVariable>;
|
||||
@@ -5084,6 +5197,7 @@ export type EchoProject = {
|
||||
environmentVariablesIncludingSensitive: Array<EnvironmentVariableWithSecret>;
|
||||
githubInfo?: Maybe<EchoProjectGithubInfo>;
|
||||
icon?: Maybe<EchoProjectIcon>;
|
||||
iconGallery: Array<EchoProjectIconGalleryItem>;
|
||||
id: Scalars['ID']['output'];
|
||||
images: Array<EchoProjectImage>;
|
||||
initFromEchoProject?: Maybe<EchoProject>;
|
||||
@@ -5156,11 +5270,27 @@ export type EchoProjectIcon = {
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type EchoProjectIconGalleryItem = {
|
||||
__typename?: 'EchoProjectIconGalleryItem';
|
||||
accentColor?: Maybe<Scalars['String']['output']>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
model?: Maybe<Scalars['String']['output']>;
|
||||
prompt?: Maybe<Scalars['String']['output']>;
|
||||
source: EchoProjectIconSource;
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type EchoProjectIconInput = {
|
||||
accentColor?: InputMaybe<Scalars['String']['input']>;
|
||||
url: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export enum EchoProjectIconSource {
|
||||
AiGenerated = 'AI_GENERATED',
|
||||
UserUploaded = 'USER_UPLOADED'
|
||||
}
|
||||
|
||||
export type EchoProjectImage = {
|
||||
__typename?: 'EchoProjectImage';
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
@@ -5172,8 +5302,17 @@ export type EchoProjectMutation = {
|
||||
__typename?: 'EchoProjectMutation';
|
||||
/** Create a new Echo project */
|
||||
createEchoProject: EchoProject;
|
||||
/**
|
||||
* Add one or more icons to the project's icon gallery.
|
||||
* Used for both AI-generated batches and user uploads.
|
||||
*/
|
||||
createEchoProjectIcons: Array<EchoProjectIconGalleryItem>;
|
||||
/** Delete an Echo project by ID */
|
||||
deleteEchoProject: EchoProject;
|
||||
/** Delete an icon from the gallery. If it is the active icon, clears the project's icon. */
|
||||
deleteEchoProjectIcon: EchoProjectIconGalleryItem;
|
||||
/** Select an icon from the gallery as the project's active icon. */
|
||||
selectEchoProjectIcon: EchoProject;
|
||||
/** Update an Echo project */
|
||||
updateEchoProject: EchoProject;
|
||||
};
|
||||
@@ -5184,11 +5323,29 @@ export type EchoProjectMutationCreateEchoProjectArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type EchoProjectMutationCreateEchoProjectIconsArgs = {
|
||||
echoProjectId: Scalars['ID']['input'];
|
||||
icons: Array<CreateEchoProjectIconInput>;
|
||||
};
|
||||
|
||||
|
||||
export type EchoProjectMutationDeleteEchoProjectArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type EchoProjectMutationDeleteEchoProjectIconArgs = {
|
||||
echoProjectIconId: Scalars['ID']['input'];
|
||||
echoProjectId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type EchoProjectMutationSelectEchoProjectIconArgs = {
|
||||
echoProjectIconId: Scalars['ID']['input'];
|
||||
echoProjectId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type EchoProjectMutationUpdateEchoProjectArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
input: UpdateEchoProjectInput;
|
||||
@@ -5284,6 +5441,7 @@ export type EchoRepositoryResult = {
|
||||
name: Scalars['String']['output'];
|
||||
nodeIdentifier: Scalars['String']['output'];
|
||||
owner: Scalars['String']['output'];
|
||||
status: Scalars['String']['output'];
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
@@ -5452,6 +5610,7 @@ export enum EntityTypeName {
|
||||
BillingContractEntity = 'BillingContractEntity',
|
||||
BranchEntity = 'BranchEntity',
|
||||
ChannelEntity = 'ChannelEntity',
|
||||
ConvexTeamConnectionEntity = 'ConvexTeamConnectionEntity',
|
||||
CustomerEntity = 'CustomerEntity',
|
||||
EchoProjectEntity = 'EchoProjectEntity',
|
||||
EchoVersionEntity = 'EchoVersionEntity',
|
||||
@@ -6354,6 +6513,8 @@ export type InvoicePeriod = {
|
||||
|
||||
export type InvoiceQuery = {
|
||||
__typename?: 'InvoiceQuery';
|
||||
/** Previews the invoice for the specified number of additional agent credit units. */
|
||||
previewInvoiceForAdditionalAgentCreditsCountUpdate?: Maybe<Invoice>;
|
||||
/**
|
||||
* Previews the invoice for the specified number of additional concurrencies.
|
||||
* This is the total number of concurrencies the customer wishes to purchase
|
||||
@@ -6367,6 +6528,12 @@ export type InvoiceQuery = {
|
||||
};
|
||||
|
||||
|
||||
export type InvoiceQueryPreviewInvoiceForAdditionalAgentCreditsCountUpdateArgs = {
|
||||
accountID: Scalars['ID']['input'];
|
||||
additionalAgentCreditsCount: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type InvoiceQueryPreviewInvoiceForAdditionalConcurrenciesCountUpdateArgs = {
|
||||
accountID: Scalars['ID']['input'];
|
||||
additionalConcurrenciesCount: Scalars['Int']['input'];
|
||||
@@ -7099,11 +7266,13 @@ export type OAuthIdentity = {
|
||||
__typename?: 'OAuthIdentity';
|
||||
email: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
login?: Maybe<Scalars['String']['output']>;
|
||||
provider: OAuthProvider;
|
||||
};
|
||||
|
||||
export enum OAuthProvider {
|
||||
Apple = 'apple',
|
||||
Github = 'github',
|
||||
Google = 'google'
|
||||
}
|
||||
|
||||
@@ -7529,6 +7698,8 @@ export type RootMutation = {
|
||||
deployments: DeploymentsMutation;
|
||||
/** Mutations that assign or modify DevDomainNames for apps */
|
||||
devDomainName: AppDevDomainNameMutation;
|
||||
/** Mutations that create and stop device run sessions */
|
||||
deviceRunSession: DeviceRunSessionMutation;
|
||||
/** Mutations for Discord users */
|
||||
discordUser: DiscordUserMutation;
|
||||
/** Mutations for Echo chats */
|
||||
@@ -7664,6 +7835,8 @@ export type RootQuery = {
|
||||
appStoreConnectApiKey: AppStoreConnectApiKeyQuery;
|
||||
/** Top-level query object for querying Apple Device registration requests. */
|
||||
appleDeviceRegistrationRequest: AppleDeviceRegistrationRequestQuery;
|
||||
/** Top-level query object for querying AppleDeviceRegistrationRequest publicly. */
|
||||
appleDeviceRegistrationRequestPublicData: AppleDeviceRegistrationRequestPublicDataQuery;
|
||||
/** Top-level query object for querying Apple distribution certificates. */
|
||||
appleDistributionCertificate?: Maybe<AppleDistributionCertificateQuery>;
|
||||
/** Top-level query object for querying Apple provisioning profiles. */
|
||||
@@ -7687,6 +7860,7 @@ export type RootQuery = {
|
||||
convexIntegration: ConvexIntegrationQuery;
|
||||
/** Top-level query object for querying Deployments. */
|
||||
deployments: DeploymentQuery;
|
||||
deviceRunSessions: DeviceRunSessionQuery;
|
||||
/** Top-level query object for querying Echo chats. */
|
||||
echoChat: EchoChatQuery;
|
||||
/** Top-level query object for querying Echo messages. */
|
||||
@@ -7953,6 +8127,7 @@ export type SsoUser = Actor & UserActor & {
|
||||
/** Associated accounts */
|
||||
primaryAccount: Account;
|
||||
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated Use primaryAccountProfileImageUrl instead */
|
||||
profilePhoto: Scalars['String']['output'];
|
||||
/** Snacks associated with this account */
|
||||
snacks: Array<Snack>;
|
||||
@@ -8042,6 +8217,10 @@ export type SecondFactorRegenerateBackupCodesResult = {
|
||||
plaintextBackupCodes: Array<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export type SendConvexTeamInviteToVerifiedEmailInput = {
|
||||
convexTeamConnectionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type SentryInstallation = {
|
||||
__typename?: 'SentryInstallation';
|
||||
account: Account;
|
||||
@@ -9202,6 +9381,7 @@ export type User = Actor & UserActor & {
|
||||
/** Associated accounts */
|
||||
primaryAccount: Account;
|
||||
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated Use primaryAccountProfileImageUrl instead */
|
||||
profilePhoto: Scalars['String']['output'];
|
||||
/** Get all certified second factor authentication methods */
|
||||
secondFactorDevices: Array<UserSecondFactorDevice>;
|
||||
@@ -9305,6 +9485,7 @@ export type UserActor = {
|
||||
/** Associated accounts */
|
||||
primaryAccount: Account;
|
||||
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** @deprecated Use primaryAccountProfileImageUrl instead */
|
||||
profilePhoto: Scalars['String']['output'];
|
||||
/** Snacks associated with this user's personal account */
|
||||
snacks: Array<Snack>;
|
||||
@@ -11566,6 +11747,13 @@ export type RetryIosBuildMutationVariables = Exact<{
|
||||
|
||||
export type RetryIosBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', retryIosBuild: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'PartnerActor', id: string, displayName: string } | { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
|
||||
|
||||
export type CreateDeviceRunSessionMutationVariables = Exact<{
|
||||
deviceRunSessionInput: CreateDeviceRunSessionInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateDeviceRunSessionMutation = { __typename?: 'RootMutation', deviceRunSession: { __typename?: 'DeviceRunSessionMutation', createDeviceRunSession: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, turtleJobRun?: { __typename?: 'JobRun', id: string } | null } } };
|
||||
|
||||
export type CreateEnvironmentSecretForAccountMutationVariables = Exact<{
|
||||
input: CreateEnvironmentSecretInput;
|
||||
accountId: Scalars['String']['input'];
|
||||
@@ -11971,6 +12159,16 @@ export type ViewBuildsOnAppQueryVariables = Exact<{
|
||||
|
||||
export type ViewBuildsOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, builds: Array<{ __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'PartnerActor', id: string, displayName: string } | { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null }> } } };
|
||||
|
||||
export type ViewChannelRuntimeInsightsOnAppQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
channelName: Scalars['String']['input'];
|
||||
runtimeVersion: Scalars['String']['input'];
|
||||
timespan: InsightsTimespan;
|
||||
}>;
|
||||
|
||||
|
||||
export type ViewChannelRuntimeInsightsOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateChannelByName?: { __typename?: 'UpdateChannel', id: string, name: string, runtimeInsights: { __typename?: 'UpdateChannelRuntimeInsights', id: string, embeddedUpdateTotalUniqueUsers: number, mostPopularUpdates: Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, runtimeVersion: string, platform: string, insights: { __typename?: 'UpdateInsights', id: string, totalUniqueUsers: number } }>, uniqueUsersOverTime: { __typename?: 'UniqueUsersOverTimeData', data: { __typename?: 'LineChartData', labels: Array<string>, datasets: Array<{ __typename?: 'LineDataset', id: string, label: string, data: Array<number | null> }> } }, cumulativeMetricsOverTime: { __typename?: 'ChannelRuntimeCumulativeMetricsOverTimeData', data: { __typename?: 'LineChartData', labels: Array<string>, datasets: Array<{ __typename?: 'LineDataset', id: string, label: string, data: Array<number | null> }> }, metricsAtLastTimestamp: Array<{ __typename?: 'LineDatapoint', id: string, label: string, data: number }> } } } | null } } };
|
||||
|
||||
export type ViewUpdateChannelBasicInfoOnAppQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
channelName: Scalars['String']['input'];
|
||||
@@ -12008,6 +12206,13 @@ export type ViewUpdateChannelsPaginatedOnAppQueryVariables = Exact<{
|
||||
|
||||
export type ViewUpdateChannelsPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, channelsPaginated: { __typename?: 'AppChannelsConnection', edges: Array<{ __typename?: 'AppChannelEdge', cursor: string, node: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
|
||||
|
||||
export type DeviceRunSessionByIdQueryVariables = Exact<{
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus, logFileUrls: Array<string> } | null } } };
|
||||
|
||||
export type EnvironmentSecretsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -12106,7 +12311,7 @@ export type AppObserveEventsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type AppObserveEventsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', events: { __typename?: 'AppObserveEventsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveEventEdge', cursor: string, node: { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string } }> } } } } };
|
||||
export type AppObserveEventsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', events: { __typename?: 'AppObserveEventsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveEventEdge', cursor: string, node: { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string, customParams?: any | null } }> } } } } };
|
||||
|
||||
export type GetAssetMetadataQueryVariables = Exact<{
|
||||
storageKeys: Array<Scalars['String']['input']> | Scalars['String']['input'];
|
||||
@@ -12160,6 +12365,14 @@ export type GetAllSubmissionsForAppQueryVariables = Exact<{
|
||||
|
||||
export type GetAllSubmissionsForAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logFiles: Array<string>, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: string, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }> } } };
|
||||
|
||||
export type ViewUpdateGroupInsightsQueryVariables = Exact<{
|
||||
groupId: Scalars['ID']['input'];
|
||||
timespan: InsightsTimespan;
|
||||
}>;
|
||||
|
||||
|
||||
export type ViewUpdateGroupInsightsQuery = { __typename?: 'RootQuery', updatesByGroup: Array<{ __typename?: 'Update', id: string, platform: string, insights: { __typename?: 'UpdateInsights', id: string, totalUniqueUsers: number, cumulativeAverageMetrics: { __typename?: 'CumulativeAverageMetrics', launchAssetCount: number, averageUpdatePayloadBytes: number }, cumulativeMetrics: { __typename?: 'CumulativeMetrics', metricsAtLastTimestamp: { __typename?: 'CumulativeMetricsTotals', totalInstalls: number, totalFailedInstalls: number }, data: { __typename?: 'UpdatesMetricsData', labels: Array<string>, installsDataset: { __typename?: 'CumulativeUpdatesDataset', id: string, label: string, cumulative: Array<number>, difference: Array<number> }, failedInstallsDataset: { __typename?: 'CumulativeUpdatesDataset', id: string, label: string, cumulative: Array<number>, difference: Array<number> } } } } }> };
|
||||
|
||||
export type ViewUpdatesByGroupQueryVariables = Exact<{
|
||||
groupId: Scalars['ID']['input'];
|
||||
}>;
|
||||
@@ -12290,7 +12503,7 @@ export type FingerprintFragment = { __typename?: 'Fingerprint', id: string, hash
|
||||
|
||||
export type AppObserveTimeSeriesFragment = { __typename?: 'AppObserveTimeSeries', eventCount: number, appVersionMarkers: Array<{ __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> }>, statistics: { __typename?: 'AppObserveTimeSeriesStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } };
|
||||
|
||||
export type AppObserveEventFragment = { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string };
|
||||
export type AppObserveEventFragment = { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string, customParams?: any | null };
|
||||
|
||||
export type AppObserveAppVersionFragment = { __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> };
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
CreateDeviceRunSessionInput,
|
||||
CreateDeviceRunSessionMutation,
|
||||
CreateDeviceRunSessionMutationVariables,
|
||||
} from '../generated';
|
||||
|
||||
export const DeviceRunSessionMutation = {
|
||||
async createDeviceRunSessionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionInput: CreateDeviceRunSessionInput
|
||||
): Promise<CreateDeviceRunSessionMutation['deviceRunSession']['createDeviceRunSession']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<CreateDeviceRunSessionMutation, CreateDeviceRunSessionMutationVariables>(
|
||||
gql`
|
||||
mutation CreateDeviceRunSessionMutation($deviceRunSessionInput: CreateDeviceRunSessionInput!) {
|
||||
deviceRunSession {
|
||||
createDeviceRunSession(deviceRunSessionInput: $deviceRunSessionInput) {
|
||||
id
|
||||
status
|
||||
app {
|
||||
id
|
||||
slug
|
||||
ownerAccount {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
turtleJobRun {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ deviceRunSessionInput },
|
||||
{ noRetry: true }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.deviceRunSession.createDeviceRunSession;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ChannelNotFoundError } from '../../channel/errors';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withUpgradeRequiredErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
ViewChannelRuntimeInsightsOnAppQuery,
|
||||
ViewChannelRuntimeInsightsOnAppQueryVariables,
|
||||
} from '../generated';
|
||||
|
||||
export type ChannelRuntimeInsights = NonNullable<
|
||||
ViewChannelRuntimeInsightsOnAppQuery['app']['byId']['updateChannelByName']
|
||||
>['runtimeInsights'];
|
||||
|
||||
export const ChannelInsightsQuery = {
|
||||
async viewChannelRuntimeInsightsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
channelName,
|
||||
runtimeVersion,
|
||||
startTime,
|
||||
endTime,
|
||||
}: {
|
||||
appId: string;
|
||||
channelName: string;
|
||||
runtimeVersion: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
): Promise<ChannelRuntimeInsights> {
|
||||
const data = await withUpgradeRequiredErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<ViewChannelRuntimeInsightsOnAppQuery, ViewChannelRuntimeInsightsOnAppQueryVariables>(
|
||||
gql`
|
||||
query ViewChannelRuntimeInsightsOnApp(
|
||||
$appId: String!
|
||||
$channelName: String!
|
||||
$runtimeVersion: String!
|
||||
$timespan: InsightsTimespan!
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateChannelByName(name: $channelName) {
|
||||
id
|
||||
name
|
||||
runtimeInsights {
|
||||
id
|
||||
embeddedUpdateTotalUniqueUsers(runtimeVersion: $runtimeVersion, timespan: $timespan)
|
||||
mostPopularUpdates(runtimeVersion: $runtimeVersion, timespan: $timespan) {
|
||||
id
|
||||
group
|
||||
message
|
||||
runtimeVersion
|
||||
platform
|
||||
insights {
|
||||
id
|
||||
totalUniqueUsers(timespan: $timespan)
|
||||
}
|
||||
}
|
||||
uniqueUsersOverTime(runtimeVersion: $runtimeVersion, timespan: $timespan) {
|
||||
data {
|
||||
labels
|
||||
datasets {
|
||||
id
|
||||
label
|
||||
data
|
||||
}
|
||||
}
|
||||
}
|
||||
cumulativeMetricsOverTime(runtimeVersion: $runtimeVersion, timespan: $timespan) {
|
||||
data {
|
||||
labels
|
||||
datasets {
|
||||
id
|
||||
label
|
||||
data
|
||||
}
|
||||
}
|
||||
metricsAtLastTimestamp {
|
||||
id
|
||||
label
|
||||
data
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
channelName,
|
||||
runtimeVersion,
|
||||
timespan: { start: startTime, end: endTime },
|
||||
},
|
||||
{ additionalTypenames: ['UpdateChannel', 'Update', 'UpdateChannelRuntimeInsights'] }
|
||||
)
|
||||
.toPromise(),
|
||||
{ featureName: 'EAS Update channel insights' }
|
||||
);
|
||||
|
||||
const updateChannel = data.app.byId.updateChannelByName;
|
||||
if (!updateChannel) {
|
||||
throw new ChannelNotFoundError(`Could not find channel with the name ${channelName}`);
|
||||
}
|
||||
|
||||
return updateChannel.runtimeInsights;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import { DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables } from '../generated';
|
||||
|
||||
export const DeviceRunSessionQuery = {
|
||||
async byIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
deviceRunSessionId: string
|
||||
): Promise<DeviceRunSessionByIdQuery['deviceRunSessions']['byId']> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables>(
|
||||
gql`
|
||||
query DeviceRunSessionByIdQuery($deviceRunSessionId: ID!) {
|
||||
deviceRunSessions {
|
||||
byId(deviceRunSessionId: $deviceRunSessionId) {
|
||||
id
|
||||
status
|
||||
turtleJobRun {
|
||||
id
|
||||
status
|
||||
logFileUrls
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ deviceRunSessionId },
|
||||
{ requestPolicy: 'network-only' }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.deviceRunSessions.byId;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withUpgradeRequiredErrorHandlingAsync } from '../client';
|
||||
import { ViewUpdateGroupInsightsQuery, ViewUpdateGroupInsightsQueryVariables } from '../generated';
|
||||
|
||||
export type UpdateWithInsightsObject = ViewUpdateGroupInsightsQuery['updatesByGroup'][number];
|
||||
|
||||
export const UpdateInsightsQuery = {
|
||||
async viewUpdateGroupInsightsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ groupId, startTime, endTime }: { groupId: string; startTime: string; endTime: string }
|
||||
): Promise<UpdateWithInsightsObject[]> {
|
||||
const data = await withUpgradeRequiredErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<ViewUpdateGroupInsightsQuery, ViewUpdateGroupInsightsQueryVariables>(
|
||||
gql`
|
||||
query ViewUpdateGroupInsights($groupId: ID!, $timespan: InsightsTimespan!) {
|
||||
updatesByGroup(group: $groupId) {
|
||||
id
|
||||
platform
|
||||
insights {
|
||||
id
|
||||
totalUniqueUsers(timespan: $timespan)
|
||||
cumulativeAverageMetrics {
|
||||
launchAssetCount
|
||||
averageUpdatePayloadBytes
|
||||
}
|
||||
cumulativeMetrics(timespan: $timespan) {
|
||||
metricsAtLastTimestamp {
|
||||
totalInstalls
|
||||
totalFailedInstalls
|
||||
}
|
||||
data {
|
||||
labels
|
||||
installsDataset {
|
||||
id
|
||||
label
|
||||
cumulative
|
||||
difference
|
||||
}
|
||||
failedInstallsDataset {
|
||||
id
|
||||
label
|
||||
cumulative
|
||||
difference
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ groupId, timespan: { start: startTime, end: endTime } },
|
||||
{ additionalTypenames: ['Update', 'UpdateInsights'] }
|
||||
)
|
||||
.toPromise(),
|
||||
{ featureName: 'EAS Update insights' }
|
||||
);
|
||||
|
||||
if (data.updatesByGroup.length === 0) {
|
||||
throw new Error(`Could not find any updates with group ID: "${groupId}"`);
|
||||
}
|
||||
|
||||
return data.updatesByGroup;
|
||||
},
|
||||
};
|
||||
@@ -33,6 +33,7 @@ export const AppObserveEventFragmentNode = gql`
|
||||
countryCode
|
||||
sessionId
|
||||
easClientId
|
||||
customParams
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import dateFormat from 'dateformat';
|
||||
|
||||
export interface InsightsTimespanFields {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
daysBack?: number;
|
||||
}
|
||||
|
||||
export function formatTimespan(timespan: InsightsTimespanFields): string {
|
||||
if (timespan.daysBack) {
|
||||
return `last ${timespan.daysBack} day${timespan.daysBack === 1 ? '' : 's'}`;
|
||||
}
|
||||
return `${toDateOnly(timespan.startTime)} to ${toDateOnly(timespan.endTime)}`;
|
||||
}
|
||||
|
||||
export function toDateOnly(isoTimestamp: string): string {
|
||||
return dateFormat(new Date(isoTimestamp), 'UTC:yyyy-mm-dd');
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { resolveTimeRange } from '../observe/startAndEndTime';
|
||||
|
||||
export const INSIGHTS_DEFAULT_DAYS_BACK = 7;
|
||||
|
||||
export function resolveInsightsTimeRange(flags: { days?: number; start?: string; end?: string }): {
|
||||
daysBack?: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
} {
|
||||
const days = flags.days ?? (flags.start ? undefined : INSIGHTS_DEFAULT_DAYS_BACK);
|
||||
return resolveTimeRange({ ...flags, days });
|
||||
}
|
||||
@@ -11,9 +11,18 @@ import { AppleConfigReader } from '../../config/reader';
|
||||
import { AppleConfigWriter } from '../../config/writer';
|
||||
import { PartialAppleData } from '../../data';
|
||||
import { PreviewsTask } from '../previews';
|
||||
import Log from '../../../../log';
|
||||
|
||||
jest.mock('../../../../ora');
|
||||
jest.mock('../../config/writer');
|
||||
jest.mock('../../../../log', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
log: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockFetch = jest.fn();
|
||||
jest.mock('../../../../fetch', () => ({
|
||||
@@ -377,6 +386,71 @@ describe(PreviewsTask, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and skips when preview type uses screenshot display type (APP_ prefix)', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
previews: {
|
||||
APP_IPHONE_67: './previews/demo.mp4',
|
||||
} as any,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', new Map());
|
||||
|
||||
await new PreviewsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
previewSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('APP_IPHONE_67'));
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('IPHONE_67'));
|
||||
});
|
||||
|
||||
it('warns and skips completely unknown preview types', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
'en-US': {
|
||||
title: 'My App',
|
||||
previews: {
|
||||
FAKE_DEVICE: './previews/demo.mp4',
|
||||
} as any,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
|
||||
locale: 'en-US',
|
||||
} as any);
|
||||
|
||||
const previewSets = new Map();
|
||||
previewSets.set('en-US', new Map());
|
||||
|
||||
await new PreviewsTask().uploadAsync({
|
||||
config,
|
||||
context: {
|
||||
previewSets,
|
||||
versionLocales: [locale],
|
||||
projectDir: '/test/project',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('FAKE_DEVICE'));
|
||||
// Should not suggest stripping APP_ prefix for non-APP_ types
|
||||
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Did you mean'));
|
||||
});
|
||||
|
||||
it('uploads new preview with object config including previewFrameTimeCode', async () => {
|
||||
const config = new AppleConfigReader({
|
||||
info: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ALL_PREVIEW_TYPES,
|
||||
AppPreview,
|
||||
AppPreviewSet,
|
||||
AppStoreVersionLocalization,
|
||||
@@ -151,6 +152,18 @@ export class PreviewsTask extends AppleTask {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ALL_PREVIEW_TYPES.includes(previewType as PreviewType)) {
|
||||
const strippedType = previewType.replace(/^APP_/, '');
|
||||
const suggestion = ALL_PREVIEW_TYPES.includes(strippedType as PreviewType)
|
||||
? chalk` Did you mean {bold ${strippedType}}? Preview types don't use the "APP_" prefix (that's only for screenshots).`
|
||||
: '';
|
||||
Log.warn(
|
||||
chalk`{yellow Unknown preview type {bold ${previewType}} for ${localeCode}, skipping.${suggestion}}`
|
||||
);
|
||||
Log.warn(chalk`{yellow Valid preview types: ${ALL_PREVIEW_TYPES.join(', ')}}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await syncPreviewSetAsync(
|
||||
context.projectDir,
|
||||
localization,
|
||||
@@ -161,7 +174,7 @@ export class PreviewsTask extends AppleTask {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete remote previews that are no longer in config
|
||||
// Delete remote previews for types no longer in config
|
||||
if (existingSets) {
|
||||
for (const [previewType, previewSet] of existingSets) {
|
||||
if (previews?.[previewType]) {
|
||||
@@ -250,15 +263,15 @@ async function syncPreviewSetAsync(
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete existing previews that don't match
|
||||
// Delete all existing previews before uploading the new one.
|
||||
// Apple limits each set to 3 previews, and we manage one preview per set,
|
||||
// so we need to clean up stale entries to avoid "Too many app previews" errors.
|
||||
for (const preview of existingPreviews) {
|
||||
if (preview.attributes.fileName !== fileName) {
|
||||
await logAsync(() => preview.deleteAsync(), {
|
||||
pending: `Deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})...`,
|
||||
success: `Deleted old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
|
||||
failure: `Failed deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
|
||||
});
|
||||
}
|
||||
await logAsync(() => preview.deleteAsync(), {
|
||||
pending: `Deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})...`,
|
||||
success: `Deleted old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
|
||||
failure: `Failed deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
|
||||
});
|
||||
}
|
||||
|
||||
// Upload new preview
|
||||
|
||||
@@ -158,6 +158,7 @@ describe(buildObserveEventsJson, () => {
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
customParams: null,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface ObserveEventJson {
|
||||
sessionId: string | null;
|
||||
easClientId: string;
|
||||
timestamp: string;
|
||||
customParams: { [key: string]: any } | null;
|
||||
}
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
@@ -39,6 +40,10 @@ function formatDate(isoString: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCustomParams(event: AppObserveEvent): { [key: string]: any } | null {
|
||||
return event.customParams ?? null;
|
||||
}
|
||||
|
||||
export interface BuildEventsTableOptions {
|
||||
metricName: string;
|
||||
daysBack?: number;
|
||||
@@ -131,6 +136,7 @@ export function buildObserveEventsJson(
|
||||
sessionId: event.sessionId ?? null,
|
||||
easClientId: event.easClientId,
|
||||
timestamp: event.timestamp,
|
||||
customParams: resolveCustomParams(event),
|
||||
})),
|
||||
pageInfo: {
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
|
||||
@@ -3,6 +3,7 @@ import chalk from 'chalk';
|
||||
import { EasCommandError } from '../commandUtils/errors';
|
||||
import { AppPlatform } from '../graphql/generated';
|
||||
import { appPlatformDisplayNames } from '../platform';
|
||||
import renderTextTable from '../utils/renderTextTable';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
|
||||
export type StatisticKey =
|
||||
@@ -175,22 +176,6 @@ function buildTimeRangeDescription(daysBack?: number): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderTable(headers: string[], rows: string[][], footerRow?: string[]): string {
|
||||
const allRows = footerRow ? [...rows, footerRow] : rows;
|
||||
const colWidths = headers.map((h, i) =>
|
||||
Math.max(h.length, ...allRows.map(r => (r[i] ?? '').length))
|
||||
);
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
const lines = [chalk.bold(headerLine), separatorLine, ...dataLines];
|
||||
if (footerRow) {
|
||||
lines.push(separatorLine);
|
||||
lines.push(footerRow.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveMetricsTable(
|
||||
metricsMap: ObserveMetricsMap,
|
||||
metricNames: string[],
|
||||
@@ -290,7 +275,7 @@ export function buildObserveMetricsTable(
|
||||
}
|
||||
}
|
||||
|
||||
sections.push(renderTable(headers, rows, footerRow));
|
||||
sections.push(renderTextTable(headers, rows, footerRow));
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AppObserveAppUpdate,
|
||||
} from '../graphql/generated';
|
||||
import { appPlatformDisplayNames } from '../platform';
|
||||
import renderTextTable from '../utils/renderTextTable';
|
||||
import { AppVersionsResult } from './fetchVersions';
|
||||
|
||||
function formatDate(isoString: string): string {
|
||||
@@ -97,14 +98,6 @@ export function buildObserveVersionsJson(results: AppVersionsResult[]): AppVersi
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderTable(headers: string[], rows: string[][]): string {
|
||||
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
|
||||
return [chalk.bold(headerLine), separatorLine, ...dataLines].join('\n');
|
||||
}
|
||||
|
||||
export function buildObserveVersionsTable(results: AppVersionsResult[]): string {
|
||||
const hasAnyVersions = results.some(r => r.appVersions.length > 0);
|
||||
|
||||
@@ -135,7 +128,7 @@ export function buildObserveVersionsTable(results: AppVersionsResult[]): string
|
||||
String(version.updates.length),
|
||||
]);
|
||||
|
||||
sections.push(renderTable(headers, rows));
|
||||
sections.push(renderTextTable(headers, rows));
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { UpdateWithInsightsObject } from '../../../graphql/queries/UpdateInsightsQuery';
|
||||
import {
|
||||
buildUpdateInsightsJson,
|
||||
buildUpdateInsightsTable,
|
||||
formatBytes,
|
||||
formatPercent,
|
||||
toUpdateInsightsSummary,
|
||||
} from '../formatInsights';
|
||||
|
||||
function makeUpdate(
|
||||
platform: string,
|
||||
overrides: {
|
||||
updateId?: string;
|
||||
totalInstalls?: number;
|
||||
totalFailedInstalls?: number;
|
||||
totalUniqueUsers?: number;
|
||||
launchAssetCount?: number;
|
||||
averageUpdatePayloadBytes?: number;
|
||||
labels?: string[];
|
||||
installsDifference?: number[];
|
||||
failedInstallsDifference?: number[];
|
||||
} = {}
|
||||
): UpdateWithInsightsObject {
|
||||
const labels = overrides.labels ?? ['2026-04-09', '2026-04-10'];
|
||||
const installsDifference = overrides.installsDifference ?? [10, 20];
|
||||
const failedInstallsDifference = overrides.failedInstallsDifference ?? [1, 2];
|
||||
return {
|
||||
__typename: 'Update',
|
||||
id: overrides.updateId ?? `${platform}-update`,
|
||||
platform,
|
||||
insights: {
|
||||
__typename: 'UpdateInsights',
|
||||
id: `${platform}-insights`,
|
||||
totalUniqueUsers: overrides.totalUniqueUsers ?? 100,
|
||||
cumulativeAverageMetrics: {
|
||||
__typename: 'CumulativeAverageMetrics',
|
||||
launchAssetCount: overrides.launchAssetCount ?? 3,
|
||||
averageUpdatePayloadBytes: overrides.averageUpdatePayloadBytes ?? 5_000_000,
|
||||
},
|
||||
cumulativeMetrics: {
|
||||
__typename: 'CumulativeMetrics',
|
||||
metricsAtLastTimestamp: {
|
||||
__typename: 'CumulativeMetricsTotals',
|
||||
totalInstalls: overrides.totalInstalls ?? 1000,
|
||||
totalFailedInstalls: overrides.totalFailedInstalls ?? 10,
|
||||
},
|
||||
data: {
|
||||
__typename: 'UpdatesMetricsData',
|
||||
labels,
|
||||
installsDataset: {
|
||||
__typename: 'CumulativeUpdatesDataset',
|
||||
id: `${platform}-installs`,
|
||||
label: 'Installs',
|
||||
cumulative: installsDifference.map((_, i) =>
|
||||
installsDifference.slice(0, i + 1).reduce((a, b) => a + b, 0)
|
||||
),
|
||||
difference: installsDifference,
|
||||
},
|
||||
failedInstallsDataset: {
|
||||
__typename: 'CumulativeUpdatesDataset',
|
||||
id: `${platform}-failed`,
|
||||
label: 'Failed installs',
|
||||
cumulative: failedInstallsDifference.map((_, i) =>
|
||||
failedInstallsDifference.slice(0, i + 1).reduce((a, b) => a + b, 0)
|
||||
),
|
||||
difference: failedInstallsDifference,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const TIMESPAN = {
|
||||
startTime: '2026-04-09T00:00:00.000Z',
|
||||
endTime: '2026-04-16T00:00:00.000Z',
|
||||
daysBack: 7,
|
||||
};
|
||||
|
||||
describe(toUpdateInsightsSummary, () => {
|
||||
it('sorts platforms alphabetically', () => {
|
||||
const summary = toUpdateInsightsSummary(
|
||||
'group-1',
|
||||
[makeUpdate('ios'), makeUpdate('android')],
|
||||
TIMESPAN
|
||||
);
|
||||
expect(summary.platforms.map(p => p.platform)).toEqual(['android', 'ios']);
|
||||
});
|
||||
|
||||
it('computes crash rate per platform', () => {
|
||||
const summary = toUpdateInsightsSummary(
|
||||
'group-1',
|
||||
[
|
||||
makeUpdate('android', { totalInstalls: 100, totalFailedInstalls: 5 }),
|
||||
makeUpdate('ios', { totalInstalls: 990, totalFailedInstalls: 10 }),
|
||||
],
|
||||
TIMESPAN
|
||||
);
|
||||
expect(summary.platforms[0].crashRatePercent).toBeCloseTo((5 / 105) * 100, 5);
|
||||
expect(summary.platforms[1].crashRatePercent).toBeCloseTo((10 / 1000) * 100, 5);
|
||||
});
|
||||
|
||||
it('returns 0 crash rate when both install counts are zero', () => {
|
||||
const summary = toUpdateInsightsSummary(
|
||||
'group-1',
|
||||
[
|
||||
makeUpdate('android', {
|
||||
totalInstalls: 0,
|
||||
totalFailedInstalls: 0,
|
||||
installsDifference: [],
|
||||
failedInstallsDifference: [],
|
||||
labels: [],
|
||||
}),
|
||||
],
|
||||
TIMESPAN
|
||||
);
|
||||
expect(summary.platforms[0].crashRatePercent).toBe(0);
|
||||
});
|
||||
|
||||
it('maps daily breakdown per platform from labels and difference datasets', () => {
|
||||
const summary = toUpdateInsightsSummary(
|
||||
'group-1',
|
||||
[
|
||||
makeUpdate('ios', {
|
||||
labels: ['2026-04-09', '2026-04-10'],
|
||||
installsDifference: [50, 75],
|
||||
failedInstallsDifference: [1, 3],
|
||||
}),
|
||||
],
|
||||
TIMESPAN
|
||||
);
|
||||
expect(summary.platforms[0].daily).toEqual([
|
||||
{ date: '2026-04-09', installs: 50, failedInstalls: 1 },
|
||||
{ date: '2026-04-10', installs: 75, failedInstalls: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('captures platform-specific updateId', () => {
|
||||
const summary = toUpdateInsightsSummary(
|
||||
'group-1',
|
||||
[
|
||||
makeUpdate('android', { updateId: 'android-id' }),
|
||||
makeUpdate('ios', { updateId: 'ios-id' }),
|
||||
],
|
||||
TIMESPAN
|
||||
);
|
||||
expect(summary.platforms.find(p => p.platform === 'android')?.updateId).toBe('android-id');
|
||||
expect(summary.platforms.find(p => p.platform === 'ios')?.updateId).toBe('ios-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildUpdateInsightsJson, () => {
|
||||
it('returns a serializable object with per-platform totals, payload, and daily', () => {
|
||||
const summary = toUpdateInsightsSummary(
|
||||
'group-1',
|
||||
[makeUpdate('android'), makeUpdate('ios', { totalInstalls: 500, totalFailedInstalls: 5 })],
|
||||
TIMESPAN
|
||||
);
|
||||
const json = buildUpdateInsightsJson(summary) as any;
|
||||
expect(json.groupId).toBe('group-1');
|
||||
expect(json.timespan.daysBack).toBe(7);
|
||||
expect(json.platforms).toHaveLength(2);
|
||||
expect(json.platforms[0].platform).toBe('android');
|
||||
expect(json.platforms[1].platform).toBe('ios');
|
||||
expect(json.platforms[1].totals.installs).toBe(500);
|
||||
expect(json.platforms[0].daily).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildUpdateInsightsTable, () => {
|
||||
it('renders per-platform sections with totals, crash rate and daily breakdown', () => {
|
||||
const summary = toUpdateInsightsSummary(
|
||||
'group-1',
|
||||
[makeUpdate('android'), makeUpdate('ios')],
|
||||
TIMESPAN
|
||||
);
|
||||
const table = buildUpdateInsightsTable(summary);
|
||||
expect(table).toMatch(/group-1/);
|
||||
expect(table).toMatch(/Platforms/);
|
||||
expect(table).toMatch(/android, ios/);
|
||||
expect(table).toMatch(/\bandroid\b/);
|
||||
expect(table).toMatch(/\bios\b/);
|
||||
expect(table).toMatch(/Launches/);
|
||||
expect(table).toMatch(/Crash rate/);
|
||||
expect(table).toMatch(/Daily breakdown/);
|
||||
expect(table).toMatch(/2026-04-09/);
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatBytes, () => {
|
||||
it('formats bytes', () => {
|
||||
expect(formatBytes(512)).toBe('512 B');
|
||||
});
|
||||
it('formats kilobytes', () => {
|
||||
expect(formatBytes(2048)).toBe('2.0 KB');
|
||||
});
|
||||
it('formats megabytes', () => {
|
||||
expect(formatBytes(5_242_880)).toBe('5.00 MB');
|
||||
});
|
||||
});
|
||||
|
||||
describe(formatPercent, () => {
|
||||
it('formats with two decimal places', () => {
|
||||
expect(formatPercent(12.3456)).toBe('12.35%');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { UpdateWithInsightsObject } from '../../graphql/queries/UpdateInsightsQuery';
|
||||
import { formatTimespan, toDateOnly } from '../../insights/formatTimespan';
|
||||
import formatFields from '../../utils/formatFields';
|
||||
import renderTextTable from '../../utils/renderTextTable';
|
||||
|
||||
export interface UpdateInsightsTimespan {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
daysBack?: number;
|
||||
}
|
||||
|
||||
export interface UpdateInsightsDailyEntry {
|
||||
date: string;
|
||||
installs: number;
|
||||
failedInstalls: number;
|
||||
}
|
||||
|
||||
export interface UpdateInsightsPlatformSummary {
|
||||
platform: string;
|
||||
updateId: string;
|
||||
totalUniqueUsers: number;
|
||||
totalInstalls: number;
|
||||
totalFailedInstalls: number;
|
||||
crashRatePercent: number;
|
||||
launchAssetCount: number;
|
||||
averageUpdatePayloadBytes: number;
|
||||
daily: UpdateInsightsDailyEntry[];
|
||||
}
|
||||
|
||||
export interface UpdateInsightsSummary {
|
||||
groupId: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
daysBack?: number;
|
||||
platforms: UpdateInsightsPlatformSummary[];
|
||||
}
|
||||
|
||||
export function toUpdateInsightsSummary(
|
||||
groupId: string,
|
||||
updates: UpdateWithInsightsObject[],
|
||||
timespan: UpdateInsightsTimespan
|
||||
): UpdateInsightsSummary {
|
||||
const platforms = updates
|
||||
.map(toPlatformSummary)
|
||||
.sort((a, b) => a.platform.localeCompare(b.platform));
|
||||
|
||||
return {
|
||||
groupId,
|
||||
startTime: timespan.startTime,
|
||||
endTime: timespan.endTime,
|
||||
daysBack: timespan.daysBack,
|
||||
platforms,
|
||||
};
|
||||
}
|
||||
|
||||
function toPlatformSummary(update: UpdateWithInsightsObject): UpdateInsightsPlatformSummary {
|
||||
const { insights } = update;
|
||||
const { totalInstalls, totalFailedInstalls } = insights.cumulativeMetrics.metricsAtLastTimestamp;
|
||||
const denom = totalInstalls + totalFailedInstalls;
|
||||
const crashRatePercent = denom === 0 ? 0 : (totalFailedInstalls / denom) * 100;
|
||||
|
||||
const { labels, installsDataset, failedInstallsDataset } = insights.cumulativeMetrics.data;
|
||||
const daily: UpdateInsightsDailyEntry[] = labels.map((date, i) => ({
|
||||
date,
|
||||
installs: installsDataset.difference[i] ?? 0,
|
||||
failedInstalls: failedInstallsDataset.difference[i] ?? 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
platform: update.platform,
|
||||
updateId: update.id,
|
||||
totalUniqueUsers: insights.totalUniqueUsers,
|
||||
totalInstalls,
|
||||
totalFailedInstalls,
|
||||
crashRatePercent,
|
||||
launchAssetCount: insights.cumulativeAverageMetrics.launchAssetCount,
|
||||
averageUpdatePayloadBytes: insights.cumulativeAverageMetrics.averageUpdatePayloadBytes,
|
||||
daily,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUpdateInsightsJson(summary: UpdateInsightsSummary): object {
|
||||
return {
|
||||
groupId: summary.groupId,
|
||||
timespan: {
|
||||
start: summary.startTime,
|
||||
end: summary.endTime,
|
||||
...(summary.daysBack !== undefined ? { daysBack: summary.daysBack } : {}),
|
||||
},
|
||||
platforms: summary.platforms.map(p => ({
|
||||
platform: p.platform,
|
||||
updateId: p.updateId,
|
||||
totals: {
|
||||
uniqueUsers: p.totalUniqueUsers,
|
||||
installs: p.totalInstalls,
|
||||
failedInstalls: p.totalFailedInstalls,
|
||||
crashRatePercent: p.crashRatePercent,
|
||||
},
|
||||
payload: {
|
||||
launchAssetCount: p.launchAssetCount,
|
||||
averageUpdatePayloadBytes: p.averageUpdatePayloadBytes,
|
||||
},
|
||||
daily: p.daily,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUpdateInsightsTable(summary: UpdateInsightsSummary): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
sections.push(chalk.bold('Update group insights:'));
|
||||
sections.push(
|
||||
formatFields([
|
||||
{ label: 'Group ID', value: summary.groupId },
|
||||
{ label: 'Time range', value: formatTimespan(summary) },
|
||||
{ label: 'Platforms', value: summary.platforms.map(p => p.platform).join(', ') || 'N/A' },
|
||||
])
|
||||
);
|
||||
|
||||
const dailyHeader = summary.daysBack ? ` (last ${summary.daysBack} days)` : '';
|
||||
|
||||
for (const platform of summary.platforms) {
|
||||
sections.push('');
|
||||
sections.push(chalk.bold(`${chalk.cyan(platform.platform)}:`));
|
||||
sections.push(
|
||||
formatFields([
|
||||
{ label: 'Update ID', value: platform.updateId },
|
||||
{ label: 'Launches', value: platform.totalInstalls.toLocaleString() },
|
||||
{ label: 'Failed launches', value: platform.totalFailedInstalls.toLocaleString() },
|
||||
{ label: 'Crash rate', value: formatPercent(platform.crashRatePercent) },
|
||||
{ label: 'Unique users', value: platform.totalUniqueUsers.toLocaleString() },
|
||||
{ label: 'Launch assets', value: platform.launchAssetCount.toLocaleString() },
|
||||
{ label: 'Avg payload size', value: formatBytes(platform.averageUpdatePayloadBytes) },
|
||||
])
|
||||
);
|
||||
if (platform.daily.length > 0) {
|
||||
sections.push('');
|
||||
sections.push(chalk.bold(` Daily breakdown${dailyHeader}:`));
|
||||
sections.push('');
|
||||
sections.push(indent(renderDailyTable(platform.daily), 2));
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
export function formatPercent(value: number): string {
|
||||
return `${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function renderDailyTable(rows: UpdateInsightsDailyEntry[]): string {
|
||||
return renderTextTable(
|
||||
['Date', 'Launches', 'Crashes'],
|
||||
rows.map(r => [
|
||||
toDateOnly(r.date),
|
||||
r.installs.toLocaleString(),
|
||||
r.failedInstalls.toLocaleString(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function indent(text: string, spaces: number): string {
|
||||
const pad = ' '.repeat(spaces);
|
||||
return text
|
||||
.split('\n')
|
||||
.map(line => (line.length > 0 ? pad + line : line))
|
||||
.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
/**
|
||||
* Render a simple column-aligned text table with a bold header row, a dashed
|
||||
* separator, the data rows, and an optional footer row (preceded by its own
|
||||
* separator). Columns are padded to the widest cell in that column.
|
||||
*/
|
||||
export default function renderTextTable(
|
||||
headers: string[],
|
||||
rows: string[][],
|
||||
footerRow?: string[]
|
||||
): string {
|
||||
const allRows = footerRow ? [...rows, footerRow] : rows;
|
||||
const colWidths = headers.map((h, i) =>
|
||||
Math.max(h.length, ...allRows.map(r => (r[i] ?? '').length))
|
||||
);
|
||||
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
|
||||
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
|
||||
const dataLines = rows.map(row =>
|
||||
row.map((cell, i) => (cell ?? '').padEnd(colWidths[i])).join(' ')
|
||||
);
|
||||
const lines = [chalk.bold(headerLine), separatorLine, ...dataLines];
|
||||
if (footerRow) {
|
||||
lines.push(separatorLine);
|
||||
lines.push(footerRow.map((cell, i) => (cell ?? '').padEnd(colWidths[i])).join(' '));
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-json",
|
||||
"version": "18.6.0",
|
||||
"version": "18.8.0",
|
||||
"description": "A library for interacting with eas.json",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "7.23.5",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/eas-build-job": "18.8.0",
|
||||
"chalk": "4.1.2",
|
||||
"env-string": "1.0.1",
|
||||
"fs-extra": "11.2.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "18.7.0",
|
||||
"version": "18.8.1",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,8 +26,8 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "18.7.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/build-tools": "18.8.1",
|
||||
"@expo/eas-build-job": "18.8.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/steps",
|
||||
"version": "18.6.0",
|
||||
"version": "18.8.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -27,7 +27,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/eas-build-job": "18.8.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"arg": "^5.0.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "18.7.0",
|
||||
"version": "18.8.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,16 +19,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "18.7.0",
|
||||
"@expo/build-tools": "18.8.1",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "18.5.0",
|
||||
"@expo/eas-build-job": "18.6.0",
|
||||
"@expo/eas-build-job": "18.8.0",
|
||||
"@expo/logger": "18.5.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "18.6.0",
|
||||
"@expo/steps": "18.8.0",
|
||||
"@expo/template-file": "18.5.0",
|
||||
"@expo/turtle-spawn": "18.5.0",
|
||||
"@hapi/boom": "10.0.1",
|
||||
|
||||
@@ -2856,14 +2856,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/build-tools@npm:18.7.0, @expo/build-tools@workspace:packages/build-tools":
|
||||
"@expo/build-tools@npm:18.8.1, @expo/build-tools@workspace:packages/build-tools":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/build-tools@workspace:packages/build-tools"
|
||||
dependencies:
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/downloader": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/eas-build-job": "npm:18.8.0"
|
||||
"@expo/env": "npm:^0.4.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
@@ -2871,7 +2871,7 @@ __metadata:
|
||||
"@expo/repack-app": "npm:~0.4.1"
|
||||
"@expo/results": "npm:^1.0.0"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:18.6.0"
|
||||
"@expo/steps": "npm:18.8.0"
|
||||
"@expo/template-file": "npm:18.5.0"
|
||||
"@expo/turtle-spawn": "npm:18.5.0"
|
||||
"@expo/xcpretty": "npm:^4.3.1"
|
||||
@@ -3111,7 +3111,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-build-job@npm:18.6.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
"@expo/eas-build-job@npm:18.8.0, @expo/eas-build-job@workspace:packages/eas-build-job":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
|
||||
dependencies:
|
||||
@@ -3128,12 +3128,12 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/eas-json@npm:18.6.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
"@expo/eas-json@npm:18.8.0, @expo/eas-json@workspace:packages/eas-json":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/eas-json@workspace:packages/eas-json"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.23.5"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/eas-build-job": "npm:18.8.0"
|
||||
"@tsconfig/node20": "npm:20.1.8"
|
||||
"@types/babel__code-frame": "npm:7.0.3"
|
||||
"@types/fs-extra": "npm:11.0.4"
|
||||
@@ -3481,11 +3481,11 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/steps@npm:18.6.0, @expo/steps@workspace:packages/steps":
|
||||
"@expo/steps@npm:18.8.0, @expo/steps@workspace:packages/steps":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/steps@workspace:packages/steps"
|
||||
dependencies:
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/eas-build-job": "npm:18.8.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
@@ -3554,16 +3554,16 @@ __metadata:
|
||||
resolution: "@expo/worker@workspace:packages/worker"
|
||||
dependencies:
|
||||
"@babel/core": "npm:7.24.7"
|
||||
"@expo/build-tools": "npm:18.7.0"
|
||||
"@expo/build-tools": "npm:18.8.1"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/config-types": "npm:55.0.5"
|
||||
"@expo/downloader": "npm:18.5.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/eas-build-job": "npm:18.8.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:18.6.0"
|
||||
"@expo/steps": "npm:18.8.0"
|
||||
"@expo/template-file": "npm:18.5.0"
|
||||
"@expo/turtle-spawn": "npm:18.5.0"
|
||||
"@hapi/boom": "npm:10.0.1"
|
||||
@@ -10331,7 +10331,7 @@ __metadata:
|
||||
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
|
||||
dependencies:
|
||||
"@expo/package-manager": "npm:1.9.10"
|
||||
"@expo/steps": "npm:18.6.0"
|
||||
"@expo/steps": "npm:18.8.0"
|
||||
"@jest/globals": "npm:^29.7.0"
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
@@ -10944,8 +10944,8 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
|
||||
dependencies:
|
||||
"@expo/build-tools": "npm:18.7.0"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/build-tools": "npm:18.8.1"
|
||||
"@expo/eas-build-job": "npm:18.8.0"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/spawn-async": "npm:^1.7.2"
|
||||
"@expo/turtle-spawn": "npm:18.5.0"
|
||||
@@ -11002,8 +11002,8 @@ __metadata:
|
||||
"@expo/code-signing-certificates": "npm:0.0.5"
|
||||
"@expo/config": "npm:55.0.10"
|
||||
"@expo/config-plugins": "npm:55.0.7"
|
||||
"@expo/eas-build-job": "npm:18.6.0"
|
||||
"@expo/eas-json": "npm:18.6.0"
|
||||
"@expo/eas-build-job": "npm:18.8.0"
|
||||
"@expo/eas-json": "npm:18.8.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
@@ -11018,7 +11018,7 @@ __metadata:
|
||||
"@expo/results": "npm:1.0.0"
|
||||
"@expo/rudder-sdk-node": "npm:1.1.1"
|
||||
"@expo/spawn-async": "npm:1.7.2"
|
||||
"@expo/steps": "npm:18.6.0"
|
||||
"@expo/steps": "npm:18.8.0"
|
||||
"@expo/timeago.js": "npm:1.0.0"
|
||||
"@graphql-codegen/cli": "npm:5.0.0"
|
||||
"@graphql-codegen/introspection": "npm:4.0.0"
|
||||
|
||||
Reference in new issue
Block a user