mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c45fdfd7e | ||
|
|
b024609a5e | ||
|
|
8afc461064 |
No files matched your search
Vendored
+1
-2
@@ -5,6 +5,5 @@
|
||||
"**/*.code-search": true,
|
||||
"**/src/**/build/**": false,
|
||||
"**/src/build/**": false,
|
||||
},
|
||||
"js/ts.tsdk.path": "node_modules/typescript/lib"
|
||||
}
|
||||
}
|
||||
+2
-35
@@ -8,44 +8,11 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] New command `observe:session` for inspecting events by session ID. ([#3868](https://github.com/expo/eas-cli/pull/3868) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Fix `eas update --channel` failing with "Channel has no branches associated with it" when the channel was auto-created by a build. A branch with the channel's name is now created and linked, matching the behavior for a channel that doesn't exist yet. ([#3891](https://github.com/expo/eas-cli/pull/3891) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [20.3.0](https://github.com/expo/eas-cli/releases/tag/v20.3.0) - 2026-06-18
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `eas account:audit [ACCOUNT_NAME]` command to view an account's audit logs. ([#3863](https://github.com/expo/eas-cli/pull/3863) by [@keith-kurak](https://github.com/keith-kurak))
|
||||
- [eas-cli] `eas update:view [GROUP_ID]` now also accepts a platform-specific update ID, resolving it to and displaying its update group. ([#3864](https://github.com/expo/eas-cli/pull/3864) by [@keith-kurak](https://github.com/keith-kurak))
|
||||
- [eas-cli] Add `eas integrations:posthog:connect` command. ([#3836](https://github.com/expo/eas-cli/pull/3836) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas integrations:posthog:dashboard` command. ([#3837](https://github.com/expo/eas-cli/pull/3837) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas integrations:posthog:disconnect` command. ([#3838](https://github.com/expo/eas-cli/pull/3838) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] `eas integrations:posthog:connect` no longer aborts when `npx expo install` reports an error (for example, on a dynamic app config); it finishes writing the environment variables and prints any remaining manual steps. ([#3877](https://github.com/expo/eas-cli/pull/3877) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Sync new app agent templates from `expo/llm-configs` before publishing. ([#3865](https://github.com/expo/eas-cli/pull/3865) by [@davidmokos](https://github.com/davidmokos))
|
||||
- [eas-cli] Simplify 2FA now that SMS is no longer supported. ([#3859](https://github.com/expo/eas-cli/pull/3859) by [@wschurman](https://github.com/wschurman))
|
||||
|
||||
## [20.2.0](https://github.com/expo/eas-cli/releases/tag/v20.2.0) - 2026-06-15
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Non-interactive iOS App Store and Enterprise builds can now use the App Store Connect API key stored in EAS credentials as a submission key to validate and repair provisioning profiles on Apple servers, without requiring `EXPO_ASC_*` environment variables or an interactive Apple login. ([#3805](https://github.com/expo/eas-cli/pull/3805) by [@sswrk](https://github.com/sswrk))
|
||||
- [eas-cli] Add support for non-interactive `eas update:rollback [GROUP_ID]`. The given update group must be the latest for its branch and runtime version; the previous update group is republished, or a roll back to the embedded update is published if there is none. ([#3825](https://github.com/expo/eas-cli/pull/3825) by [@quinlanj](https://github.com/quinlanj))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [expo-cocoapods-proxy] Fix iOS worker tarball build failing on macOS Tahoe due to bundler incompatibility with RubyGems 4. ([#3824](https://github.com/expo/eas-cli/pull/3824) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Fix `update:embedded` topic showing the `update:embedded:delete` subcommand description in the command listing. ([#3866](https://github.com/expo/eas-cli/pull/3866) by [@jc-expo](https://github.com/jc-expo))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [20.1.0](https://github.com/expo/eas-cli/releases/tag/v20.1.0) - 2026-06-05
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "20.4.0",
|
||||
"version": "20.1.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "20.4.0",
|
||||
"version": "20.1.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -14,6 +14,10 @@ jest.mock('../sentry', () => ({
|
||||
}));
|
||||
|
||||
describe('runtimeSettings', () => {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
};
|
||||
const originalCacheUrls = {
|
||||
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
|
||||
EAS_NODEJS_CACHE_URL: process.env.EAS_NODEJS_CACHE_URL,
|
||||
@@ -26,6 +30,8 @@ describe('runtimeSettings', () => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
jest.restoreAllMocks();
|
||||
jest.mocked(Sentry.capture).mockReset();
|
||||
logger.info.mockReset();
|
||||
logger.warn.mockReset();
|
||||
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
|
||||
restoreEnv('EAS_NODEJS_CACHE_URL', originalCacheUrls.EAS_NODEJS_CACHE_URL);
|
||||
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
|
||||
@@ -35,16 +41,24 @@ describe('runtimeSettings', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({}));
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
jest.mocked(fetch).mockReset();
|
||||
logger.info.mockReset();
|
||||
logger.warn.mockReset();
|
||||
});
|
||||
|
||||
it('fetches runtime settings from the staging and production buckets', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({}));
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'production' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'test' });
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
},
|
||||
});
|
||||
await RuntimeSettings.loadAsync({ environment: 'production', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'test', logger: logger as any });
|
||||
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -67,11 +81,12 @@ describe('runtimeSettings', () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({}, 404));
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith('Failed to fetch worker runtime settings', {
|
||||
extras: {
|
||||
status: 404,
|
||||
@@ -88,12 +103,13 @@ describe('runtimeSettings', () => {
|
||||
.mockResolvedValueOnce(response({ caches: { linux: { npm: true } } }))
|
||||
.mockResolvedValueOnce(response({ iosPrecompiledModules: 'enabled' }));
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
|
||||
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to load worker runtime settings',
|
||||
expect.any(Error),
|
||||
@@ -122,7 +138,7 @@ describe('runtimeSettings', () => {
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
@@ -147,7 +163,7 @@ describe('runtimeSettings', () => {
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
|
||||
@@ -156,13 +172,50 @@ describe('runtimeSettings', () => {
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://pods.example');
|
||||
});
|
||||
|
||||
it('uses job environment flags to enable cache URLs from worker environment variables', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
it('uses cache URL fallbacks when enabled cache environment variables are not set', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { npm: true, nodejs: true, maven: true },
|
||||
darwin: { cocoapods: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'test',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
nodejs: 'https://fallback-node.example',
|
||||
maven: 'https://fallback-maven.example',
|
||||
cocoapods: 'https://fallback-pods.example',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://fallback-npm.example');
|
||||
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://fallback-node.example');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://fallback-maven.example');
|
||||
mockProcessPlatform('darwin');
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://fallback-pods.example');
|
||||
});
|
||||
|
||||
it('prefers cache URL environment variables over fallbacks', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { npm: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -170,41 +223,20 @@ describe('runtimeSettings', () => {
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
|
||||
});
|
||||
|
||||
it('returns null when job env enables cache but worker env var is empty', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = '';
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'test',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not use cache URLs from the job environment', async () => {
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'test',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
EAS_NPM_CACHE_URL: 'https://job-npm.example',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not enable cache when runtime settings explicitly disable it, even with job env flag', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: false } } }));
|
||||
it('does not use cache URL fallbacks when runtime settings disable caches', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { npm: false },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -212,61 +244,17 @@ describe('runtimeSettings', () => {
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('disables cache when EAS_BUILD_DISABLE_NPM_CACHE is set', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
it('clears cache URL fallbacks when loading without them', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_BUILD_DISABLE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('disables cache when EAS_BUILD_DISABLE_MAVEN_CACHE is set', async () => {
|
||||
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { maven: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_BUILD_DISABLE_MAVEN_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('disables cache when EAS_BUILD_DISABLE_COCOAPODS_CACHE is set', async () => {
|
||||
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { darwin: { cocoapods: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_BUILD_DISABLE_COCOAPODS_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('darwin');
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('allows job environment flags to disable remotely enabled cache URLs', async () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '0',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
},
|
||||
});
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
@@ -287,7 +275,7 @@ describe('runtimeSettings', () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
|
||||
mockProcessPlatform('darwin');
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { prepareProjectSourcesAsync } from '../../common/projectSources';
|
||||
import { BuildContext } from '../../context';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Datadog } from '../../datadog';
|
||||
import { findAndUploadXcodeBuildLogsAsync } from '../../ios/xcodeBuildLogs';
|
||||
import { runCustomBuildAsync } from '../custom';
|
||||
|
||||
@@ -18,7 +17,6 @@ const findAndUploadXcodeBuildLogsAsyncMock = jest.mocked(findAndUploadXcodeBuild
|
||||
|
||||
describe(runCustomBuildAsync, () => {
|
||||
let ctx: BuildContext<BuildJob>;
|
||||
const datadogLogSpy = jest.spyOn(Datadog, 'log');
|
||||
|
||||
beforeEach(() => {
|
||||
const job = createTestIosJob();
|
||||
@@ -29,24 +27,10 @@ describe(runCustomBuildAsync, () => {
|
||||
vol.fromJSON(
|
||||
{
|
||||
'test.yaml': `
|
||||
functions:
|
||||
test_function:
|
||||
name: Test function
|
||||
path: ./custom-function
|
||||
shell: /bin/zsh
|
||||
supported_platforms:
|
||||
- darwin
|
||||
- linux
|
||||
inputs:
|
||||
- name
|
||||
- profile
|
||||
outputs:
|
||||
- artifact_path
|
||||
build:
|
||||
steps:
|
||||
- eas/checkout
|
||||
`,
|
||||
'custom-function/package.json': '{}',
|
||||
},
|
||||
'/workingdir/temporary-custom-build'
|
||||
);
|
||||
@@ -93,21 +77,6 @@ describe(runCustomBuildAsync, () => {
|
||||
expect(prepareProjectSourcesAsync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports user-provided custom functions when parsing the custom build config', async () => {
|
||||
await runCustomBuildAsync(ctx);
|
||||
|
||||
expect(datadogLogSpy).toHaveBeenCalledWith('Custom build saw user-provided function', {
|
||||
event: 'custom_build_user_provided_function',
|
||||
custom_function_id: 'test_function',
|
||||
custom_function_input_count: '2',
|
||||
custom_function_module_path: '/workingdir/temporary-custom-build/custom-function',
|
||||
custom_function_name: 'Test function',
|
||||
custom_function_output_count: '1',
|
||||
custom_function_shell: '/bin/zsh',
|
||||
custom_function_supported_runtime_platforms: 'darwin,linux',
|
||||
});
|
||||
});
|
||||
|
||||
it('awaits drainPendingMetricUploads after workflow execution', async () => {
|
||||
let resolveDrain!: () => void;
|
||||
const drainSpy = jest
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildConfigParser,
|
||||
BuildStepGlobalContext,
|
||||
BuildWorkflow,
|
||||
StepsConfigParser,
|
||||
errors,
|
||||
} from '@expo/steps';
|
||||
import { BuildConfigParser, BuildStepGlobalContext, StepsConfigParser, errors } from '@expo/steps';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs/promises';
|
||||
import nullthrows from 'nullthrows';
|
||||
@@ -15,7 +9,6 @@ import { resolveEnvFromBuildProfileAsync } from '../common/easBuildInternal';
|
||||
import { prepareProjectSourcesAsync } from '../common/projectSources';
|
||||
import { Artifacts, BuildContext } from '../context';
|
||||
import { CustomBuildContext } from '../customBuildContext';
|
||||
import { Datadog } from '../datadog';
|
||||
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
|
||||
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
|
||||
import { getEasFunctions } from '../steps/easFunctions';
|
||||
@@ -88,7 +81,6 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
|
||||
throw parseError;
|
||||
}
|
||||
});
|
||||
logUserProvidedCustomFunctions(workflow);
|
||||
try {
|
||||
try {
|
||||
await workflow.executeAsync();
|
||||
@@ -111,26 +103,3 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
|
||||
|
||||
return ctx.artifacts;
|
||||
}
|
||||
|
||||
function logUserProvidedCustomFunctions(workflow: BuildWorkflow): void {
|
||||
for (const buildFunction of Object.values(workflow.buildFunctions)) {
|
||||
if (!buildFunction.customFunctionModulePath) {
|
||||
continue;
|
||||
}
|
||||
Datadog.log('Custom build saw user-provided function', {
|
||||
event: 'custom_build_user_provided_function',
|
||||
custom_function_id: buildFunction.getFullId(),
|
||||
custom_function_module_path: buildFunction.customFunctionModulePath,
|
||||
custom_function_input_count: String(buildFunction.inputProviders?.length ?? 0),
|
||||
custom_function_output_count: String(buildFunction.outputProviders?.length ?? 0),
|
||||
...(buildFunction.name ? { custom_function_name: buildFunction.name } : {}),
|
||||
...(buildFunction.shell ? { custom_function_shell: buildFunction.shell } : {}),
|
||||
...(buildFunction.supportedRuntimePlatforms
|
||||
? {
|
||||
custom_function_supported_runtime_platforms:
|
||||
buildFunction.supportedRuntimePlatforms.join(','),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
|
||||
import { createMockLogger } from '../../__tests__/utils/logger';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { PackageManager } from '../../utils/packageManager';
|
||||
import { installDependenciesWithNpmCacheFallbackAsync } from '../installDependencies';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => jest.fn());
|
||||
jest.mock('../../sentry', () => ({
|
||||
Sentry: {
|
||||
capture: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(installDependenciesWithNpmCacheFallbackAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('retries without NPM_CONFIG_REGISTRY when npm cache install fails', async () => {
|
||||
const logger = createMockLogger();
|
||||
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
|
||||
const error = Object.assign(
|
||||
new Error('npm install --include=dev exited with non-zero code: 1'),
|
||||
{
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
|
||||
}
|
||||
);
|
||||
|
||||
jest
|
||||
.mocked(spawn)
|
||||
.mockReturnValueOnce(createSpawnPromise(Promise.reject(error)))
|
||||
.mockReturnValueOnce(createSpawnPromise(Promise.resolve(createSpawnResult())));
|
||||
|
||||
await installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
EAS_VERBOSE: '1',
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(2);
|
||||
expect(spawn).toHaveBeenNthCalledWith(1, 'npm', ['install', '--include=dev', '--verbose'], {
|
||||
cwd: '/tmp/build',
|
||||
logger,
|
||||
infoCallbackFn: undefined,
|
||||
lineTransformer: expect.any(Function),
|
||||
env: {
|
||||
EAS_VERBOSE: '1',
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
},
|
||||
});
|
||||
expect(spawn).toHaveBeenNthCalledWith(2, 'npm', ['install', '--include=dev', '--verbose'], {
|
||||
cwd: '/tmp/build',
|
||||
logger,
|
||||
infoCallbackFn: undefined,
|
||||
env: {
|
||||
EAS_VERBOSE: '1',
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Failed to install dependencies using npm cache registry',
|
||||
name: 'NpmCacheRegistryInstallError',
|
||||
}),
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
packageManager: 'npm',
|
||||
},
|
||||
extras: {
|
||||
cwd: '/tmp/build',
|
||||
npmCacheUrl,
|
||||
useFrozenLockfile: false,
|
||||
originalErrorMessage: 'npm install --include=dev exited with non-zero code: 1',
|
||||
status: 1,
|
||||
signal: null,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('reports non-fatal npm cache registry errors from successful installs', async () => {
|
||||
const logger = createMockLogger();
|
||||
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
|
||||
const auditErrorLine = `npm verbose audit error FetchError: request to ${npmCacheUrl}/-/npm/v1/security/advisories/bulk failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`;
|
||||
|
||||
jest.mocked(spawn).mockImplementationOnce((_command, _args, options: any) => {
|
||||
options.lineTransformer(auditErrorLine);
|
||||
options.lineTransformer(
|
||||
`npm http cache left-pad@${npmCacheUrl}/left-pad/-/left-pad-1.3.0.tgz 0ms (cache hit)`
|
||||
);
|
||||
return createSpawnPromise(Promise.resolve(createSpawnResult()));
|
||||
});
|
||||
|
||||
await installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'Non-fatal npm cache registry error during dependency install',
|
||||
name: 'NpmCacheRegistryNonFatalError',
|
||||
}),
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
packageManager: 'npm',
|
||||
},
|
||||
extras: {
|
||||
cwd: '/tmp/build',
|
||||
npmCacheUrl,
|
||||
useFrozenLockfile: false,
|
||||
firstErrorLine: auditErrorLine,
|
||||
errorLineCount: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not retry when install failure does not reference npm cache registry', async () => {
|
||||
const logger = createMockLogger();
|
||||
const error = Object.assign(
|
||||
new Error('npm install --include=dev exited with non-zero code: 1'),
|
||||
{
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: 'npm error command sh -c node postinstall.js',
|
||||
}
|
||||
);
|
||||
|
||||
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
|
||||
|
||||
await expect(
|
||||
installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
NPM_CONFIG_REGISTRY: 'http://npm.staging.caches.eas-build.internal',
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
})
|
||||
).rejects.toThrow(error);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not retry when EAS_USE_NPM_CACHE is not enabled', async () => {
|
||||
const logger = createMockLogger();
|
||||
const npmCacheUrl = 'http://npm.staging.caches.eas-build.internal';
|
||||
const error = Object.assign(
|
||||
new Error('npm install --include=dev exited with non-zero code: 1'),
|
||||
{
|
||||
status: 1,
|
||||
signal: null,
|
||||
stdout: '',
|
||||
stderr: `npm error request to ${npmCacheUrl}/left-pad failed, reason: getaddrinfo ENOTFOUND npm.staging.caches.eas-build.internal`,
|
||||
}
|
||||
);
|
||||
|
||||
jest.mocked(spawn).mockReturnValueOnce(createSpawnPromise(Promise.reject(error)));
|
||||
|
||||
await expect(
|
||||
installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: PackageManager.NPM,
|
||||
env: {
|
||||
NPM_CONFIG_REGISTRY: npmCacheUrl,
|
||||
},
|
||||
logger,
|
||||
cwd: '/tmp/build',
|
||||
useFrozenLockfile: false,
|
||||
})
|
||||
).rejects.toThrow(error);
|
||||
|
||||
expect(spawn).toHaveBeenCalledTimes(1);
|
||||
expect(Sentry.capture).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function createSpawnPromise(result: Promise<SpawnResult>): SpawnPromise<SpawnResult> {
|
||||
return Object.assign(result, {
|
||||
child: {
|
||||
pid: 123,
|
||||
},
|
||||
}) as SpawnPromise<SpawnResult>;
|
||||
}
|
||||
|
||||
function createSpawnResult(): SpawnResult {
|
||||
return {
|
||||
pid: 123,
|
||||
output: ['', ''],
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
status: 0,
|
||||
signal: null,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Job, SystemError } from '@expo/eas-build-job';
|
||||
import { Job } from '@expo/eas-build-job';
|
||||
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import path from 'path';
|
||||
|
||||
import { BuildContext } from '../context';
|
||||
import { Sentry } from '../sentry';
|
||||
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
|
||||
import { isUsingModernYarnVersion } from '../utils/project';
|
||||
|
||||
@@ -12,7 +11,6 @@ export async function installDependenciesAsync({
|
||||
env,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
lineTransformer,
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
}: {
|
||||
@@ -21,7 +19,6 @@ export async function installDependenciesAsync({
|
||||
cwd: string;
|
||||
logger: Exclude<SpawnOptions['logger'], undefined>;
|
||||
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
|
||||
lineTransformer?: SpawnOptions['lineTransformer'];
|
||||
useFrozenLockfile: boolean;
|
||||
}): Promise<{ spawnPromise: SpawnPromise<SpawnResult> }> {
|
||||
let args: string[];
|
||||
@@ -72,92 +69,11 @@ export async function installDependenciesAsync({
|
||||
cwd,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
lineTransformer,
|
||||
env,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager,
|
||||
env,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
}: {
|
||||
packageManager: PackageManager;
|
||||
env: Record<string, string | undefined>;
|
||||
cwd: string;
|
||||
logger: Exclude<SpawnOptions['logger'], undefined>;
|
||||
infoCallbackFn?: SpawnOptions['infoCallbackFn'];
|
||||
useFrozenLockfile: boolean;
|
||||
}): Promise<void> {
|
||||
const npmCacheUrl = env.NPM_CONFIG_REGISTRY;
|
||||
let firstErrorLine: string | undefined;
|
||||
let errorLineCount = 0;
|
||||
|
||||
try {
|
||||
await (
|
||||
await installDependenciesAsync({
|
||||
packageManager,
|
||||
env,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
lineTransformer: (line: string) => {
|
||||
if (isNpmCacheRegistryErrorLine(line, { env, npmCacheUrl })) {
|
||||
firstErrorLine ??= line;
|
||||
errorLineCount += 1;
|
||||
}
|
||||
return line;
|
||||
},
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
})
|
||||
).spawnPromise;
|
||||
|
||||
if (firstErrorLine) {
|
||||
Sentry.capture(new NpmCacheRegistryNonFatalError(), {
|
||||
level: 'warning',
|
||||
tags: { packageManager },
|
||||
extras: { cwd, npmCacheUrl, useFrozenLockfile, firstErrorLine, errorLineCount },
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!isNpmCacheInstallFailure(err, { env, npmCacheUrl })) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Failed to install dependencies using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
|
||||
);
|
||||
Sentry.capture(new NpmCacheRegistryInstallError(err), {
|
||||
level: 'warning',
|
||||
tags: { packageManager },
|
||||
extras: {
|
||||
cwd,
|
||||
npmCacheUrl,
|
||||
useFrozenLockfile,
|
||||
originalErrorMessage: err instanceof Error ? err.message : String(err),
|
||||
status: err instanceof Error ? (err as any).status : undefined,
|
||||
signal: err instanceof Error ? (err as any).signal : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { NPM_CONFIG_REGISTRY: _NPM_CONFIG_REGISTRY, ...fallbackEnv } = env;
|
||||
await (
|
||||
await installDependenciesAsync({
|
||||
packageManager,
|
||||
env: fallbackEnv,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
})
|
||||
).spawnPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackagerDir(ctx: BuildContext<Job>): string {
|
||||
const packagerRunDir = findPackagerRootDir(ctx.getReactNativeProjectDirectory());
|
||||
if (packagerRunDir !== ctx.getReactNativeProjectDirectory()) {
|
||||
@@ -171,54 +87,3 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
|
||||
}
|
||||
return packagerRunDir;
|
||||
}
|
||||
|
||||
function isNpmCacheInstallFailure(
|
||||
err: unknown,
|
||||
{ env, npmCacheUrl }: { env: Record<string, string | undefined>; npmCacheUrl: string | undefined }
|
||||
): boolean {
|
||||
if (!isNpmCacheRegistryEnabled(env, npmCacheUrl)) {
|
||||
return false;
|
||||
}
|
||||
const errorOutput = getErrorOutput(err);
|
||||
return errorOutput.includes(npmCacheUrl);
|
||||
}
|
||||
|
||||
function isNpmCacheRegistryErrorLine(
|
||||
line: string,
|
||||
{ env, npmCacheUrl }: { env: Record<string, string | undefined>; npmCacheUrl: string | undefined }
|
||||
): boolean {
|
||||
return (
|
||||
isNpmCacheRegistryEnabled(env, npmCacheUrl) &&
|
||||
line.includes(npmCacheUrl) &&
|
||||
/(?:error|failed|ENOTFOUND|ECONN|ETIMEDOUT|EAI_AGAIN|FetchError)/i.test(line)
|
||||
);
|
||||
}
|
||||
|
||||
function isNpmCacheRegistryEnabled(
|
||||
env: Record<string, string | undefined>,
|
||||
npmCacheUrl: string | undefined
|
||||
): npmCacheUrl is string {
|
||||
return env.EAS_USE_NPM_CACHE === '1' && !!npmCacheUrl;
|
||||
}
|
||||
|
||||
function getErrorOutput(err: unknown): string {
|
||||
if (!(err instanceof Error)) {
|
||||
return '';
|
||||
}
|
||||
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
|
||||
return [stdout, stderr].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
class NpmCacheRegistryNonFatalError extends SystemError {
|
||||
override name = 'NpmCacheRegistryNonFatalError';
|
||||
constructor() {
|
||||
super('Non-fatal npm cache registry error during dependency install');
|
||||
}
|
||||
}
|
||||
|
||||
class NpmCacheRegistryInstallError extends SystemError {
|
||||
override name = 'NpmCacheRegistryInstallError';
|
||||
constructor(cause: unknown) {
|
||||
super('Failed to install dependencies using npm cache registry', { cause });
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,7 @@ import { BuildJob } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { SpawnOptions } from '@expo/turtle-spawn';
|
||||
|
||||
import {
|
||||
installDependenciesWithNpmCacheFallbackAsync,
|
||||
resolvePackagerDir,
|
||||
} from './installDependencies';
|
||||
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
|
||||
import { BuildContext } from '../context';
|
||||
import { runExpoCliCommand } from '../utils/project';
|
||||
|
||||
@@ -33,14 +30,17 @@ export async function prebuildAsync<TJob extends BuildJob>(
|
||||
options: spawnOptions,
|
||||
packageManager: ctx.packageManager,
|
||||
});
|
||||
await installDependenciesWithNpmCacheFallbackAsync({
|
||||
packageManager: ctx.packageManager,
|
||||
env: ctx.env,
|
||||
logger,
|
||||
cwd: resolvePackagerDir(ctx),
|
||||
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
|
||||
useFrozenLockfile: false,
|
||||
});
|
||||
const installDependenciesSpawnPromise = (
|
||||
await installDependenciesAsync({
|
||||
packageManager: ctx.packageManager,
|
||||
env: ctx.env,
|
||||
logger,
|
||||
cwd: resolvePackagerDir(ctx),
|
||||
// prebuild sometimes modifies package.json, so we don't want to use frozen lockfile
|
||||
useFrozenLockfile: false,
|
||||
})
|
||||
).spawnPromise;
|
||||
await installDependenciesSpawnPromise;
|
||||
}
|
||||
|
||||
function getPrebuildCommandArgs<TJob extends BuildJob>(ctx: BuildContext<TJob>): string[] {
|
||||
|
||||
@@ -45,7 +45,6 @@ export type ArtifactToUpload =
|
||||
type: GenericArtifactType;
|
||||
name: string;
|
||||
paths: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface BuildContextOptions {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import fetch from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -26,18 +26,27 @@ const RuntimeSettingsSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
type CacheUrlFallbacks = {
|
||||
npm?: string | null;
|
||||
nodejs?: string | null;
|
||||
maven?: string | null;
|
||||
cocoapods?: string | null;
|
||||
};
|
||||
|
||||
let runtimeSettings: z.infer<typeof RuntimeSettingsSchema> = {};
|
||||
let runtimeEnvironment: Env = {};
|
||||
let cacheUrlFallbacks: CacheUrlFallbacks = {};
|
||||
|
||||
export namespace RuntimeSettings {
|
||||
export async function loadAsync({
|
||||
environment,
|
||||
env: nextRuntimeEnvironment = {},
|
||||
logger,
|
||||
cacheUrlFallbacks: nextCacheUrlFallbacks = {},
|
||||
}: {
|
||||
environment: string;
|
||||
env?: Env;
|
||||
logger: bunyan;
|
||||
cacheUrlFallbacks?: CacheUrlFallbacks;
|
||||
}): Promise<void> {
|
||||
runtimeEnvironment = nextRuntimeEnvironment;
|
||||
cacheUrlFallbacks = nextCacheUrlFallbacks;
|
||||
const url = ENVIRONMENT_TO_RUNTIME_SETTINGS_URL[environment];
|
||||
if (!url) {
|
||||
return;
|
||||
@@ -49,6 +58,10 @@ export namespace RuntimeSettings {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(
|
||||
{ url, status: response.status },
|
||||
'Failed to fetch worker runtime settings, using defaults'
|
||||
);
|
||||
Sentry.capture('Failed to fetch worker runtime settings', {
|
||||
extras: { url, status: response.status },
|
||||
level: 'warning',
|
||||
@@ -71,7 +84,9 @@ export namespace RuntimeSettings {
|
||||
}
|
||||
);
|
||||
}
|
||||
logger.info({ url, settings: runtimeSettings }, 'Loaded worker runtime settings');
|
||||
} catch (err) {
|
||||
logger.warn({ err, url }, 'Failed to load worker runtime settings, using defaults');
|
||||
Sentry.capture(
|
||||
'Failed to load worker runtime settings',
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
@@ -88,51 +103,26 @@ export namespace RuntimeSettings {
|
||||
}
|
||||
|
||||
export function getNpmCacheUrl(): string | null {
|
||||
if (runtimeEnvironment['EAS_BUILD_DISABLE_NPM_CACHE'] === '1') {
|
||||
return null;
|
||||
}
|
||||
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.npm;
|
||||
if (runtimeEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const envOverride = runtimeEnvironment['EAS_USE_NPM_CACHE'];
|
||||
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
|
||||
return enabled ? process.env.EAS_NPM_CACHE_URL || null : null;
|
||||
return runtimeSettings.caches?.[process.platform]?.npm
|
||||
? process.env.EAS_NPM_CACHE_URL || cacheUrlFallbacks.npm || null
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getNodeJsCacheUrl(): string | null {
|
||||
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.nodejs;
|
||||
if (runtimeEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const envOverride = runtimeEnvironment['EAS_USE_NODEJS_CACHE'];
|
||||
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
|
||||
return enabled ? process.env.EAS_NODEJS_CACHE_URL || null : null;
|
||||
return runtimeSettings.caches?.[process.platform]?.nodejs
|
||||
? process.env.EAS_NODEJS_CACHE_URL || cacheUrlFallbacks.nodejs || null
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getMavenCacheUrl(): string | null {
|
||||
if (runtimeEnvironment['EAS_BUILD_DISABLE_MAVEN_CACHE'] === '1') {
|
||||
return null;
|
||||
}
|
||||
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.maven;
|
||||
if (runtimeEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const envOverride = runtimeEnvironment['EAS_USE_MAVEN_CACHE'];
|
||||
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
|
||||
return enabled ? process.env.EAS_MAVEN_CACHE_URL || null : null;
|
||||
return runtimeSettings.caches?.[process.platform]?.maven
|
||||
? process.env.EAS_MAVEN_CACHE_URL || cacheUrlFallbacks.maven || null
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getCocoapodsCacheUrl(): string | null {
|
||||
if (runtimeEnvironment['EAS_BUILD_DISABLE_COCOAPODS_CACHE'] === '1') {
|
||||
return null;
|
||||
}
|
||||
const runtimeEnabled = runtimeSettings.caches?.[process.platform]?.cocoapods;
|
||||
if (runtimeEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const envOverride = runtimeEnvironment['EAS_USE_COCOAPODS_CACHE'];
|
||||
const enabled = envOverride === '1' || (envOverride !== '0' && runtimeEnabled);
|
||||
return enabled ? process.env.EAS_COCOAPODS_CACHE_URL || null : null;
|
||||
return runtimeSettings.caches?.[process.platform]?.cocoapods
|
||||
? process.env.EAS_COCOAPODS_CACHE_URL || cacheUrlFallbacks.cocoapods || null
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createDownloadBuildFunction(),
|
||||
createEasExportBuildFunction(),
|
||||
createEasDeployBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
@@ -98,7 +98,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadToAscBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(ctx),
|
||||
createMaestroTestsBuildFunction(),
|
||||
];
|
||||
|
||||
if (ctx.hasBuildJob()) {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import { Client, CombinedError } from '@urql/core';
|
||||
import fetch, { Response } from 'node-fetch';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
@@ -19,63 +17,22 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
|
||||
'base64'
|
||||
);
|
||||
|
||||
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
|
||||
|
||||
function createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
error,
|
||||
}: {
|
||||
applicationArchiveUrl?: string | null;
|
||||
error?: Error;
|
||||
}): Client {
|
||||
const toPromise = jest.fn().mockResolvedValue(
|
||||
error
|
||||
? { error, data: undefined }
|
||||
: {
|
||||
data: {
|
||||
builds: {
|
||||
byId: {
|
||||
id: randomUUID(),
|
||||
platform: 'IOS',
|
||||
artifacts: {
|
||||
applicationArchiveUrl: applicationArchiveUrl ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
query: jest.fn().mockReturnValue({ toPromise }),
|
||||
} as unknown as Client;
|
||||
}
|
||||
|
||||
describe('downloadBuild', () => {
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
it('should handle an archive', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
|
||||
} as unknown as Response);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
buildId: randomUUID(),
|
||||
expoApiServerURL: 'http://api.expo.test',
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
expect(artifactPath).toBeDefined();
|
||||
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
|
||||
'i am executable\n'
|
||||
@@ -83,21 +40,16 @@ describe('downloadBuild', () => {
|
||||
});
|
||||
|
||||
it('should handle a straight-up file', async () => {
|
||||
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(Buffer.from('hello')),
|
||||
url: applicationArchiveUrl,
|
||||
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.txt?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
|
||||
} as unknown as Response);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
expoApiServerURL: 'http://api.expo.test',
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
@@ -106,114 +58,18 @@ describe('downloadBuild', () => {
|
||||
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
|
||||
});
|
||||
|
||||
it('throws UserError when the build has no application archive url', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
|
||||
message: 'Build does not have an application archive url',
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request fails with a network error', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
networkError: new Error('Network request failed'),
|
||||
}),
|
||||
});
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SystemError when GraphQL request returns 5xx', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: Object.assign(new Error('Internal Server Error'), {
|
||||
response: { status: 500 },
|
||||
}),
|
||||
});
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(SystemError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
message: `Could not fetch build ${buildId}: Internal Server Error`,
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws UserError when GraphQL returns a client error', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
error: new CombinedError({
|
||||
graphQLErrors: [{ message: 'Build not found' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const promise = downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId,
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(UserError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
|
||||
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if no matching files are found', async () => {
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
graphqlClient,
|
||||
expoApiServerURL: 'http://api.expo.test',
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
})
|
||||
@@ -223,11 +79,7 @@ describe('downloadBuild', () => {
|
||||
|
||||
describe('createDownloadBuildFunction', () => {
|
||||
it('should download a build', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
const downloadBuild = createDownloadBuildFunction({ graphqlClient } as any);
|
||||
const downloadBuild = createDownloadBuildFunction();
|
||||
const logger = createMockLogger();
|
||||
|
||||
const buildStep = downloadBuild.createBuildStepFromFunctionCall(
|
||||
@@ -240,7 +92,7 @@ describe('createDownloadBuildFunction', () => {
|
||||
}),
|
||||
{
|
||||
callInputs: {
|
||||
build_id: buildId,
|
||||
build_id: randomUUID(),
|
||||
extensions: ['app'],
|
||||
},
|
||||
}
|
||||
@@ -253,9 +105,5 @@ describe('createDownloadBuildFunction', () => {
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(buildStep.executeAsync()).rejects.toThrow('Internal Server Error');
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
APPLICATION_ARCHIVE_URL,
|
||||
expect.objectContaining({ headers: undefined })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,10 @@ import { vol } from 'memfs';
|
||||
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
isFileAttrRun,
|
||||
junitFileHasFileAttrs,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseMaestroResults, () => {
|
||||
@@ -302,169 +298,7 @@ describe(parseMaestroResults, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseMaestroResultsFromFileAttrs, () => {
|
||||
it('uses file= as the path', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseMaestroResultsFromFileAttrs('/junit');
|
||||
expect(results[0]).toEqual(
|
||||
expect.objectContaining({ name: 'login', path: '.maestro/login.yaml' })
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps two same-named flows separate by their file= path', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="login" file="b/login.yaml" status="ERROR" time="1.0"><failure>x</failure></testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseMaestroResultsFromFileAttrs('/junit');
|
||||
expect(results.map(r => r.path).sort()).toEqual(['a/login.yaml', 'b/login.yaml']);
|
||||
});
|
||||
|
||||
it('returns per-attempt results keyed by file= across attempt files', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/android-maestro-junit-attempt-0.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
'/junit/android-maestro-junit-attempt-1.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseMaestroResultsFromFileAttrs('/junit');
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({ path: 'flows/login.yaml', status: 'failed', retryCount: 0 }),
|
||||
expect.objectContaining({ path: 'flows/login.yaml', status: 'passed', retryCount: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array when the directory does not exist', async () => {
|
||||
const results = await parseMaestroResultsFromFileAttrs('/nope');
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips testcases without file= instead of emitting an undefined path (contract guard)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="b" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseMaestroResultsFromFileAttrs('/junit');
|
||||
expect(results).toEqual([expect.objectContaining({ name: 'a', path: 'a.yaml' })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('junitFileHasFileAttrs', () => {
|
||||
it('returns true when every testcase has a non-empty file= attribute', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="b" file="b.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when any testcase lacks file= (mixed report)', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="b" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when file= is an empty string', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" file="" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a legacy report with no file= attributes', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the file cannot be read', async () => {
|
||||
expect(await junitFileHasFileAttrs('/missing.xml')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFileAttrRun', () => {
|
||||
it('returns false for an empty list (an empty report is not a file-attr run)', () => {
|
||||
expect(isFileAttrRun([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseJUnitTestCases, () => {
|
||||
it('extracts the file= attribute when present', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results[0].file).toBe('.maestro/login.yaml');
|
||||
});
|
||||
|
||||
it('treats a missing or empty file= attribute as undefined', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="a" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="b" file="" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const results = await parseJUnitTestCases('/junit');
|
||||
expect(results.find(r => r.name === 'a')!.file).toBeUndefined();
|
||||
expect(results.find(r => r.name === 'b')!.file).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses a single testcase with SUCCESS status', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
@@ -748,100 +582,6 @@ describe(parseJUnitTestCases, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseFailedFlowsFromFileAttrs, () => {
|
||||
it('returns the failing flows by file= (duplicate names retry correctly)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
|
||||
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/proj/b/login.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toEqual(['b/login.yaml']);
|
||||
});
|
||||
|
||||
it('returns null when a file= path does not exist on disk (dumb retry, never legacy)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="gone" file="missing/flow.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the report lacks file= attributes (callers must pre-check)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="B" time="1.0"><failure>y</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/proj/a.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('dedupes repeated failing file= paths (same flow failed twice in one attempt)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>y</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/proj/flows/login.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toEqual(['flows/login.yaml']);
|
||||
});
|
||||
|
||||
it('resolves and existence-checks an absolute file= path', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="ext" file="/outside/flow.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/outside/flow.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toEqual(['/outside/flow.yaml']);
|
||||
});
|
||||
|
||||
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
<testcase name="B" file="b.yaml"`, // intentionally truncated mid-tag
|
||||
'/proj/a.yaml': '',
|
||||
});
|
||||
const result = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: '/tmp/jr/attempt-0.xml',
|
||||
workingDirectory: '/proj',
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFailedFlowsFromJUnit', () => {
|
||||
it('returns the subset of input flow paths whose testcases failed', async () => {
|
||||
vol.fromJSON({
|
||||
@@ -1025,28 +765,6 @@ describe('mergeJUnitReports', () => {
|
||||
expect(b.failure).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps a same-named flow from an earlier attempt when keyed by file=', async () => {
|
||||
vol.fromJSON({
|
||||
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
|
||||
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
|
||||
</testsuite></testsuites>`,
|
||||
'/tmp/r/android-maestro-junit-attempt-1.xml': `<?xml version="1.0"?>
|
||||
<testsuites><testsuite>
|
||||
<testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.0"/>
|
||||
</testsuite></testsuites>`,
|
||||
});
|
||||
|
||||
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
|
||||
|
||||
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
|
||||
const parsed = new XMLParser({ ignoreAttributes: false }).parse(out);
|
||||
const testcases = parsed.testsuites.testsuite.testcase;
|
||||
const files = testcases.map((t: any) => t['@_file']).sort();
|
||||
expect(files).toEqual(['a/login.yaml', 'b/login.yaml']);
|
||||
});
|
||||
|
||||
it('throws when source directory contains no *.xml files', async () => {
|
||||
// No per-attempt JUnit files were ever written (maestro crashed before
|
||||
// producing output). Phase 3 in runMaestroTests treats this as a SystemError
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import {
|
||||
type HarvestedScreenshot,
|
||||
computePureFailureFlowNames,
|
||||
harvestFailureScreenshotsAsync,
|
||||
parseFailureScreenshotFilename,
|
||||
selectFailureScreenshots,
|
||||
} from '../maestroScreenshots';
|
||||
|
||||
describe(parseFailureScreenshotFilename, () => {
|
||||
it('parses a plain failure screenshot', () => {
|
||||
expect(parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login Flow).png')).toEqual({
|
||||
flowName: 'Login Flow',
|
||||
capturedAtMs: 1781186692250,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a sharded failure screenshot', () => {
|
||||
expect(
|
||||
parseFailureScreenshotFilename('screenshot-shard-2-❌-1781186692250-(Login Flow).png')
|
||||
).toEqual({
|
||||
flowName: 'Login Flow',
|
||||
capturedAtMs: 1781186692250,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles parentheses inside the flow name (right-to-left parse)', () => {
|
||||
expect(
|
||||
parseFailureScreenshotFilename('screenshot-❌-1781186692250-(Login (staging) Flow).png')
|
||||
).toEqual({
|
||||
flowName: 'Login (staging) Flow',
|
||||
capturedAtMs: 1781186692250,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-failure screenshots (✅, ⚠️) and unrelated files', () => {
|
||||
expect(
|
||||
parseFailureScreenshotFilename('screenshot-✅-1781186692250-(Login Flow).png')
|
||||
).toBeNull();
|
||||
expect(parseFailureScreenshotFilename('commands-(Login Flow).json')).toBeNull();
|
||||
expect(parseFailureScreenshotFilename('android-maestro-junit-attempt-0.xml')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe(harvestFailureScreenshotsAsync, () => {
|
||||
const logger = createMockLogger();
|
||||
let testsDirectory: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testsDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'maestro-harvest-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testsDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function makeDebugDir(name: string, files: string[], mtimeMs: number): Promise<void> {
|
||||
const dir = path.join(testsDirectory, name);
|
||||
await fs.mkdir(dir);
|
||||
for (const file of files) {
|
||||
await fs.writeFile(path.join(dir, file), '');
|
||||
}
|
||||
const when = new Date(mtimeMs);
|
||||
await fs.utimes(dir, when, when);
|
||||
}
|
||||
|
||||
it('returns shots from dirs modified at/after sinceMtimeMs, attributed to the attempt', async () => {
|
||||
const sinceMtimeMs = 1_000_000;
|
||||
await makeDebugDir(
|
||||
'attempt-dir',
|
||||
['screenshot-❌-1781186692250-(Login Flow).png', 'commands.json'],
|
||||
sinceMtimeMs + 5_000
|
||||
);
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 1,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([
|
||||
{
|
||||
fileAbsPath: path.join(
|
||||
testsDirectory,
|
||||
'attempt-dir',
|
||||
'screenshot-❌-1781186692250-(Login Flow).png'
|
||||
),
|
||||
displayName: 'Failure Screenshot: Login Flow (attempt 2)',
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: 'Login Flow',
|
||||
attemptIndex: 1,
|
||||
capturedAtMs: 1781186692250,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores dirs older than sinceMtimeMs (previous attempts)', async () => {
|
||||
const sinceMtimeMs = 1_000_000;
|
||||
await makeDebugDir(
|
||||
'old-dir',
|
||||
['screenshot-❌-1781186692250-(Old Flow).png'],
|
||||
sinceMtimeMs - 5_000
|
||||
);
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
|
||||
it('scans multiple new dirs (maestro <2.5.0 split-dir bug)', async () => {
|
||||
const sinceMtimeMs = 1_000_000;
|
||||
await makeDebugDir(
|
||||
'dir-a',
|
||||
[`screenshot-❌-${sinceMtimeMs + 100}-(Flow A).png`],
|
||||
sinceMtimeMs + 1_000
|
||||
);
|
||||
await makeDebugDir(
|
||||
'dir-b',
|
||||
[`screenshot-❌-${sinceMtimeMs + 200}-(Flow B).png`],
|
||||
sinceMtimeMs + 2_000
|
||||
);
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots.map(shot => shot.metadata.flowName).sort()).toEqual(['Flow A', 'Flow B']);
|
||||
});
|
||||
|
||||
it('keeps the disk flow name (slashes already substituted) and strips the shard prefix', async () => {
|
||||
const sinceMtimeMs = 1_000_000;
|
||||
await makeDebugDir(
|
||||
'sharded-dir',
|
||||
['screenshot-shard-3-❌-1781186692250-(Login_Sub Flow).png'],
|
||||
sinceMtimeMs + 1_000
|
||||
);
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toHaveLength(1);
|
||||
expect(shots[0].metadata.flowName).toBe('Login_Sub Flow');
|
||||
});
|
||||
|
||||
it('ignores a stale screenshot whose capturedAtMs predates sinceMtimeMs (dir touched later)', async () => {
|
||||
const sinceMtimeMs = 1_000_000;
|
||||
// The dir mtime is AFTER this attempt started (so it passes the dir gate), but the
|
||||
// screenshot inside was captured BEFORE it — a prior attempt's shot in a dir touched later.
|
||||
await makeDebugDir(
|
||||
'touched-dir',
|
||||
['screenshot-❌-999000-(Stale Flow).png'],
|
||||
sinceMtimeMs + 5_000
|
||||
);
|
||||
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: sinceMtimeMs,
|
||||
attemptIndex: 1,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
|
||||
it('logs and returns [] when the tests dir cannot be read', async () => {
|
||||
const shots = await harvestFailureScreenshotsAsync({
|
||||
testsDirectory: path.join(testsDirectory, 'does-not-exist'),
|
||||
capturedSinceMs: 0,
|
||||
attemptIndex: 0,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(shots).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(computePureFailureFlowNames, () => {
|
||||
it('classifies a flow that failed every attempt as pure', () => {
|
||||
const pure = computePureFailureFlowNames([
|
||||
{ name: 'Login Flow', status: 'failed' },
|
||||
{ name: 'Login Flow', status: 'failed' },
|
||||
]);
|
||||
expect([...pure]).toEqual(['Login Flow']);
|
||||
});
|
||||
|
||||
it('excludes a flaky flow (failed then passed)', () => {
|
||||
const pure = computePureFailureFlowNames([
|
||||
{ name: 'Flaky Flow', status: 'failed' },
|
||||
{ name: 'Flaky Flow', status: 'passed' },
|
||||
]);
|
||||
expect(pure.size).toBe(0);
|
||||
});
|
||||
|
||||
it('excludes a fail-pass-fail flow (it passed at some point)', () => {
|
||||
const pure = computePureFailureFlowNames([
|
||||
{ name: 'FPF', status: 'failed' },
|
||||
{ name: 'FPF', status: 'passed' },
|
||||
{ name: 'FPF', status: 'failed' },
|
||||
]);
|
||||
expect(pure.size).toBe(0);
|
||||
});
|
||||
|
||||
it('excludes a flow that only passed', () => {
|
||||
expect(computePureFailureFlowNames([{ name: 'Happy', status: 'passed' }]).size).toBe(0);
|
||||
});
|
||||
|
||||
it('normalizes the JUnit name to the screenshot form (/ -> _)', () => {
|
||||
const pure = computePureFailureFlowNames([{ name: 'sub/Login', status: 'failed' }]);
|
||||
expect([...pure]).toEqual(['sub_Login']);
|
||||
});
|
||||
|
||||
it('excludes flows that collide only after / -> _ normalization (one failed, one passed)', () => {
|
||||
const pure = computePureFailureFlowNames([
|
||||
{ name: 'sub/Login', status: 'failed' },
|
||||
{ name: 'sub_Login', status: 'passed' },
|
||||
]);
|
||||
expect(pure.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns an empty set for no testcases', () => {
|
||||
expect(computePureFailureFlowNames([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe(selectFailureScreenshots, () => {
|
||||
function makeShot(flowName: string, attemptIndex: number): HarvestedScreenshot {
|
||||
return {
|
||||
fileAbsPath: `/tmp/${flowName}-${attemptIndex}.png`,
|
||||
displayName: `Failure Screenshot: ${flowName} (attempt ${attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName,
|
||||
attemptIndex,
|
||||
capturedAtMs: 1_000 + attemptIndex,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('keeps only the final attempt for a pure-failure flow', () => {
|
||||
const shots = [makeShot('Pure', 0), makeShot('Pure', 1), makeShot('Pure', 2)];
|
||||
const selected = selectFailureScreenshots(shots, new Set(['Pure']));
|
||||
expect(selected.map(shot => shot.metadata.attemptIndex)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('keeps every attempt for a flow not classified pure (flaky)', () => {
|
||||
const shots = [makeShot('Flaky', 0), makeShot('Flaky', 1)];
|
||||
const selected = selectFailureScreenshots(shots, new Set());
|
||||
expect(selected.map(shot => shot.metadata.attemptIndex)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('reduces pure flows while preserving flaky flows and input order', () => {
|
||||
const shots = [
|
||||
makeShot('Pure', 0),
|
||||
makeShot('Flaky', 0),
|
||||
makeShot('Pure', 1),
|
||||
makeShot('Flaky', 1),
|
||||
];
|
||||
const selected = selectFailureScreenshots(shots, new Set(['Pure']));
|
||||
expect(selected.map(shot => `${shot.metadata.flowName}#${shot.metadata.attemptIndex}`)).toEqual(
|
||||
['Flaky#0', 'Pure#1', 'Flaky#1']
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps all when the pure set is empty', () => {
|
||||
const shots = [makeShot('A', 0), makeShot('A', 1)];
|
||||
expect(selectFailureScreenshots(shots, new Set())).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,10 @@
|
||||
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import * as discovery from '../maestroFlowDiscovery';
|
||||
import * as parser from '../maestroResultParser';
|
||||
import * as maestroScreenshots from '../maestroScreenshots';
|
||||
import { createMaestroTestsBuildFunction } from '../maestroTests';
|
||||
|
||||
jest.mock('@expo/turtle-spawn', () => ({
|
||||
@@ -22,29 +17,7 @@ jest.mock('../../../utils/retry', () => ({
|
||||
sleepAsync: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Partial mock: stub the harvest (filesystem) but keep the real reduction helpers
|
||||
// (computePureFailureFlowNames / selectFailureScreenshots) the step now calls post-loop.
|
||||
jest.mock('../maestroScreenshots', () => ({
|
||||
...jest.requireActual('../maestroScreenshots'),
|
||||
harvestFailureScreenshotsAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedSpawn = jest.mocked(spawn);
|
||||
const mockedHarvest = jest.mocked(maestroScreenshots.harvestFailureScreenshotsAsync);
|
||||
const mockUploadArtifact = jest.fn();
|
||||
|
||||
function makeShot(index: number): maestroScreenshots.HarvestedScreenshot {
|
||||
return {
|
||||
fileAbsPath: path.join(os.tmpdir(), `src-screenshot-${index}.png`),
|
||||
displayName: 'Failure Screenshot: Login (attempt 1)',
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: 'Login',
|
||||
attemptIndex: 0,
|
||||
capturedAtMs: 1781186692250,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const SPAWN_SUCCESS = {
|
||||
status: 0,
|
||||
@@ -68,10 +41,7 @@ function createStep(
|
||||
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
|
||||
> {
|
||||
const logger = createMockLogger();
|
||||
const ctx = {
|
||||
runtimeApi: { uploadArtifact: mockUploadArtifact },
|
||||
} as unknown as CustomBuildContext;
|
||||
const fn = createMaestroTestsBuildFunction(ctx);
|
||||
const fn = createMaestroTestsBuildFunction();
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
|
||||
@@ -84,18 +54,10 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
// hit the real disk. Individual tests override this.
|
||||
jest.restoreAllMocks();
|
||||
jest.spyOn(parser, 'mergeJUnitReports').mockResolvedValue();
|
||||
mockedHarvest.mockReset();
|
||||
mockedHarvest.mockResolvedValue([]);
|
||||
mockUploadArtifact.mockReset();
|
||||
mockUploadArtifact.mockResolvedValue({ artifactId: 'artifact-1' });
|
||||
});
|
||||
|
||||
it('exports a factory that returns a BuildFunction instance', () => {
|
||||
expect(
|
||||
createMaestroTestsBuildFunction({
|
||||
runtimeApi: { uploadArtifact: mockUploadArtifact },
|
||||
} as unknown as CustomBuildContext)
|
||||
).toBeDefined();
|
||||
expect(createMaestroTestsBuildFunction()).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets all outputs before running any flows', async () => {
|
||||
@@ -311,33 +273,6 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the file= path on reports that carry it, without scanning flow files', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'junitFileHasFileAttrs').mockResolvedValue(true);
|
||||
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
|
||||
const legacyParseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
|
||||
const newParseSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromFileAttrs')
|
||||
.mockResolvedValue(['flows/b.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
|
||||
retries: 1,
|
||||
output_format: 'junit',
|
||||
retry_failed_only: true,
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
expect(newParseSpy).toHaveBeenCalledTimes(1);
|
||||
// The new branch needs no name→path map: no scan, no legacy parser.
|
||||
expect(discoverySpy).not.toHaveBeenCalled();
|
||||
expect(legacyParseSpy).not.toHaveBeenCalled();
|
||||
const a1 = mockedSpawn.mock.calls[1][1]!;
|
||||
expect(a1).toContain('flows/b.yaml');
|
||||
expect(a1).not.toContain('flows/a.yaml');
|
||||
});
|
||||
|
||||
it('falls back to all-flows retry when nameToPath is null (duplicate flow names)', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(discovery, 'buildFlowNameToPathMap').mockResolvedValue(null);
|
||||
@@ -358,44 +293,6 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('never scans flow files when all attempts succeed', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
|
||||
|
||||
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(discoverySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('memoizes the flow scan across retries (legacy reports)', async () => {
|
||||
// Routing note: the step lands in the legacy arm because the real
|
||||
// junitFileHasFileAttrs returns false — the attempt XML does not exist on
|
||||
// the mock fs. Creating that file with file= attrs would flip the branch.
|
||||
mockedSpawn
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockRejectedValueOnce(rejectExit1())
|
||||
.mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
const discoverySpy = jest
|
||||
.spyOn(discovery, 'buildFlowNameToPathMap')
|
||||
.mockResolvedValue(new Map([['a', 'flows/a.yaml']]));
|
||||
const parseSpy = jest
|
||||
.spyOn(parser, 'parseFailedFlowsFromJUnit')
|
||||
.mockResolvedValue(['flows/a.yaml']);
|
||||
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
retries: 2,
|
||||
output_format: 'junit',
|
||||
platform: 'android',
|
||||
});
|
||||
await step.executeAsync();
|
||||
|
||||
// Two failed attempts hit the legacy branch twice; the scan runs once.
|
||||
expect(parseSpy).toHaveBeenCalledTimes(2);
|
||||
expect(discoverySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('defaults retry_failed_only to true when omitted from inputs', async () => {
|
||||
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
|
||||
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
|
||||
@@ -742,149 +639,4 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
|
||||
await step.executeAsync(); // should succeed, not throw
|
||||
});
|
||||
|
||||
it('uploads harvested screenshots after the retry loop with metadata', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const shot = makeShot(0);
|
||||
await fs.writeFile(shot.fileAbsPath, '');
|
||||
mockedHarvest.mockResolvedValue([shot]);
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.objectContaining({
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: shot.displayName,
|
||||
metadata: shot.metadata,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads screenshots even when all attempts fail, before throwing ERR_MAESTRO_TESTS_FAILED', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
const shot = makeShot(0);
|
||||
await fs.writeFile(shot.fileAbsPath, '');
|
||||
mockedHarvest.mockResolvedValue([shot]);
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('swallows upload errors without affecting the test verdict', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const shot = makeShot(0);
|
||||
await fs.writeFile(shot.fileAbsPath, '');
|
||||
mockedHarvest.mockResolvedValue([shot]);
|
||||
mockUploadArtifact.mockRejectedValue(new Error('upload boom'));
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips screenshot upload but preserves the verdict when the JUnit re-parse throws', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
mockedHarvest.mockResolvedValue([makeShot(0)]);
|
||||
jest.spyOn(parser, 'parseJUnitTestCases').mockRejectedValue(new Error('malformed junit'));
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
|
||||
// The classify throw is swallowed; the maestro failure verdict still surfaces.
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips screenshot upload but preserves the verdict when the staging dir cannot be created', async () => {
|
||||
mockedSpawn.mockRejectedValue(rejectExit1());
|
||||
mockedHarvest.mockResolvedValue([makeShot(0)]);
|
||||
jest.spyOn(parser, 'parseJUnitTestCases').mockResolvedValue([]);
|
||||
jest.spyOn(fs, 'mkdtemp').mockRejectedValue(new Error('no tmp space'));
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
|
||||
// mkdtemp failure is swallowed; the maestro failure verdict still surfaces.
|
||||
await expect(step.executeAsync()).rejects.toThrow(UserError);
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('caps uploads at 30 screenshots (excess dropped)', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const shots = Array.from({ length: 35 }, (_, index) => makeShot(index));
|
||||
await Promise.all(shots.map(shot => fs.writeFile(shot.fileAbsPath, '')));
|
||||
mockedHarvest.mockResolvedValue(shots);
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockUploadArtifact).toHaveBeenCalledTimes(30);
|
||||
});
|
||||
|
||||
it('does not harvest or upload screenshots when output_format is not junit', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'html' });
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedHarvest).not.toHaveBeenCalled();
|
||||
expect(mockUploadArtifact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uploads only the final attempt for a pure-failure flow, but every attempt for a flaky flow', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
const tc = (name: string, status: 'passed' | 'failed'): parser.JUnitTestCaseResult => ({
|
||||
name,
|
||||
file: undefined,
|
||||
status,
|
||||
duration: 0,
|
||||
errorMessage: null,
|
||||
tags: [],
|
||||
properties: {},
|
||||
});
|
||||
jest
|
||||
.spyOn(parser, 'parseJUnitTestCases')
|
||||
.mockResolvedValue([
|
||||
tc('PureFlow', 'failed'),
|
||||
tc('FlakyFlow', 'failed'),
|
||||
tc('FlakyFlow', 'passed'),
|
||||
]);
|
||||
|
||||
const makeShotFor = (
|
||||
flowName: string,
|
||||
attemptIndex: number
|
||||
): maestroScreenshots.HarvestedScreenshot => ({
|
||||
fileAbsPath: path.join(os.tmpdir(), `wiring-${flowName}-${attemptIndex}.png`),
|
||||
displayName: `Failure Screenshot: ${flowName} (attempt ${attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName,
|
||||
attemptIndex,
|
||||
capturedAtMs: 1781186692250 + attemptIndex,
|
||||
},
|
||||
});
|
||||
const shots = [
|
||||
makeShotFor('PureFlow', 0),
|
||||
makeShotFor('PureFlow', 1),
|
||||
makeShotFor('FlakyFlow', 0),
|
||||
makeShotFor('FlakyFlow', 1),
|
||||
];
|
||||
await Promise.all(shots.map(shot => fs.writeFile(shot.fileAbsPath, '')));
|
||||
mockedHarvest.mockResolvedValue(shots);
|
||||
|
||||
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', output_format: 'junit' });
|
||||
await step.executeAsync();
|
||||
|
||||
// PureFlow keeps only its final attempt (1); FlakyFlow keeps every failed attempt.
|
||||
// Uploads run concurrently, so compare the set, not the order.
|
||||
const uploaded = mockUploadArtifact.mock.calls
|
||||
.map(([arg]) => `${arg.artifact.metadata.flowName}#${arg.artifact.metadata.attemptIndex}`)
|
||||
.sort();
|
||||
expect(uploaded).toEqual(['FlakyFlow#0', 'FlakyFlow#1', 'PureFlow#1']);
|
||||
});
|
||||
});
|
||||
+3
-80
@@ -3,7 +3,6 @@ import { vol } from 'memfs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { createMockLogger } from '../../../__tests__/utils/logger';
|
||||
import * as discovery from '../maestroFlowDiscovery';
|
||||
import { createReportMaestroTestResultsFunction } from '../reportMaestroTestResults';
|
||||
|
||||
const JUNIT_PASS = [
|
||||
@@ -44,24 +43,15 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
} as unknown as Client;
|
||||
});
|
||||
|
||||
// Returns a mock logger whose child() is itself, so step-level log calls can
|
||||
// be asserted on the returned object.
|
||||
function createSelfChildedLogger(): ReturnType<typeof createMockLogger> {
|
||||
const logger = createMockLogger();
|
||||
(logger.child as jest.Mock).mockReturnValue(logger);
|
||||
return logger;
|
||||
}
|
||||
|
||||
function createStep(overrides?: {
|
||||
callInputs?: Record<string, unknown>;
|
||||
staticContextContent?: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
logger?: ReturnType<typeof createMockLogger>;
|
||||
}) {
|
||||
const ctx = { graphqlClient: mockGraphqlClient };
|
||||
const fn = createReportMaestroTestResultsFunction(ctx as any);
|
||||
const globalCtx = createGlobalContextMock({
|
||||
logger: overrides?.logger ?? createMockLogger(),
|
||||
logger: createMockLogger(),
|
||||
projectTargetDirectory: '/root/project',
|
||||
staticContextContent: {
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
@@ -115,54 +105,6 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports both flows when two share a name but have distinct file= paths', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: {
|
||||
createWorkflowDeviceTestCaseResults: [{ id: '1' }, { id: '2' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
await createStep().executeAsync();
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
|
||||
expect(variables.input.testCaseResults.map((r: any) => r.path).sort()).toEqual([
|
||||
'a/login.yaml',
|
||||
'b/login.yaml',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never scans flow files for a file= report, even when flow_path is provided', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
|
||||
mockMutationFn.mockResolvedValue({
|
||||
data: {
|
||||
workflowDeviceTestCaseResult: { createWorkflowDeviceTestCaseResults: [{ id: '1' }] },
|
||||
},
|
||||
});
|
||||
|
||||
await createStep({ callInputs: { flow_path: ['flows/login.yaml'] } }).executeAsync();
|
||||
|
||||
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
|
||||
expect(discoverySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends tags and properties from JUnit XML', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
@@ -279,7 +221,7 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
await createStep().executeAsync();
|
||||
});
|
||||
|
||||
it('skips report when duplicate testcase names found (legacy), advising unique names', async () => {
|
||||
it('skips report when duplicate testcase names found', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
@@ -296,27 +238,8 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const logger = createSelfChildedLogger();
|
||||
await createStep({ logger }).executeAsync();
|
||||
await createStep().executeAsync();
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('unique name'));
|
||||
});
|
||||
|
||||
it('skips report when the same flow file appears twice in one attempt, advising flow_path check', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
'<testsuites><testsuite>',
|
||||
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
|
||||
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="2.0"/>',
|
||||
'</testsuite></testsuites>',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
const logger = createSelfChildedLogger();
|
||||
await createStep({ logger }).executeAsync();
|
||||
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('flow_path entries'));
|
||||
});
|
||||
|
||||
it('sends per-attempt results with same name but different retryCount', async () => {
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
|
||||
import {
|
||||
MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
warnIfArgentPackageVersionCannotBeVerified,
|
||||
} from '../startArgentRemoteSession';
|
||||
|
||||
describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
const warn = jest.fn();
|
||||
const logger = { warn } as unknown as bunyan;
|
||||
const supportedVersion = '999.0.0';
|
||||
const oldVersion = '0.0.0';
|
||||
|
||||
beforeEach(() => {
|
||||
warn.mockClear();
|
||||
});
|
||||
|
||||
it('allows the default latest package version', () => {
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: undefined, logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'latest', logger })
|
||||
).not.toThrow();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows exact supported semver versions', () => {
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion: MIN_ARGENT_REMOTE_SESSION_VERSION,
|
||||
logger,
|
||||
})
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: supportedVersion, logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion: `v${supportedVersion}`,
|
||||
logger,
|
||||
})
|
||||
).not.toThrow();
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects exact semver versions that are too old', () => {
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
|
||||
).toThrow(SystemError);
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: oldVersion, logger })
|
||||
).toThrow(`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}`);
|
||||
});
|
||||
|
||||
it('warns for package tags or ranges that cannot be verified', () => {
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion: 'next', logger })
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion: `^${MIN_ARGENT_REMOTE_SESSION_VERSION}`,
|
||||
logger,
|
||||
})
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting bunx resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -120,44 +120,4 @@ describe(createUploadArtifactBuildFunction, () => {
|
||||
const typeInput = buildStep.inputs?.find(input => input.id === 'type')!;
|
||||
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
|
||||
});
|
||||
|
||||
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
|
||||
contextUploadArtifact.mockClear();
|
||||
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
|
||||
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: {
|
||||
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
|
||||
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
|
||||
type: 'other',
|
||||
key: 'Screen Recording',
|
||||
path: '/',
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(contextUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
|
||||
);
|
||||
});
|
||||
|
||||
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
|
||||
contextUploadArtifact.mockClear();
|
||||
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
|
||||
callInputs: {
|
||||
type: 'build-artifact',
|
||||
path: '/',
|
||||
metadata: { kind: 'screen-recording' },
|
||||
},
|
||||
});
|
||||
|
||||
await buildStep.executeAsync();
|
||||
|
||||
expect(contextUploadArtifact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
artifact: expect.not.objectContaining({ metadata: expect.anything() }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
BuildStepInputValueTypeName,
|
||||
BuildStepOutput,
|
||||
} from '@expo/steps';
|
||||
import { Client } from '@urql/core';
|
||||
import { glob } from 'fast-glob';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch from 'node-fetch';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -18,7 +16,6 @@ import stream from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
@@ -26,21 +23,7 @@ import { pluralize } from '../../utils/strings';
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline);
|
||||
|
||||
const BUILD_BY_ID_QUERY = graphql(`
|
||||
query DownloadBuildByIdQuery($buildId: ID!) {
|
||||
builds {
|
||||
byId(buildId: $buildId) {
|
||||
id
|
||||
platform
|
||||
artifacts {
|
||||
applicationArchiveUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
export function createDownloadBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'download_build',
|
||||
@@ -76,7 +59,7 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions,
|
||||
});
|
||||
@@ -86,45 +69,16 @@ export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunct
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchApplicationArchiveUrlAsync({
|
||||
buildId,
|
||||
graphqlClient,
|
||||
}: {
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
}): Promise<string> {
|
||||
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
|
||||
|
||||
if (result.error) {
|
||||
const { error } = result;
|
||||
const message = `Could not fetch build ${buildId}: ${error.message}`;
|
||||
|
||||
throw error.networkError || result.error.response?.status >= 500
|
||||
? new SystemError(message, { cause: error })
|
||||
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
|
||||
}
|
||||
|
||||
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
|
||||
if (!applicationArchiveUrl) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
|
||||
'Build does not have an application archive url'
|
||||
);
|
||||
}
|
||||
|
||||
return applicationArchiveUrl;
|
||||
}
|
||||
|
||||
export async function downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
graphqlClient,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
extensions,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
buildId: string;
|
||||
graphqlClient: Client;
|
||||
expoApiServerURL: string;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
}): Promise<{ artifactPath: string }> {
|
||||
@@ -132,11 +86,12 @@ export async function downloadBuildAsync({
|
||||
path.join(os.tmpdir(), 'download_build-downloaded-')
|
||||
);
|
||||
|
||||
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
|
||||
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
|
||||
});
|
||||
const response = await retryOnDNSFailure(fetch)(
|
||||
new URL(`/v2/artifacts/eas/${buildId}`, expoApiServerURL),
|
||||
{
|
||||
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const textResult = await asyncResult(response.text());
|
||||
|
||||
@@ -26,14 +26,14 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
|
||||
try {
|
||||
await uploadApplicationArchivesAsync({ ctx, stepCtx });
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error({ err }, `Failed to upload application archives.`);
|
||||
stepCtx.logger.error(`Failed to upload application archives.`, err);
|
||||
firstError ||= err;
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadBuildArtifacts({ ctx, stepCtx });
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error({ err }, `Failed to upload build artifacts.`);
|
||||
stepCtx.logger.error(`Failed to upload build artifacts.`, err);
|
||||
firstError ||= err;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
@@ -19,7 +18,6 @@ const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
||||
|
||||
export interface JUnitTestCaseResult {
|
||||
name: string;
|
||||
file: string | undefined;
|
||||
status: 'passed' | 'failed';
|
||||
duration: number; // milliseconds
|
||||
errorMessage: string | null;
|
||||
@@ -34,12 +32,6 @@ const xmlParser = new XMLParser({
|
||||
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
|
||||
});
|
||||
|
||||
// A `file=` attribute counts as present only when it is a non-empty string.
|
||||
function fileAttrOf(tc: any): string | undefined {
|
||||
const f = tc?.['@_file'];
|
||||
return typeof f === 'string' && f.length > 0 ? f : undefined;
|
||||
}
|
||||
|
||||
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
const results: JUnitTestCaseResult[] = [];
|
||||
try {
|
||||
@@ -62,8 +54,6 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
const file = fileAttrOf(tc);
|
||||
|
||||
const timeStr = tc['@_time'];
|
||||
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
|
||||
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
|
||||
@@ -104,7 +94,7 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
: [];
|
||||
delete properties['tags'];
|
||||
|
||||
results.push({ name, file, status, duration, errorMessage, tags, properties });
|
||||
results.push({ name, status, duration, errorMessage, tags, properties });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -141,142 +131,6 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
|
||||
return perFile.flat();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Maestro >= 2.6.0 code path: every <testcase> carries a `file=` attribute with
|
||||
// its flow's own path (all-or-none per run). Callers check the report first
|
||||
// (isFileAttrRun / junitFileHasFileAttrs) and route here; the legacy name→path
|
||||
// scan is then never consulted.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isFileAttrRun(
|
||||
testcases: JUnitTestCaseResult[]
|
||||
): testcases is (JUnitTestCaseResult & { file: string })[] {
|
||||
return testcases.length > 0 && testcases.every(tc => tc.file !== undefined);
|
||||
}
|
||||
|
||||
// Unreadable or attribute-less reports ⇒ false: the caller then takes the
|
||||
// legacy path, whose own guards degrade safely.
|
||||
export async function junitFileHasFileAttrs(junitFile: string): Promise<boolean> {
|
||||
return isFileAttrRun(await parseJUnitFile(junitFile));
|
||||
}
|
||||
|
||||
async function fileExists(absPath: string): Promise<boolean> {
|
||||
return (await asyncResult(fs.stat(absPath))).ok;
|
||||
}
|
||||
|
||||
// Group by `file=` so two same-named flows in different files stay separate.
|
||||
export async function parseMaestroResultsFromFileAttrs(
|
||||
junitDirectory: string
|
||||
): Promise<MaestroFlowResult[]> {
|
||||
interface JUnitEntry {
|
||||
result: JUnitTestCaseResult;
|
||||
sourceFile: string;
|
||||
}
|
||||
|
||||
let junitEntries: string[];
|
||||
try {
|
||||
junitEntries = await fs.readdir(junitDirectory);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
|
||||
|
||||
const entries: JUnitEntry[] = [];
|
||||
for (const xmlFile of xmlFiles) {
|
||||
const fileResults = await parseJUnitFile(path.join(junitDirectory, xmlFile));
|
||||
for (const result of fileResults) {
|
||||
entries.push({ result, sourceFile: xmlFile });
|
||||
}
|
||||
}
|
||||
|
||||
const byPath = new Map<string, JUnitEntry[]>();
|
||||
for (const entry of entries) {
|
||||
// Callers verify the report with isFileAttrRun/junitFileHasFileAttrs first;
|
||||
// skip (rather than emit an undefined path) if that contract is violated.
|
||||
const flowPath = entry.result.file;
|
||||
if (flowPath === undefined) {
|
||||
continue;
|
||||
}
|
||||
const group = byPath.get(flowPath) ?? [];
|
||||
group.push(entry);
|
||||
byPath.set(flowPath, group);
|
||||
}
|
||||
|
||||
const results: MaestroFlowResult[] = [];
|
||||
for (const [flowPath, group] of byPath) {
|
||||
// retryCount is derived from each entry's source filename (`attempt-N`);
|
||||
// a single entry from `report.xml` (no marker) collapses to attempt 0.
|
||||
const sorted = group
|
||||
.map(entry => {
|
||||
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
return { ...entry, attemptIndex };
|
||||
})
|
||||
.sort((a, b) => a.attemptIndex - b.attemptIndex);
|
||||
|
||||
for (const { result, attemptIndex } of sorted) {
|
||||
results.push({
|
||||
name: result.name,
|
||||
path: flowPath,
|
||||
status: result.status,
|
||||
errorMessage: result.errorMessage,
|
||||
duration: result.duration,
|
||||
retryCount: attemptIndex,
|
||||
tags: result.tags,
|
||||
properties: result.properties,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `file=` paths of the failing testcases in the given attempt's
|
||||
* JUnit file, or `null` when the result cannot be trusted (caller then falls
|
||||
* back to dumb retry — re-run everything; never the legacy scan).
|
||||
*
|
||||
* Callers verify the report carries `file=` attributes first
|
||||
* (junitFileHasFileAttrs).
|
||||
*/
|
||||
export async function parseFailedFlowsFromFileAttrs(args: {
|
||||
junitFile: string;
|
||||
workingDirectory: string;
|
||||
}): Promise<string[] | null> {
|
||||
// Same hardening as the legacy parser: fast-xml-parser is lenient — a
|
||||
// truncated XML could drop testcases, and trusting that partial failing set
|
||||
// would skip retries for cut-off flows.
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(args.junitFile, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (XMLValidator.validate(content) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const testcases = parseJUnitContent(content);
|
||||
if (!isFileAttrRun(testcases)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failing = testcases.filter(tc => tc.status === 'failed');
|
||||
if (failing.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Each failing testcase is self-identifying via `file=`, so duplicate flow
|
||||
// names retry correctly. path.resolve (not join): `file=` may be absolute
|
||||
// when the flow lives outside the working directory.
|
||||
const paths = [...new Set(failing.map(tc => tc.file))];
|
||||
for (const p of paths) {
|
||||
if (!(await fileExists(path.resolve(args.workingDirectory, p)))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export async function parseMaestroResults(
|
||||
junitDirectory: string,
|
||||
nameToPath: Map<string, string> | null
|
||||
@@ -459,13 +313,6 @@ export async function mergeJUnitReports(args: {
|
||||
}
|
||||
const match = filename.match(ATTEMPT_PATTERN);
|
||||
const attemptIndex = match ? parseInt(match[1], 10) : 0;
|
||||
// Maestro >= 2.6.0 reports: key by `file=` so two same-named flows in
|
||||
// different files merge separately. Legacy reports keep name keying.
|
||||
const allCases = testsuites.flatMap((suite: any) =>
|
||||
Array.isArray(suite?.testcase) ? suite.testcase : []
|
||||
);
|
||||
const useFileKeys =
|
||||
allCases.length > 0 && allCases.every((tc: any) => fileAttrOf(tc) !== undefined);
|
||||
const testcasesByName = new Map<string, unknown[]>();
|
||||
for (const suite of testsuites) {
|
||||
const cases = suite?.testcase;
|
||||
@@ -477,10 +324,9 @@ export async function mergeJUnitReports(args: {
|
||||
if (typeof name !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const key = useFileKeys ? fileAttrOf(tc)! : name;
|
||||
const group = testcasesByName.get(key) ?? [];
|
||||
const group = testcasesByName.get(name) ?? [];
|
||||
group.push(tc);
|
||||
testcasesByName.set(key, group);
|
||||
testcasesByName.set(name, group);
|
||||
}
|
||||
}
|
||||
if (testcasesByName.size === 0) {
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { type Dirent } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export interface ParsedFailureScreenshot {
|
||||
flowName: string;
|
||||
capturedAtMs: number;
|
||||
}
|
||||
|
||||
// Maestro failure screenshot: `screenshot-[shard-N-]❌-<epochMillis>-(<flowName>).png`.
|
||||
// The flow name may contain parentheses, so anchor on the `-(` after the epoch and the
|
||||
// `).png` suffix rather than scanning for parens.
|
||||
const FAILURE_SCREENSHOT_PATTERN = /^screenshot-(?:shard-\d+-)?❌-(\d+)-\((.+)\)\.png$/u;
|
||||
|
||||
export function parseFailureScreenshotFilename(filename: string): ParsedFailureScreenshot | null {
|
||||
const match = filename.match(FAILURE_SCREENSHOT_PATTERN);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const capturedAtMs = Number(match[1]);
|
||||
if (!Number.isFinite(capturedAtMs)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
flowName: match[2],
|
||||
capturedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
export interface HarvestedScreenshot {
|
||||
fileAbsPath: string;
|
||||
displayName: string; // `Failure Screenshot: <flowName> (attempt <n>)`
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot';
|
||||
// The Maestro flow name straight from the filename (already '/'->'_' substituted by
|
||||
// Maestro). The website matches this against `attempt.name` (normalized the same way).
|
||||
flowName: string;
|
||||
attemptIndex: number;
|
||||
capturedAtMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Scans every debug dir under testsDirectory whose mtime is recent enough (mtime-based to cover
|
||||
// the maestro <2.5.0 bug that splits one invocation across two dirs), then keeps only screenshots
|
||||
// whose own capturedAtMs is at/after capturedSinceMs. Never throws: an unreadable dir is logged
|
||||
// and skipped so a screenshot harvest can't affect the maestro step outcome.
|
||||
export async function harvestFailureScreenshotsAsync(args: {
|
||||
testsDirectory: string;
|
||||
capturedSinceMs: number;
|
||||
attemptIndex: number;
|
||||
logger: bunyan;
|
||||
}): Promise<HarvestedScreenshot[]> {
|
||||
// Gate dirs by mtime floored to the second, so a dir created within the same second as the
|
||||
// attempt start isn't stat'd below the baseline. The exact capturedAtMs gate below is what
|
||||
// prevents mis-attributing an earlier attempt's screenshot.
|
||||
const dirSinceMtimeMs = Math.floor(args.capturedSinceMs / 1000) * 1000;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(args.testsDirectory, { withFileTypes: true });
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping screenshot harvest: cannot read ${args.testsDirectory}.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: HarvestedScreenshot[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const dirPath = path.join(args.testsDirectory, entry.name);
|
||||
let filenames: string[];
|
||||
try {
|
||||
if ((await fs.stat(dirPath)).mtimeMs < dirSinceMtimeMs) {
|
||||
continue;
|
||||
}
|
||||
filenames = await fs.readdir(dirPath);
|
||||
} catch (err: any) {
|
||||
args.logger.info({ err }, `Skipping unreadable debug dir ${dirPath}.`);
|
||||
continue;
|
||||
}
|
||||
for (const filename of filenames) {
|
||||
const parsed = parseFailureScreenshotFilename(filename);
|
||||
// Re-check capture time per file: a dir's mtime can advance after this attempt began (or
|
||||
// fall within the floored-second dir baseline), so the dir gate alone would re-attribute an
|
||||
// earlier attempt's screenshot to this one.
|
||||
if (parsed === null || parsed.capturedAtMs < args.capturedSinceMs) {
|
||||
continue;
|
||||
}
|
||||
results.push({
|
||||
fileAbsPath: path.join(dirPath, filename),
|
||||
displayName: `Failure Screenshot: ${parsed.flowName} (attempt ${args.attemptIndex + 1})`,
|
||||
metadata: {
|
||||
kind: 'maestro-test-screenshot',
|
||||
flowName: parsed.flowName,
|
||||
attemptIndex: args.attemptIndex,
|
||||
capturedAtMs: parsed.capturedAtMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// Maestro substitutes '/' -> '_' in screenshot filenames (so HarvestedScreenshot.flowName
|
||||
// is already in that form) but keeps '/' in the JUnit testcase name. Normalize the JUnit
|
||||
// name the same way before comparing — matching the website's own normalization.
|
||||
function normalizeFlowName(name: string): string {
|
||||
return name.replace(/\//g, '_');
|
||||
}
|
||||
|
||||
// A flow is a "pure failure" when it failed and never passed across all attempts. The website
|
||||
// surfaces only the final screenshot for such a flow, so keeping its earlier attempts would
|
||||
// spend the per-job artifact budget on screenshots that are never shown — we keep only the final
|
||||
// one (selectFailureScreenshots). A flow that passed at least once (flaky / fail-pass-fail) is
|
||||
// NOT pure: each of its failed attempts is a distinct failure and is kept.
|
||||
export function computePureFailureFlowNames(
|
||||
testCases: readonly { name: string; status: 'passed' | 'failed' }[]
|
||||
): Set<string> {
|
||||
const passed = new Set<string>();
|
||||
const failed = new Set<string>();
|
||||
for (const testCase of testCases) {
|
||||
const flowName = normalizeFlowName(testCase.name);
|
||||
if (testCase.status === 'passed') {
|
||||
passed.add(flowName);
|
||||
} else {
|
||||
failed.add(flowName);
|
||||
}
|
||||
}
|
||||
const pure = new Set<string>();
|
||||
for (const flowName of failed) {
|
||||
if (!passed.has(flowName)) {
|
||||
pure.add(flowName);
|
||||
}
|
||||
}
|
||||
return pure;
|
||||
}
|
||||
|
||||
// Keep every harvested screenshot for flaky/mixed flows; for pure-failure flows keep only
|
||||
// the final (highest attemptIndex) screenshot.
|
||||
export function selectFailureScreenshots(
|
||||
harvested: readonly HarvestedScreenshot[],
|
||||
pureFailureFlowNames: ReadonlySet<string>
|
||||
): HarvestedScreenshot[] {
|
||||
const finalAttemptByPureFlow = new Map<string, number>();
|
||||
for (const shot of harvested) {
|
||||
if (!pureFailureFlowNames.has(shot.metadata.flowName)) {
|
||||
continue;
|
||||
}
|
||||
const finalAttempt = finalAttemptByPureFlow.get(shot.metadata.flowName);
|
||||
if (finalAttempt === undefined || shot.metadata.attemptIndex > finalAttempt) {
|
||||
finalAttemptByPureFlow.set(shot.metadata.flowName, shot.metadata.attemptIndex);
|
||||
}
|
||||
}
|
||||
return harvested.filter(shot => {
|
||||
const finalAttempt = finalAttemptByPureFlow.get(shot.metadata.flowName);
|
||||
return finalAttempt === undefined || shot.metadata.attemptIndex === finalAttempt;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -9,26 +8,15 @@ import {
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
junitFileHasFileAttrs,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseJUnitTestCases,
|
||||
} from './maestroResultParser';
|
||||
import {
|
||||
type HarvestedScreenshot,
|
||||
computePureFailureFlowNames,
|
||||
harvestFailureScreenshotsAsync,
|
||||
selectFailureScreenshots,
|
||||
} from './maestroScreenshots';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
|
||||
const FlowPathSchema = z.array(z.string().min(1)).min(1);
|
||||
@@ -91,7 +79,7 @@ function buildMaestroArgs(args: {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'maestro_tests',
|
||||
@@ -212,10 +200,13 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
throw new SystemError('Failed to create JUnit report directory', { cause: err });
|
||||
}
|
||||
|
||||
// Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
|
||||
// flow scan is built lazily in the retry branch below and memoized so
|
||||
// retries share one scan. Never runs when the report has `file=`.
|
||||
let nameToPathPromise: Promise<Map<string, string> | null> | undefined;
|
||||
// null → duplicate flow names; retry-failed-only disabled, fall through to
|
||||
// dumb retry (re-run everything) on failure.
|
||||
const nameToPath = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
});
|
||||
|
||||
// Retry loop. spawn-async error shapes:
|
||||
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
|
||||
@@ -223,11 +214,10 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
|
||||
// downgraded to "tests failed".
|
||||
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
|
||||
// flows. The failed-flow parsers return null when the JUnit cannot be
|
||||
// flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
|
||||
// trusted; we then fall through to dumb retry (re-run everything).
|
||||
let flowsToRun: string[] = flowPaths;
|
||||
let lastAttemptExitCode: number | null = null;
|
||||
const harvested: HarvestedScreenshot[] = [];
|
||||
|
||||
const totalAttempts = retries + 1;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
@@ -250,8 +240,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
|
||||
);
|
||||
|
||||
const attemptStartedAtMs = Date.now();
|
||||
|
||||
try {
|
||||
await spawn('maestro', maestroArgs, {
|
||||
cwd: stepCtx.workingDirectory,
|
||||
@@ -271,44 +259,15 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}
|
||||
}
|
||||
|
||||
// Harvest this attempt's failure screenshots before any retry subsetting. Gated on
|
||||
// junit: test-case-result rows (and therefore the summary icons) only exist for junit
|
||||
// runs, so harvesting other formats would just create orphan artifacts the website hides.
|
||||
if (outputFormat === 'junit') {
|
||||
harvested.push(
|
||||
...(await harvestFailureScreenshotsAsync({
|
||||
testsDirectory,
|
||||
capturedSinceMs: attemptStartedAtMs,
|
||||
attemptIndex: attempt,
|
||||
logger,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (lastAttemptExitCode === 0 || attempt === retries) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
|
||||
let failed: string[] | null;
|
||||
if (await junitFileHasFileAttrs(outputPath)) {
|
||||
failed = await parseFailedFlowsFromFileAttrs({
|
||||
junitFile: outputPath,
|
||||
workingDirectory: stepCtx.workingDirectory,
|
||||
});
|
||||
} else {
|
||||
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
|
||||
// paths via the flow-file scan. DELETE this arm once the fleet is
|
||||
// on >= 2.6.0.
|
||||
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
|
||||
inputFlowPaths: flowPaths,
|
||||
projectRoot: stepCtx.workingDirectory,
|
||||
logger,
|
||||
}));
|
||||
failed = nameToPath
|
||||
? await parseFailedFlowsFromJUnit({ junitFile: outputPath, nameToPath })
|
||||
: null;
|
||||
}
|
||||
if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
|
||||
const failed = await parseFailedFlowsFromJUnit({
|
||||
junitFile: outputPath,
|
||||
nameToPath,
|
||||
});
|
||||
if (failed !== null && failed.length > 0) {
|
||||
flowsToRun = failed;
|
||||
logger.info(
|
||||
@@ -357,12 +316,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
}
|
||||
}
|
||||
|
||||
// Upload before the ERR_MAESTRO_TESTS_FAILED throw below so fully-failed runs (which need
|
||||
// screenshots most) still upload. Harvest only ran for junit, so guard the same way.
|
||||
if (outputFormat === 'junit') {
|
||||
await uploadFailureScreenshotsAsync({ harvested, junitReportDirectory, ctx, logger });
|
||||
}
|
||||
|
||||
// The retry loop exits via success (0), numeric status (retryable),
|
||||
// or throw (infra). A non-null non-zero status means the user's tests
|
||||
// failed every attempt.
|
||||
@@ -375,82 +328,3 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Reduce harvested failure screenshots to what's worth uploading, then upload them as workflow
|
||||
// artifacts. Best-effort and verdict-neutral: never throws, so a screenshot problem can't mask
|
||||
// the maestro test result. Caller guards on junit (harvest only runs for junit).
|
||||
async function uploadFailureScreenshotsAsync({
|
||||
harvested,
|
||||
junitReportDirectory,
|
||||
ctx,
|
||||
logger,
|
||||
}: {
|
||||
harvested: HarvestedScreenshot[];
|
||||
junitReportDirectory: string;
|
||||
ctx: CustomBuildContext;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
// Reduce to the attempts worth uploading — every failed attempt for flaky flows, only the final
|
||||
// attempt for all-failed flows. See computePureFailureFlowNames / selectFailureScreenshots.
|
||||
// Guard the JUnit re-parse so a malformed/missing report can't throw past here and mask the
|
||||
// test verdict (the whole step is verdict-neutral for screenshots).
|
||||
let selected: HarvestedScreenshot[];
|
||||
try {
|
||||
const pureFailureFlowNames = computePureFailureFlowNames(
|
||||
await parseJUnitTestCases(junitReportDirectory)
|
||||
);
|
||||
selected = selectFailureScreenshots(harvested, pureFailureFlowNames);
|
||||
} catch (err: any) {
|
||||
logger.warn({ err }, 'Failed to classify failure screenshots; skipping screenshot upload.');
|
||||
return;
|
||||
}
|
||||
if (selected.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cap well under www's 50-artifact-per-job limit.
|
||||
const MAX_SCREENSHOT_UPLOADS = 30;
|
||||
const toUpload = selected.slice(0, MAX_SCREENSHOT_UPLOADS);
|
||||
if (selected.length > toUpload.length) {
|
||||
logger.warn(
|
||||
`Found ${selected.length} failure screenshots; uploading only the first ${toUpload.length}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Copy each shot to an ASCII-safe name outside testsDirectory (the originals contain a
|
||||
// non-ASCII marker and testsDirectory is uploaded wholesale as the tarball).
|
||||
let safeScreenshotDir: string;
|
||||
try {
|
||||
safeScreenshotDir = await fs.mkdtemp(path.join(os.tmpdir(), 'eas-maestro-screenshots-'));
|
||||
} catch (err: any) {
|
||||
logger.warn(
|
||||
{ err },
|
||||
'Failed to create the failure-screenshot staging dir; skipping screenshot upload.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
toUpload.map(async (shot, index) => {
|
||||
try {
|
||||
// `index` disambiguates two flows that fail within the same millisecond of an attempt.
|
||||
const safePath = path.join(
|
||||
safeScreenshotDir,
|
||||
`failure-attempt-${shot.metadata.attemptIndex}-${index}-${shot.metadata.capturedAtMs}.png`
|
||||
);
|
||||
await fs.copyFile(shot.fileAbsPath, safePath);
|
||||
await ctx.runtimeApi.uploadArtifact({
|
||||
artifact: {
|
||||
type: GenericArtifactType.OTHER,
|
||||
name: shot.displayName,
|
||||
paths: [safePath],
|
||||
metadata: shot.metadata,
|
||||
},
|
||||
logger,
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.warn({ err }, 'Failed to upload failure screenshot.');
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,7 @@ import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import {
|
||||
MaestroFlowResult,
|
||||
isFileAttrRun,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
} from './maestroResultParser';
|
||||
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
const FlowPathSchema = z.array(z.string().min(1)).min(1);
|
||||
@@ -67,64 +61,47 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Maestro >= 2.6.0 stamps every <testcase> with its flow's path in a
|
||||
// `file=` attribute; older reports need the legacy flow-file scan to
|
||||
// map testcase names back to paths.
|
||||
const usedFileAttrs = isFileAttrRun(await parseJUnitTestCases(junitDirectory));
|
||||
|
||||
let flowResults: MaestroFlowResult[];
|
||||
if (usedFileAttrs) {
|
||||
flowResults = await parseMaestroResultsFromFileAttrs(junitDirectory);
|
||||
const flowPathRaw = inputs.flow_path.value;
|
||||
let nameToPath: Map<string, string> | null = null;
|
||||
if (flowPathRaw !== undefined) {
|
||||
const parsed = FlowPathSchema.safeParse(flowPathRaw);
|
||||
if (parsed.success) {
|
||||
nameToPath = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: parsed.data,
|
||||
projectRoot: stepsCtx.workingDirectory,
|
||||
logger,
|
||||
});
|
||||
} else {
|
||||
// Legacy (Maestro < 2.6.0) — DELETE this arm once the fleet is on >= 2.6.0.
|
||||
const flowPathRaw = inputs.flow_path.value;
|
||||
let nameToPath: Map<string, string> | null = null;
|
||||
if (flowPathRaw !== undefined) {
|
||||
const parsed = FlowPathSchema.safeParse(flowPathRaw);
|
||||
if (parsed.success) {
|
||||
nameToPath = await buildFlowNameToPathMap({
|
||||
inputFlowPaths: parsed.data,
|
||||
projectRoot: stepsCtx.workingDirectory,
|
||||
logger,
|
||||
});
|
||||
} else {
|
||||
logger.warn(
|
||||
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
|
||||
);
|
||||
}
|
||||
}
|
||||
flowResults = await parseMaestroResults(junitDirectory, nameToPath);
|
||||
logger.warn(
|
||||
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const flowResults = await parseMaestroResults(junitDirectory, nameToPath);
|
||||
if (flowResults.length === 0) {
|
||||
logger.info('No maestro test results found, skipping report');
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect truly conflicting results: the same (path, retryCount) twice means two
|
||||
// flow files resolved to the same path, which we can't disambiguate and the API
|
||||
// rejects as a duplicate. Same path with different retryCount is expected
|
||||
// (per-attempt results from retries).
|
||||
// Detect truly conflicting results: same (name, retryCount) pair means different flow files
|
||||
// share the same name (Maestro config override), which we can't disambiguate.
|
||||
// Same name with different retryCount is expected (per-attempt results from retries).
|
||||
const seen = new Set<string>();
|
||||
const conflicting = new Set<string>();
|
||||
for (const r of flowResults) {
|
||||
const key = `${r.path}:${r.retryCount}`;
|
||||
const key = `${r.name}:${r.retryCount}`;
|
||||
if (seen.has(key)) {
|
||||
conflicting.add(r.path);
|
||||
conflicting.add(r.name);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
if (conflicting.size > 0) {
|
||||
const conflictList = [...conflicting].join(', ');
|
||||
logger.error(
|
||||
usedFileAttrs
|
||||
? `The same flow file was reported more than once in a single attempt: ` +
|
||||
`${conflictList}. Skipping report. Check for duplicate flow_path entries ` +
|
||||
`or leftover XML files in the JUnit report directory.`
|
||||
: `Duplicate Maestro flow names found: ${conflictList}. Skipping report. ` +
|
||||
`Give each flow a unique name, or upgrade Maestro to >= 2.6.0 (flows are ` +
|
||||
`then identified by file path, so duplicate names are fine).`
|
||||
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
|
||||
', '
|
||||
)}. Skipping report. Ensure each Maestro flow has a unique name.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,16 +206,16 @@ export async function restoreGradleCacheAsync({
|
||||
}
|
||||
|
||||
try {
|
||||
// Enable build cache via user-level gradle.properties.
|
||||
// This avoids mutating android/gradle.properties which affects fingerprinting.
|
||||
const gradleUserHome = path.join(os.homedir(), '.gradle');
|
||||
await fs.promises.mkdir(gradleUserHome, { recursive: true });
|
||||
const userGradlePropertiesPath = path.join(gradleUserHome, 'gradle.properties');
|
||||
await fs.promises.appendFile(userGradlePropertiesPath, '\norg.gradle.caching=true\n');
|
||||
const gradlePropertiesPath = path.join(workingDirectory, 'android', 'gradle.properties');
|
||||
const gradlePropertiesContent = await fs.promises.readFile(gradlePropertiesPath, 'utf-8');
|
||||
await fs.promises.writeFile(
|
||||
gradlePropertiesPath,
|
||||
`${gradlePropertiesContent}\n\norg.gradle.caching=true\n`
|
||||
);
|
||||
|
||||
// Configure cache cleanup via init script.
|
||||
// Works with both Gradle 8 and 9 (org.gradle.cache.cleanup property was removed in Gradle 9).
|
||||
const initScriptDir = path.join(gradleUserHome, 'init.d');
|
||||
// Configure cache cleanup via init script (works with both Gradle 8 and 9,
|
||||
// org.gradle.cache.cleanup property was removed in Gradle 9)
|
||||
const initScriptDir = path.join(os.homedir(), '.gradle', 'init.d');
|
||||
await fs.promises.mkdir(initScriptDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
|
||||
|
||||
@@ -141,6 +141,10 @@ export async function uploadCacheAsync({
|
||||
return;
|
||||
}
|
||||
const textResult = await asyncResult(response.text());
|
||||
const errorMessage = extractValidationErrorMessage(textResult.value);
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
|
||||
}
|
||||
|
||||
@@ -231,6 +235,19 @@ export async function uploadPublicCacheAsync({
|
||||
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
|
||||
}
|
||||
|
||||
function extractValidationErrorMessage(body: string | undefined): string | undefined {
|
||||
if (!body) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const { errors } = JSON.parse(body);
|
||||
if (errors?.[0]?.code === 'VALIDATION_ERROR') {
|
||||
return errors[0].message;
|
||||
}
|
||||
} catch {}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function compressCacheAsync({
|
||||
paths,
|
||||
workingDirectory,
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
@@ -31,6 +30,7 @@ const AGENT_DEVICE_PACKAGE_NAME = 'agent-device';
|
||||
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
|
||||
const SRC_DIR = '/tmp/agent-device-src';
|
||||
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
@@ -64,7 +64,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
}
|
||||
|
||||
logger.info('Launching agent-device daemon.');
|
||||
@@ -89,7 +90,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
// on Darwin. Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
const { previewUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
const { previewUrl } = await startServeSimWithTunnelAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -10,16 +9,13 @@ import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
@@ -28,15 +24,11 @@ import {
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
|
||||
export const MIN_ARGENT_REMOTE_SESSION_VERSION = '0.12.0';
|
||||
const ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG = 'artifacts-list-endpoint';
|
||||
const ARGENT_STATE_FILE = path.join(os.homedir(), '.argent', 'tool-server.json');
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
const ArgentToolServerStateSchema = z.object({
|
||||
port: z.number(),
|
||||
token: z.string().optional(),
|
||||
});
|
||||
const ArgentToolServerStateSchema = z.object({ port: z.number() });
|
||||
|
||||
export function createStartArgentRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
@@ -63,7 +55,6 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);
|
||||
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
logger.info(
|
||||
@@ -71,76 +62,46 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
}
|
||||
|
||||
// Stale state from a previous run would mask the new server's port.
|
||||
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
await spawn(
|
||||
'bunx',
|
||||
[`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG],
|
||||
{ env, logger }
|
||||
);
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
|
||||
const argentServer = spawnDetached({
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} via bunx.`);
|
||||
// `argent mcp` is the public entry that triggers @argent/tools-client
|
||||
// to spawn the tool-server detached + unref'd, so the tool-server
|
||||
// outlives this MCP process. ARGENT_IDLE_TIMEOUT_MINUTES=0 disables the
|
||||
// 30-min idle shutdown that would otherwise tear the tunnel down.
|
||||
spawnDetached({
|
||||
command: 'bunx',
|
||||
args: [
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--detach',
|
||||
],
|
||||
env,
|
||||
args: [`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'mcp'],
|
||||
env: { ...env, ARGENT_IDLE_TIMEOUT_MINUTES: '0' },
|
||||
});
|
||||
|
||||
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
|
||||
let toolServerPort: number;
|
||||
let toolServerToken: string | undefined;
|
||||
try {
|
||||
const toolServerState = await waitForFileAsync({
|
||||
filePath: ARGENT_STATE_FILE,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'argent tool-server state',
|
||||
parse: parseArgentToolServerState,
|
||||
});
|
||||
toolServerPort = toolServerState.port;
|
||||
toolServerToken = toolServerState.token;
|
||||
} catch (err) {
|
||||
const output = argentServer.getOutput();
|
||||
throw new SystemError(
|
||||
`${
|
||||
err instanceof Error ? err.message : `Timed out waiting for argent tool-server state.`
|
||||
}${output ? `\nArgent tool-server output:\n${output}` : ''}`
|
||||
);
|
||||
}
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
void pollArgentArtifactsForUploadAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
|
||||
toolsAuthToken: toolServerToken,
|
||||
logger,
|
||||
const { port: toolServerPort } = await waitForFileAsync({
|
||||
filePath: ARGENT_STATE_FILE,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'argent tool-server state',
|
||||
parse: parseArgentToolServerState,
|
||||
});
|
||||
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
|
||||
|
||||
const publicToolsUrl = await startNgrokTunnelAsync({
|
||||
const toolsUrl = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
subdomainPrefix: 'argent',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
rewriteHostHeader: true,
|
||||
logger,
|
||||
});
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
logger.info(`Tunnel is ready at ${toolsUrl}.`);
|
||||
|
||||
// serve-sim is iOS-only — Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
const serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
const serveSim = await startServeSimWithTunnelAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
@@ -154,8 +115,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
toolsUrl,
|
||||
...(webPreviewUrl ? { webPreviewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
@@ -169,37 +129,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
}
|
||||
|
||||
export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
packageVersion,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
logger: bunyan;
|
||||
}): void {
|
||||
if (!packageVersion || packageVersion === 'latest') {
|
||||
return;
|
||||
}
|
||||
|
||||
const validVersion = semver.valid(packageVersion);
|
||||
if (!validVersion) {
|
||||
logger.warn(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
|
||||
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
|
||||
`Continuing and letting bunx resolve it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (semver.lt(validVersion, MIN_ARGENT_REMOTE_SESSION_VERSION)) {
|
||||
throw new SystemError(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer. ` +
|
||||
`The requested package_version "${packageVersion}" is too old for the EAS remote-session API. ` +
|
||||
`Use "latest" or pass an exact version >= ${MIN_ARGENT_REMOTE_SESSION_VERSION}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgentToolServerState(raw: string): { port: number; token?: string } {
|
||||
function parseArgentToolServerState(raw: string): { port: number } {
|
||||
const result = ArgentToolServerStateSchema.safeParse(JSON.parse(raw));
|
||||
if (!result.success) {
|
||||
throw new SystemError(
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { BuildFunction, BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
} from '../utils/remoteDeviceRunSession';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const STARTUP_TIMEOUT_MS = 60_000;
|
||||
|
||||
export function createStartServeSimRemoteSessionBuildFunction(
|
||||
@@ -26,9 +27,10 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
|
||||
logger.info('Starting serve-sim remote session.');
|
||||
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
|
||||
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
GenericArtifactType,
|
||||
Job,
|
||||
ManagedArtifactType,
|
||||
isGenericArtifact,
|
||||
} from '@expo/eas-build-job';
|
||||
import { GenericArtifactType, Job, ManagedArtifactType } from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
@@ -70,11 +65,6 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'metadata',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
@@ -118,7 +108,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
throw result.reason;
|
||||
});
|
||||
|
||||
const artifactSpec = {
|
||||
const artifact = {
|
||||
type: parseArtifactTypeInput({
|
||||
platform: ctx.job.platform,
|
||||
inputValue: `${inputs.type.value ?? ''}`,
|
||||
@@ -126,22 +116,19 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
paths: artifactPaths,
|
||||
name: (inputs.name.value || inputs.key.value) as string,
|
||||
};
|
||||
const artifact = isGenericArtifact(artifactSpec)
|
||||
? {
|
||||
...artifactSpec,
|
||||
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
|
||||
}
|
||||
: artifactSpec;
|
||||
|
||||
try {
|
||||
const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
|
||||
const { artifactId } = await ctx.runtimeApi.uploadArtifact({
|
||||
artifact,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (artifactId) {
|
||||
outputs.artifact_id.set(artifactId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (inputs.ignore_error.value) {
|
||||
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
|
||||
logger.error(`Failed to upload ${artifact.type}. Ignoring error.`, error);
|
||||
// Ignoring error.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import fetch from 'node-fetch';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
import { listArgentArtifactsAsync, uploadArgentArtifactAsync } from '../argentArtifacts';
|
||||
|
||||
jest.mock('../deviceRunSessionArtifacts');
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
|
||||
for await (const chunk of stream as Readable) {
|
||||
void chunk;
|
||||
}
|
||||
}
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
}
|
||||
|
||||
describe(listArgentArtifactsAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it('lists Argent artifacts with bearer auth', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
artifacts: [
|
||||
{
|
||||
id: 'artifact-id',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
isDirectory: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const artifacts = await listArgentArtifactsAsync({
|
||||
toolsUrl: 'http://127.0.0.1:1234',
|
||||
toolsAuthToken: 'tools-token',
|
||||
});
|
||||
|
||||
expect(artifacts).toEqual([
|
||||
{
|
||||
id: 'artifact-id',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
isDirectory: false,
|
||||
},
|
||||
]);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts', {
|
||||
headers: { Authorization: 'Bearer tools-token' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(uploadArgentArtifactAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
jest.mocked(uploadDeviceRunSessionArtifactAsync).mockReset();
|
||||
});
|
||||
|
||||
it('downloads an Argent artifact and uploads it as a device run session artifact', async () => {
|
||||
const data = Buffer.from('artifact-data');
|
||||
const logger = createLoggerMock();
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
|
||||
jest
|
||||
.mocked(uploadDeviceRunSessionArtifactAsync)
|
||||
.mockImplementationOnce(async (_ctx, { stream }) => {
|
||||
await readStreamAsync(stream);
|
||||
});
|
||||
|
||||
await uploadArgentArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
toolsUrl: 'http://127.0.0.1:1234',
|
||||
toolsAuthToken: 'tools-token',
|
||||
logger,
|
||||
artifact: {
|
||||
id: 'artifact-id',
|
||||
filename: 'report.json',
|
||||
mimeType: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith('http://127.0.0.1:1234/artifacts/artifact-id', {
|
||||
headers: { Authorization: 'Bearer tools-token' },
|
||||
});
|
||||
expect(jest.mocked(uploadDeviceRunSessionArtifactAsync)).toHaveBeenCalledWith(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'artifact-id',
|
||||
name: 'report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
size: data.length,
|
||||
stream: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
import fetch from 'node-fetch';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
|
||||
describe(uploadDeviceRunSessionArtifactAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(fetch).mockReset();
|
||||
});
|
||||
|
||||
it('streams an artifact through a signed upload URL', async () => {
|
||||
const stream = Readable.from(Buffer.from('artifact-data'));
|
||||
const reportedSize = 1024;
|
||||
const mutation = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: {
|
||||
deviceRunSession: {
|
||||
createArtifactUploadSession: {
|
||||
uploadSession: {
|
||||
url: 'https://uploads.expo.test/artifact',
|
||||
headers: {
|
||||
'Content-Length': String(reportedSize),
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const ctx = {
|
||||
graphqlClient: {
|
||||
mutation,
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
|
||||
jest.mocked(fetch).mockResolvedValueOnce(new Response('', { status: 200 }));
|
||||
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId: 'drs-id',
|
||||
artifactId: 'artifact-id',
|
||||
name: 'Artifact report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
size: reportedSize,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(mutation).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
deviceRunSessionId: 'drs-id',
|
||||
input: {
|
||||
name: 'Artifact report.json (artifact-id)',
|
||||
filename: 'report.json',
|
||||
size: reportedSize,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://uploads.expo.test/artifact',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: stream,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
import {
|
||||
fetchServeSimTurnArgsAsync,
|
||||
turnIceServersToServeSimArgs,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
jest.mock('../../../sentry');
|
||||
|
||||
function createLoggerMock(): bunyan {
|
||||
return {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as bunyan;
|
||||
}
|
||||
|
||||
function createCtxMock(): CustomBuildContext {
|
||||
return {
|
||||
env: {
|
||||
__API_SERVER_URL: 'https://api.expo.test',
|
||||
},
|
||||
job: {
|
||||
secrets: { robotAccessToken: 'robot-token' },
|
||||
},
|
||||
} as unknown as CustomBuildContext;
|
||||
}
|
||||
|
||||
function createEnvMock(): BuildStepEnv {
|
||||
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
|
||||
}
|
||||
|
||||
describe(turnIceServersToServeSimArgs, () => {
|
||||
it('returns no args for an empty ICE server list', () => {
|
||||
expect(turnIceServersToServeSimArgs([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => {
|
||||
expect(
|
||||
turnIceServersToServeSimArgs([
|
||||
{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] },
|
||||
{
|
||||
urls: [
|
||||
'turn:turn.cloudflare.com:3478?transport=udp',
|
||||
'turns:turn.cloudflare.com:443?transport=tcp',
|
||||
],
|
||||
username: 'user-123',
|
||||
credential: 'cred-456',
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478,stun:stun.cloudflare.com:53',
|
||||
'--turn-url',
|
||||
'turn:turn.cloudflare.com:3478?transport=udp,turns:turn.cloudflare.com:443?transport=tcp',
|
||||
'--turn-username',
|
||||
'user-123',
|
||||
'--turn-credential',
|
||||
'cred-456',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => {
|
||||
expect(
|
||||
turnIceServersToServeSimArgs([
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
credential: 'c',
|
||||
},
|
||||
])
|
||||
).toEqual([
|
||||
'--turn-url',
|
||||
'turns:turn.cloudflare.com:443?transport=tcp',
|
||||
'--turn-username',
|
||||
'u',
|
||||
'--turn-credential',
|
||||
'c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits only --stun-url when there is no credentialed TURN entry', () => {
|
||||
expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(fetchServeSimTurnArgsAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(turtleFetch).mockReset();
|
||||
});
|
||||
|
||||
it('requests TURN ICE servers from the device run session endpoint and returns serve-sim args', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValue({
|
||||
json: async () => ({
|
||||
data: {
|
||||
iceServers: [
|
||||
{ urls: ['stun:stun.cloudflare.com:3478'] },
|
||||
{
|
||||
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
|
||||
username: 'u',
|
||||
credential: 'c',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
|
||||
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger: createLoggerMock(),
|
||||
});
|
||||
|
||||
expect(args).toEqual([
|
||||
'--stun-url',
|
||||
'stun:stun.cloudflare.com:3478',
|
||||
'--turn-url',
|
||||
'turns:turn.cloudflare.com:443?transport=tcp',
|
||||
'--turn-username',
|
||||
'u',
|
||||
'--turn-credential',
|
||||
'c',
|
||||
]);
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
|
||||
'POST',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer robot-token' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('returns [] and warns when the request fails so serve-sim falls back to P2P/STUN', async () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
|
||||
const logger = createLoggerMock();
|
||||
|
||||
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
|
||||
env: createEnvMock(),
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(args).toEqual([]);
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises';
|
||||
import fetch from 'node-fetch';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { formatBytes } from '../../utils/artifacts';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { uploadDeviceRunSessionArtifactAsync } from './deviceRunSessionArtifacts';
|
||||
|
||||
const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
const ArgentArtifactSchema = z.object({
|
||||
id: z.string(),
|
||||
filename: z.string(),
|
||||
mimeType: z.string(),
|
||||
isDirectory: z.boolean().optional(),
|
||||
});
|
||||
const ArgentArtifactsListResponseSchema = z.object({
|
||||
artifacts: z.array(ArgentArtifactSchema),
|
||||
});
|
||||
|
||||
type ArgentArtifact = z.infer<typeof ArgentArtifactSchema>;
|
||||
|
||||
export async function pollArgentArtifactsForUploadAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
logger,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
toolsUrl: string;
|
||||
toolsAuthToken?: string;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<never> {
|
||||
logger.info('Started polling Argent tool-server for artifacts.');
|
||||
const seenArtifactIds = new Set<string>();
|
||||
let listArtifactsErrorCount = 0;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
const artifacts = await listArgentArtifactsAsync({ toolsUrl, toolsAuthToken });
|
||||
listArtifactsErrorCount = 0;
|
||||
for (const artifact of artifacts) {
|
||||
if (seenArtifactIds.has(artifact.id)) {
|
||||
continue;
|
||||
}
|
||||
seenArtifactIds.add(artifact.id);
|
||||
void uploadArgentArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
artifact,
|
||||
logger,
|
||||
}).catch(err => {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not upload Argent remote session artifact', error);
|
||||
logger.warn({ err: error }, 'Could not upload Argent remote session artifact.');
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
listArtifactsErrorCount += 1;
|
||||
if (listArtifactsErrorCount === 1 || listArtifactsErrorCount % 5 === 0) {
|
||||
Sentry.capture('Could not list Argent remote session artifacts', error);
|
||||
logger.warn(
|
||||
{ err: error, failedArtifactListCount: listArtifactsErrorCount },
|
||||
'Could not list Argent remote session artifacts.'
|
||||
);
|
||||
}
|
||||
}
|
||||
await sleepAsync(ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listArgentArtifactsAsync({
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
}: {
|
||||
toolsUrl: string;
|
||||
toolsAuthToken?: string;
|
||||
}): Promise<ArgentArtifact[]> {
|
||||
const response = await fetch(new URL('/artifacts', toolsUrl).toString(), {
|
||||
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Failed to list Argent artifacts: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
const result = ArgentArtifactsListResponseSchema.safeParse(await response.json());
|
||||
if (!result.success) {
|
||||
throw new SystemError(`Invalid Argent artifacts response: ${result.error.message}`);
|
||||
}
|
||||
return result.data.artifacts;
|
||||
}
|
||||
|
||||
export async function uploadArgentArtifactAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
artifact,
|
||||
logger,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
toolsUrl: string;
|
||||
toolsAuthToken?: string;
|
||||
artifact: ArgentArtifact;
|
||||
logger: bunyan;
|
||||
}
|
||||
): Promise<void> {
|
||||
const filename = artifact.isDirectory ? `${artifact.filename}.tar.gz` : artifact.filename;
|
||||
logger.info(`Downloading artifact ${filename}.`);
|
||||
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'argent-artifact-'));
|
||||
try {
|
||||
const temporaryArtifactPath = path.join(temporaryDirectory, path.basename(filename));
|
||||
await downloadArgentArtifactToFileAsync({
|
||||
artifact,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
destinationPath: temporaryArtifactPath,
|
||||
});
|
||||
const { size } = await stat(temporaryArtifactPath);
|
||||
logger.info(`Uploading artifact ${filename} (${formatBytes(size)}).`);
|
||||
await uploadDeviceRunSessionArtifactAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId: artifact.id,
|
||||
name: `${filename} (${artifact.id})`,
|
||||
filename,
|
||||
size,
|
||||
stream: createReadStream(temporaryArtifactPath),
|
||||
});
|
||||
} finally {
|
||||
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadArgentArtifactToFileAsync({
|
||||
artifact,
|
||||
toolsUrl,
|
||||
toolsAuthToken,
|
||||
destinationPath,
|
||||
}: {
|
||||
artifact: ArgentArtifact;
|
||||
toolsUrl: string;
|
||||
toolsAuthToken?: string;
|
||||
destinationPath: string;
|
||||
}): Promise<void> {
|
||||
const response = await fetch(new URL(`/artifacts/${artifact.id}`, toolsUrl).toString(), {
|
||||
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Failed to download Argent artifact ${artifact.id}: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new SystemError(
|
||||
`Argent artifact ${artifact.id} response did not include a readable body.`
|
||||
);
|
||||
}
|
||||
await pipeline(response.body, createWriteStream(destinationPath));
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { graphql } from 'gql.tada';
|
||||
import fetch, { Headers } from 'node-fetch';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
const CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
|
||||
mutation CreateDeviceRunSessionArtifactUploadSession(
|
||||
$deviceRunSessionId: ID!
|
||||
$input: CreateDeviceRunSessionArtifactUploadSessionInput!
|
||||
) {
|
||||
deviceRunSession {
|
||||
createArtifactUploadSession(deviceRunSessionId: $deviceRunSessionId, input: $input) {
|
||||
uploadSession {
|
||||
url
|
||||
headers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export async function uploadDeviceRunSessionArtifactAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
size,
|
||||
stream,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
stream: NodeJS.ReadableStream;
|
||||
}
|
||||
): Promise<void> {
|
||||
const uploadSession = await createDeviceRunSessionArtifactUploadSessionAsync(ctx, {
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
size,
|
||||
});
|
||||
const response = await fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
headers: new Headers(uploadSession.headers as Record<string, string>),
|
||||
body: stream,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new SystemError(
|
||||
`Failed to upload device run session artifact ${artifactId}: ${response.status} ${response.statusText}`,
|
||||
{ cause: response }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeviceRunSessionArtifactUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
deviceRunSessionId,
|
||||
artifactId,
|
||||
name,
|
||||
filename,
|
||||
size,
|
||||
}: {
|
||||
deviceRunSessionId: string;
|
||||
artifactId: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
}
|
||||
) {
|
||||
const result = await ctx.graphqlClient
|
||||
.mutation(CREATE_DEVICE_RUN_SESSION_ARTIFACT_UPLOAD_SESSION_MUTATION, {
|
||||
deviceRunSessionId,
|
||||
input: {
|
||||
name,
|
||||
filename,
|
||||
size,
|
||||
},
|
||||
})
|
||||
.toPromise();
|
||||
if (result.error) {
|
||||
throw new SystemError(
|
||||
`Failed to create upload session for device run session artifact ${artifactId}: ${result.error.message}`,
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
return result.data!.deviceRunSession.createArtifactUploadSession.uploadSession;
|
||||
}
|
||||
@@ -1,20 +1,14 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv, spawnAsync } from '@expo/steps';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { graphql } from 'gql.tada';
|
||||
import nullthrows from 'nullthrows';
|
||||
import { z } from 'zod';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import { Sentry } from '../../sentry';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
|
||||
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
|
||||
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
|
||||
@@ -66,125 +60,6 @@ export function getNgrokAuthtokenOrThrow(env: BuildStepEnv): string {
|
||||
return authtoken;
|
||||
}
|
||||
|
||||
const TurnIceServersSchema = z.array(
|
||||
z.object({
|
||||
urls: z.array(z.string()),
|
||||
username: z.string().optional(),
|
||||
credential: z.string().optional(),
|
||||
})
|
||||
);
|
||||
|
||||
export type TurnIceServers = z.infer<typeof TurnIceServersSchema>;
|
||||
|
||||
export async function selectXcodeDeveloperDirectoryAsync({
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<void> {
|
||||
if (process.env.ENVIRONMENT === 'development') {
|
||||
logger.info('Job running outside of EAS, not selecting Xcode developer directory.');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawnAsync('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], {
|
||||
env,
|
||||
logger,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
const TurnIceServersResponseSchema = z.object({
|
||||
data: z.object({
|
||||
iceServers: TurnIceServersSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
|
||||
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
|
||||
* (the entry carrying the short-lived credentials).
|
||||
*/
|
||||
export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] {
|
||||
const stunUrls = iceServers
|
||||
.filter(server => !server.username && !server.credential)
|
||||
.flatMap(server => server.urls);
|
||||
const turnServer = iceServers.find(server => server.username && server.credential);
|
||||
|
||||
const args: string[] = [];
|
||||
if (stunUrls.length > 0) {
|
||||
args.push('--stun-url', stunUrls.join(','));
|
||||
}
|
||||
if (turnServer?.username && turnServer.credential && turnServer.urls.length > 0) {
|
||||
args.push(
|
||||
'--turn-url',
|
||||
turnServer.urls.join(','),
|
||||
'--turn-username',
|
||||
turnServer.username,
|
||||
'--turn-credential',
|
||||
turnServer.credential
|
||||
);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch short-lived Cloudflare TURN ICE servers for this job run from www
|
||||
* (minted on demand, mirroring how the worker fetches project clone URLs) and
|
||||
* translate them into serve-sim CLI flags.
|
||||
*
|
||||
* Best-effort: on any failure we log and return [] so serve-sim falls back to
|
||||
* its built-in P2P/STUN behavior. The credential is passed to serve-sim as a
|
||||
* process arg and deliberately not logged (turtle-spawn never logs argv and the
|
||||
* worker is single-tenant).
|
||||
*/
|
||||
export async function fetchServeSimTurnArgsAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
|
||||
const expoApiServerUrl = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
|
||||
const robotAccessToken = nullthrows(
|
||||
ctx.job.secrets?.robotAccessToken,
|
||||
'robot access token is not set'
|
||||
);
|
||||
|
||||
const response = await turtleFetch(
|
||||
new URL(
|
||||
`/v2/device-run-sessions/${deviceRunSessionId}/turn-ice-servers`,
|
||||
expoApiServerUrl
|
||||
).toString(),
|
||||
'POST',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${robotAccessToken}`,
|
||||
},
|
||||
timeout: 5000,
|
||||
retries: 1,
|
||||
logger,
|
||||
}
|
||||
);
|
||||
|
||||
const { data } = TurnIceServersResponseSchema.parse(await response.json());
|
||||
const args = turnIceServersToServeSimArgs(data.iceServers);
|
||||
if (args.length > 0) {
|
||||
logger.info('Configured serve-sim with Cloudflare TURN ICE servers.');
|
||||
}
|
||||
return args;
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
|
||||
logger.warn(
|
||||
{ err: error },
|
||||
'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.'
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadRemoteSessionConfigAsync({
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
@@ -245,22 +120,18 @@ export function spawnDetached({
|
||||
return { getOutput: () => output };
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
}
|
||||
): Promise<{ previewUrl: string; streamUrl: string }> {
|
||||
export async function startServeSimWithTunnelAsync({
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
}): Promise<{ previewUrl: string; streamUrl: string }> {
|
||||
logger.info('Launching serve-sim with tunnel.');
|
||||
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
|
||||
const serveSim = spawnDetached({
|
||||
command: 'npx',
|
||||
args: [
|
||||
@@ -276,7 +147,6 @@ export async function startServeSimWithTunnelAsync(
|
||||
'0.55',
|
||||
'--codec',
|
||||
'webrtc',
|
||||
...turnArgs,
|
||||
],
|
||||
env,
|
||||
});
|
||||
@@ -318,26 +188,19 @@ export async function startNgrokTunnelAsync({
|
||||
subdomainPrefix,
|
||||
baseDomain,
|
||||
authtoken,
|
||||
rewriteHostHeader,
|
||||
logger,
|
||||
}: {
|
||||
port: number;
|
||||
subdomainPrefix: string;
|
||||
baseDomain: string;
|
||||
authtoken: string;
|
||||
rewriteHostHeader?: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<string> {
|
||||
const domain = `${subdomainPrefix}-${randomBytes(8).toString('hex')}.${baseDomain}`;
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
|
||||
// the process exits, and the step blocks forever to hold it open.
|
||||
const listener = await ngrok.forward({
|
||||
addr: port,
|
||||
authtoken,
|
||||
domain,
|
||||
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
|
||||
});
|
||||
const listener = await ngrok.forward({ addr: port, authtoken, domain });
|
||||
const url = listener.url();
|
||||
if (!url) {
|
||||
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
|
||||
|
||||
+120
-246
File diff suppressed because it is too large.
Load diff
Generated
+16
-1768
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "20.4.0",
|
||||
"version": "20.1.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -27,7 +27,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"postpack": "rimraf oclif.manifest.json",
|
||||
"prepack": "yarn sync-agent-templates && yarn rebuild && node ./scripts/prepack.js",
|
||||
"prepack": "yarn rebuild && node ./scripts/prepack.js",
|
||||
"rebuild": "rimraf build \"*.tsbuildinfo\" && yarn build",
|
||||
"build": "yarn typecheck-for-build && yarn copy-new-templates",
|
||||
"build-allow-unused": "tsc --project tsconfig.allowUnused.json",
|
||||
@@ -36,7 +36,6 @@
|
||||
"typecheck": "tsc",
|
||||
"typecheck-for-build": "tsc --project tsconfig.build.json",
|
||||
"test": "jest",
|
||||
"sync-agent-templates": "node scripts/sync-agent-templates.js",
|
||||
"version": "yarn run -T oclif readme && node scripts/patch-readme && git add README.md",
|
||||
"generate-graphql-code": "graphql-codegen --config graphql-codegen.ts",
|
||||
"verify-graphql-code": "./scripts/verify-graphql.sh",
|
||||
@@ -225,9 +224,6 @@
|
||||
"update": {
|
||||
"description": "manage individual updates"
|
||||
},
|
||||
"update:embedded": {
|
||||
"description": "manage embedded updates registered with EAS Update"
|
||||
},
|
||||
"webhook": {
|
||||
"description": "manage webhooks"
|
||||
},
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
|
||||
const LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL =
|
||||
'https://raw.githubusercontent.com/expo/llm-configs/main/expo-app';
|
||||
const AGENT_TEMPLATE_FILE_NAMES = ['AGENTS.md', 'CLAUDE.md'];
|
||||
|
||||
async function fetchTemplateAsync(fileName) {
|
||||
const url = `${LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL}/${fileName}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const templatesDir = path.join(__dirname, '..', 'src', 'commandUtils', 'new', 'templates');
|
||||
|
||||
await Promise.all(
|
||||
AGENT_TEMPLATE_FILE_NAMES.map(async fileName => {
|
||||
const content = await fetchTemplateAsync(fileName);
|
||||
await fs.writeFile(path.join(templatesDir, fileName), content);
|
||||
})
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Synced new app agent templates from expo/llm-configs');
|
||||
})();
|
||||
@@ -1,41 +1,162 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
|
||||
|
||||
## Expo has changed — do not trust your training data
|
||||
## Documentation Resources
|
||||
|
||||
Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API:
|
||||
When working on this project, **always consult the official Expo documentation** available at:
|
||||
|
||||
1. Read the major version of the `expo` package in `package.json`.
|
||||
2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v<major>.0.0/`
|
||||
3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory.
|
||||
- **https://docs.expo.dev/llms.txt** - Index of all available documentation files
|
||||
- **https://docs.expo.dev/llms-full.txt** - Complete Expo documentation including Expo Router, Expo Modules API, development process
|
||||
- **https://docs.expo.dev/llms-eas.txt** - Complete EAS (Expo Application Services) documentation
|
||||
- **https://docs.expo.dev/llms-sdk.txt** - Complete Expo SDK documentation
|
||||
- **https://reactnative.dev/docs/getting-started** - Complete React Native documentation
|
||||
|
||||
## Commands
|
||||
These documentation files are specifically formatted for AI agents and should be your **primary reference** for:
|
||||
|
||||
Use `bunx` instead of `npx` if the project uses bun (`bun.lock` present).
|
||||
- Expo APIs and best practices
|
||||
- Expo Router navigation patterns
|
||||
- EAS Build, Submit, and Update workflows
|
||||
- Expo SDK modules and their usage
|
||||
- Development and deployment processes
|
||||
|
||||
```bash
|
||||
npx expo install <package> # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions
|
||||
npx expo start # start the dev server
|
||||
npx expo lint # lint
|
||||
npx tsc --noEmit # typecheck
|
||||
npx expo-doctor # diagnose dependency and config issues
|
||||
npx expo install --fix # fix incompatible package versions
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── app/ # Expo Router file-based routing
|
||||
│ ├── (tabs)/ # Tab-based navigation screens
|
||||
│ │ ├── index.tsx # Home screen
|
||||
│ │ ├── explore.tsx # Explore screen
|
||||
│ │ └── _layout.tsx # Tabs layout
|
||||
│ ├── _layout.tsx # Root layout with theme provider
|
||||
│ └── modal.tsx # Modal screen example
|
||||
├── components/ # Reusable React components
|
||||
│ ├── ui/ # UI primitives (IconSymbol, Collapsible)
|
||||
│ └── ... # Feature components (themed, haptic, parallax)
|
||||
├── constants/ # App-wide constants (theme, colors)
|
||||
├── hooks/ # Custom React hooks (color scheme, theme)
|
||||
├── assets/ # Static assets (images, fonts)
|
||||
├── scripts/ # Utility scripts (reset-project)
|
||||
├── .eas/workflows/ # EAS Workflows (CI/CD automation)
|
||||
├── app.json # Expo configuration
|
||||
├── eas.json # EAS Build/Submit configuration
|
||||
└── package.json # Dependencies and scripts
|
||||
```
|
||||
|
||||
Run lint and typecheck before declaring any task done.
|
||||
## Essential Commands
|
||||
|
||||
## Navigation & Routing
|
||||
### Development
|
||||
|
||||
- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`.
|
||||
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`.
|
||||
- Docs: https://docs.expo.dev/router/introduction.md
|
||||
```bash
|
||||
npx expo start # Start dev server
|
||||
npx expo start --clear # Clear cache and start dev server
|
||||
npx expo install <package> # Install packages with compatible versions
|
||||
npx expo install --check # Check which installed packages need to be updated
|
||||
npx expo install --fix # Automatically update any invalid package versions
|
||||
npm run development-builds # Create development builds (workflow)
|
||||
npm run reset-project # Reset to blank template
|
||||
```
|
||||
|
||||
## Building with EAS
|
||||
### Building & Testing
|
||||
|
||||
Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `bunx eas-cli <command>` in Bun projects, or `npx eas-cli@latest <command>` otherwise; substitute that for bare `eas` in docs examples.
|
||||
Docs: https://docs.expo.dev/eas/index.md
|
||||
```bash
|
||||
npx expo doctor # Check project health and dependencies
|
||||
npx expo lint # Run ESLint
|
||||
npm run draft # Publish preview update and website (workflow)
|
||||
```
|
||||
|
||||
## Rules
|
||||
### Production
|
||||
|
||||
- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins.
|
||||
- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`.
|
||||
- Prefer recommended Expo modules over third-party libraries, and check your available skills before adding dependencies. Docs: https://docs.expo.dev/versions/latest/index.md
|
||||
```bash
|
||||
npx eas-cli@latest build --platform ios -s # Use EAS to build for iOS platform and submit to App Store
|
||||
npx eas-cli@latest build --platform android -s # Use EAS to build for Android platform and submit to Google Play Store
|
||||
npm run deploy # Deploy to production (workflow)
|
||||
```
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Style & Standards
|
||||
|
||||
- **TypeScript First**: Use TypeScript for all new code with strict type checking
|
||||
- **Naming Conventions**: Use meaningful, descriptive names for variables, functions, and components
|
||||
- **Self-Documenting Code**: Write clear, readable code that explains itself; only add comments for complex business logic or design decisions
|
||||
- **React 19 Patterns**: Follow modern React patterns including:
|
||||
- Function components with hooks
|
||||
- Enable React Compiler
|
||||
- Proper dependency arrays in useEffect
|
||||
- Memoization when appropriate (useMemo, useCallback)
|
||||
- Error boundaries for better error handling
|
||||
|
||||
### Navigation & Routing
|
||||
|
||||
- Use **Expo Router** for all navigation
|
||||
- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`
|
||||
- Docs: https://docs.expo.dev/router/introduction/
|
||||
|
||||
### Recommended Libraries
|
||||
|
||||
- **Navigation**: `expo-router` for navigation
|
||||
- **Images**: `expo-image` for optimized image handling and caching
|
||||
- **Animations**: `react-native-reanimated` for performant animations on native thread
|
||||
- **Gestures**: `react-native-gesture-handler` for native gesture recognition
|
||||
- **Storage**: Use `expo-sqlite` for persistent storage, `expo-sqlite/kv-store` for simple key-value storage
|
||||
|
||||
## Debugging & Development Tools
|
||||
|
||||
### DevTools Integration
|
||||
|
||||
- **React Native DevTools**: Use MCP `open_devtools` command to launch debugging tools
|
||||
- **Network Inspection**: Monitor API calls and network requests in DevTools
|
||||
- **Element Inspector**: Debug component hierarchy and styles
|
||||
- **Performance Profiler**: Identify performance bottlenecks
|
||||
- **Logging**: Use `console.log` for debugging (remove before production), `console.warn` for deprecation notices, `console.error` for actual errors, and implement error boundaries for production error handling
|
||||
|
||||
### Testing & Quality Assurance
|
||||
|
||||
#### Automated Testing with MCP Tools
|
||||
|
||||
Developers can configure the Expo MCP server with the following doc: https://docs.expo.dev/eas/ai/mcp/
|
||||
|
||||
- **Component Testing**: Add `testID` props to components for automation
|
||||
- **Visual Testing**: Use MCP `automation_take_screenshot` to verify UI appearance
|
||||
- **Interaction Testing**: Use MCP `automation_tap_by_testid` to simulate user interactions
|
||||
- **View Verification**: Use MCP `automation_find_view_by_testid` to validate component rendering
|
||||
|
||||
## EAS Workflows CI/CD
|
||||
|
||||
This project is pre-configured with **EAS Workflows** for automating development and release processes. Workflows are defined in `.eas/workflows/` directory.
|
||||
|
||||
When working with EAS Workflows, **always refer to**:
|
||||
|
||||
- https://docs.expo.dev/eas/workflows/ for workflow examples
|
||||
- The `.eas/workflows/` directory for existing workflow configurations
|
||||
- You can check that a workflow YAML is valid using the workflows schema: https://exp.host/--/api/v2/workflows/schema
|
||||
|
||||
### Build Profiles (eas.json)
|
||||
|
||||
- **development**: Development builds with dev client
|
||||
- **development-simulator**: Development builds for iOS simulator
|
||||
- **preview**: Internal distribution preview builds
|
||||
- **production**: Production builds with auto-increment
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Expo Go Errors & Development Builds
|
||||
|
||||
If there are errors in **Expo Go** or the project is not running, create a **development build**. **Expo Go** is a sandbox environment with a limited set of native modules. To create development builds, run `eas build:dev`. Additionally, after installing new packages or adding config plugins, new development builds are often required.
|
||||
|
||||
## AI Agent Instructions
|
||||
|
||||
When working on this project:
|
||||
|
||||
1. **Always start by consulting the appropriate documentation**:
|
||||
- For general Expo questions: https://docs.expo.dev/llms-full.txt
|
||||
- For EAS/deployment questions: https://docs.expo.dev/llms-eas.txt
|
||||
- For SDK/API questions: https://docs.expo.dev/llms-sdk.txt
|
||||
|
||||
2. **Understand before implementing**: Read the relevant docs section before writing code
|
||||
|
||||
3. **Follow existing patterns**: Look at existing components and screens for patterns to follow
|
||||
@@ -1 +1,9 @@
|
||||
@AGENTS.md
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
**Note**: This project uses `AGENTS.md` files for detailed guidance.
|
||||
|
||||
## Primary Reference
|
||||
|
||||
Please see `AGENTS.md` in this same directory for the main project documentation and guidance.
|
||||
@@ -1,22 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { PostHogProjectData } from '../graphql/types/PostHogConnection';
|
||||
import Log, { link } from '../log';
|
||||
|
||||
export function getPostHogProjectDashboardUrl(project: PostHogProjectData): string {
|
||||
const host = project.posthogHost.replace(/\/$/, '');
|
||||
return `${host}/project/${encodeURIComponent(project.posthogProjectIdentifier)}`;
|
||||
}
|
||||
|
||||
export function formatPostHogProject(project: PostHogProjectData): string {
|
||||
return [
|
||||
`${chalk.bold('Name')}: ${project.posthogProjectName}`,
|
||||
`${chalk.bold('Host')}: ${project.posthogHost}`,
|
||||
`${chalk.bold('Region')}: ${project.posthogOrganizationConnection.posthogRegion}`,
|
||||
`${chalk.bold('Dashboard')}: ${link(getPostHogProjectDashboardUrl(project), { dim: false })}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function logNoPostHogProject(projectName: string): void {
|
||||
Log.warn(`No PostHog project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EASNonInteractiveFlag, EasJsonOnlyFlag } from '../../commandUtils/flags';
|
||||
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
|
||||
import { AuditLogFragment } from '../../graphql/generated';
|
||||
import { AuditLogQuery } from '../../graphql/queries/AuditLogQuery';
|
||||
import Log from '../../log';
|
||||
import { ora } from '../../ora';
|
||||
import { confirmAsync, selectAsync } from '../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import renderTextTable from '../../utils/renderTextTable';
|
||||
|
||||
const AUDIT_LOGS_LIMIT = 50;
|
||||
|
||||
export default class AccountAudit extends EasCommand {
|
||||
static override description = 'view the audit logs for an account';
|
||||
|
||||
static override args = {
|
||||
ACCOUNT_NAME: Args.string({
|
||||
description:
|
||||
'Account name to view audit logs for. If not provided, the account will be selected interactively (or defaults to the only account if there is just one)',
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
limit: getLimitFlagWithCustomValues({ defaultTo: AUDIT_LOGS_LIMIT, limit: 100 }),
|
||||
after: Flags.string({
|
||||
description:
|
||||
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
|
||||
}),
|
||||
...EasJsonOnlyFlag,
|
||||
...EASNonInteractiveFlag,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { ACCOUNT_NAME: accountName },
|
||||
flags: { limit, after, json: jsonFlag, 'non-interactive': nonInteractive },
|
||||
} = await this.parse(AccountAudit);
|
||||
|
||||
const json = jsonFlag || nonInteractive;
|
||||
if (json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient, actor },
|
||||
} = await this.getContextAsync(AccountAudit, { nonInteractive });
|
||||
|
||||
const pageSize = limit ?? AUDIT_LOGS_LIMIT;
|
||||
|
||||
let targetAccount: { id: string; name: string };
|
||||
const availableAccounts = actor.accounts.map(a => a.name).join(', ');
|
||||
if (accountName) {
|
||||
const found = actor.accounts.find(a => a.name === accountName);
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`Account "${accountName}" not found or you don't have access. Available accounts: ${availableAccounts}`
|
||||
);
|
||||
}
|
||||
targetAccount = found;
|
||||
} else if (nonInteractive) {
|
||||
throw new Error(
|
||||
'ACCOUNT_NAME argument must be provided when running in `--non-interactive` mode.'
|
||||
);
|
||||
} else if (actor.accounts.length === 1) {
|
||||
targetAccount = actor.accounts[0];
|
||||
} else {
|
||||
targetAccount = await selectAsync(
|
||||
'Select account to view audit logs for:',
|
||||
actor.accounts.map(account => ({
|
||||
title: account.name,
|
||||
value: account,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (json) {
|
||||
const spinner = ora(`Fetching audit logs for account ${targetAccount.name}`).start();
|
||||
try {
|
||||
const connection = await AuditLogQuery.getAllForAccountAsync(
|
||||
graphqlClient,
|
||||
targetAccount.id,
|
||||
{
|
||||
first: pageSize,
|
||||
after,
|
||||
}
|
||||
);
|
||||
spinner.stop();
|
||||
printJsonOnlyOutput({
|
||||
auditLogs: connection.edges.map(edge => edge.node),
|
||||
pageInfo: connection.pageInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
spinner.fail(`Failed to fetch audit logs for account ${targetAccount.name}`);
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let cursor = after;
|
||||
do {
|
||||
const spinner = ora(`Fetching audit logs for account ${targetAccount.name}`).start();
|
||||
const connection = await AuditLogQuery.getAllForAccountAsync(
|
||||
graphqlClient,
|
||||
targetAccount.id,
|
||||
{
|
||||
first: pageSize,
|
||||
after: cursor,
|
||||
}
|
||||
);
|
||||
spinner.stop();
|
||||
|
||||
const logs = connection.edges.map(edge => edge.node);
|
||||
renderPageOfAuditLogs(logs);
|
||||
|
||||
if (!connection.pageInfo.hasNextPage) {
|
||||
break;
|
||||
}
|
||||
cursor = connection.pageInfo.endCursor ?? undefined;
|
||||
} while (cursor && (await confirmAsync({ message: 'Load more audit logs?' })));
|
||||
}
|
||||
}
|
||||
|
||||
function renderPageOfAuditLogs(logs: AuditLogFragment[]): void {
|
||||
if (logs.length === 0) {
|
||||
Log.log('No audit logs found.');
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log(
|
||||
renderTextTable(
|
||||
['Date', 'Actor', 'Action', 'Entity', 'Message'],
|
||||
logs.map(log => [
|
||||
new Date(log.createdAt).toLocaleString(),
|
||||
log.actor?.displayName ?? 'Unknown',
|
||||
log.targetEntityMutationType,
|
||||
log.targetEntityTypePublicName,
|
||||
log.websiteMessage,
|
||||
])
|
||||
)
|
||||
);
|
||||
Log.addNewLineIfNone();
|
||||
}
|
||||
@@ -1,658 +0,0 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import * as fs from 'fs-extra';
|
||||
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
|
||||
import {
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
PostHogRegion,
|
||||
Role,
|
||||
} from '../../../../graphql/generated';
|
||||
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { PostHogMutation } from '../../../../graphql/mutations/PostHogMutation';
|
||||
import { EnvironmentVariablesQuery } from '../../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import { PostHogQuery } from '../../../../graphql/queries/PostHogQuery';
|
||||
import {
|
||||
PostHogOrganizationConnectionData,
|
||||
PostHogProjectData,
|
||||
} from '../../../../graphql/types/PostHogConnection';
|
||||
import Log from '../../../../log';
|
||||
import { createOrModifyExpoConfigAsync } from '../../../../project/expoConfig';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
|
||||
import { confirmAsync, promptAsync, selectAsync } from '../../../../prompts';
|
||||
import { Actor } from '../../../../user/User';
|
||||
import { printJsonOnlyOutput } from '../../../../utils/json';
|
||||
import IntegrationsPostHogConnect from '../connect';
|
||||
|
||||
jest.mock('@expo/spawn-async');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../../../project/expoConfig');
|
||||
jest.mock('../../../../graphql/queries/PostHogQuery');
|
||||
jest.mock('../../../../graphql/queries/EnvironmentVariablesQuery');
|
||||
jest.mock('../../../../graphql/mutations/PostHogMutation');
|
||||
jest.mock('../../../../graphql/mutations/EnvironmentVariableMutation');
|
||||
jest.mock('../../../../project/projectUtils');
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../utils/json');
|
||||
jest.mock('../../../../ora', () => ({
|
||||
ora: () => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe(IntegrationsPostHogConnect, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const testAccountId = 'test-account-id';
|
||||
const testAccountName = 'testuser';
|
||||
|
||||
const mockActor: Actor = {
|
||||
__typename: 'User',
|
||||
id: 'test-user-id',
|
||||
username: testAccountName,
|
||||
email: '[email protected]',
|
||||
featureGates: {},
|
||||
isExpoAdmin: false,
|
||||
primaryAccount: { id: testAccountId, name: testAccountName, ownerUserActor: null, users: [] },
|
||||
preferences: { onboarding: null },
|
||||
accounts: [],
|
||||
};
|
||||
|
||||
const mockAccount = {
|
||||
id: testAccountId,
|
||||
name: testAccountName,
|
||||
ownerUserActor: { id: 'test-user-id', username: testAccountName },
|
||||
users: [{ role: Role.Owner, actor: { id: 'test-user-id' } }],
|
||||
};
|
||||
|
||||
const mockConnection: PostHogOrganizationConnectionData = {
|
||||
id: 'connection-1',
|
||||
posthogOrganizationIdentifier: 'org-123',
|
||||
posthogOrganizationName: 'Test Org',
|
||||
posthogRegion: PostHogRegion.Us,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const mockProject: PostHogProjectData = {
|
||||
id: 'project-1',
|
||||
posthogProjectIdentifier: '467657',
|
||||
posthogProjectName: 'testapp',
|
||||
posthogProjectToken: 'phc_public_key',
|
||||
posthogHost: 'https://us.posthog.com',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
posthogOrganizationConnection: mockConnection,
|
||||
};
|
||||
|
||||
function createCommand(argv: string[], actor: Actor = mockActor): IntegrationsPostHogConnect {
|
||||
const command = new IntegrationsPostHogConnect(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
exp: { name: 'testapp', slug: 'testapp', plugins: [] },
|
||||
projectDir: '/test/project',
|
||||
},
|
||||
loggedIn: { graphqlClient, actor },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
// Helper: which features the interactive multiselect returns.
|
||||
function mockFeatureSelection(features: string[]): void {
|
||||
jest.mocked(promptAsync).mockImplementation((async (opts: any) => {
|
||||
if (opts.name === 'features') {
|
||||
return { features };
|
||||
}
|
||||
if (opts.name === 'apiKey') {
|
||||
return { apiKey: 'phx_personal_key' };
|
||||
}
|
||||
return {};
|
||||
}) as any);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'error').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'withTick').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
|
||||
|
||||
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
|
||||
jest.mocked(selectAsync).mockResolvedValue(PostHogRegion.Us);
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
jest.mocked(spawnAsync).mockResolvedValue({} as any);
|
||||
jest.mocked(fs.pathExists).mockResolvedValue(false as never);
|
||||
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
|
||||
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'success' } as any);
|
||||
// Default: reuse an existing connection + project.
|
||||
jest
|
||||
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
|
||||
.mockResolvedValue(mockConnection);
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(mockProject);
|
||||
jest.mocked(PostHogMutation.createPostHogAccountRequestAsync).mockResolvedValue(mockConnection);
|
||||
jest.mocked(PostHogMutation.setupPostHogProjectAsync).mockResolvedValue(mockProject);
|
||||
jest.mocked(EnvironmentVariablesQuery.byAppIdAsync).mockResolvedValue([]);
|
||||
jest.mocked(EnvironmentVariableMutation.createForAppAsync).mockResolvedValue({
|
||||
id: 'env-var-1',
|
||||
scope: EnvironmentVariableScope.Project,
|
||||
} as any);
|
||||
mockFeatureSelection(['analytics', 'session-replay', 'error-tracking']);
|
||||
});
|
||||
|
||||
it('sets up all features: installs SDK + replay pkg, adds plugin, writes EXPO_PUBLIC_ + POSTHOG_CLI_ vars', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
const installArgs = jest.mocked(spawnAsync).mock.calls[0][1];
|
||||
expect(installArgs).toContain('posthog-react-native');
|
||||
expect(installArgs).toContain('posthog-react-native-session-replay');
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalled();
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
|
||||
const createdNames = jest
|
||||
.mocked(EnvironmentVariableMutation.createForAppAsync)
|
||||
.mock.calls.map(call => call[1].name);
|
||||
expect(createdNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'EXPO_PUBLIC_POSTHOG_API_KEY',
|
||||
'EXPO_PUBLIC_POSTHOG_HOST',
|
||||
'POSTHOG_CLI_API_KEY',
|
||||
'POSTHOG_CLI_PROJECT_ID',
|
||||
'POSTHOG_CLI_HOST',
|
||||
])
|
||||
);
|
||||
const cliKeyVar = jest
|
||||
.mocked(EnvironmentVariableMutation.createForAppAsync)
|
||||
.mock.calls.find(call => call[1].name === 'POSTHOG_CLI_API_KEY');
|
||||
expect(cliKeyVar?.[1].visibility).toBe(EnvironmentVariableVisibility.Sensitive);
|
||||
// The pasted personal key flows through verbatim — to EAS and to .env.local.
|
||||
expect(cliKeyVar?.[1].value).toBe('phx_personal_key');
|
||||
const written = jest.mocked(fs.writeFile).mock.calls[0][1] as string;
|
||||
expect(written).toContain('POSTHOG_CLI_API_KEY=phx_personal_key');
|
||||
});
|
||||
|
||||
it('asks for features only after provisioning (fast-forward ordering)', async () => {
|
||||
jest
|
||||
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
|
||||
.mockResolvedValue(null);
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
let provisionedBeforePrompt = false;
|
||||
jest.mocked(promptAsync).mockImplementation((async (opts: any) => {
|
||||
if (opts.name === 'features') {
|
||||
provisionedBeforePrompt =
|
||||
jest.mocked(PostHogMutation.createPostHogAccountRequestAsync).mock.calls.length > 0 &&
|
||||
jest.mocked(PostHogMutation.setupPostHogProjectAsync).mock.calls.length > 0;
|
||||
return { features: ['analytics'] };
|
||||
}
|
||||
return {};
|
||||
}) as any);
|
||||
|
||||
await createCommand(['--region', 'US']).runAsync();
|
||||
|
||||
expect(provisionedBeforePrompt).toBe(true);
|
||||
});
|
||||
|
||||
it('analytics only: no replay pkg, no CLI vars, no key prompt', async () => {
|
||||
mockFeatureSelection(['analytics']);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(jest.mocked(spawnAsync).mock.calls[0][1]).not.toContain(
|
||||
'posthog-react-native-session-replay'
|
||||
);
|
||||
const createdNames = jest
|
||||
.mocked(EnvironmentVariableMutation.createForAppAsync)
|
||||
.mock.calls.map(call => call[1].name);
|
||||
expect(createdNames).toEqual(['EXPO_PUBLIC_POSTHOG_API_KEY', 'EXPO_PUBLIC_POSTHOG_HOST']);
|
||||
expect(promptAsync).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'apiKey' }));
|
||||
});
|
||||
|
||||
it('adds the config plugin and public env vars for session-replay-only (analytics deselected)', async () => {
|
||||
mockFeatureSelection(['session-replay']);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
// The plugin wires native modules replay needs, so it must be added even without analytics.
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalled();
|
||||
expect(jest.mocked(spawnAsync).mock.calls[0][1]).toContain(
|
||||
'posthog-react-native-session-replay'
|
||||
);
|
||||
// The public key + host initialize the SDK for any feature, so they're still written.
|
||||
const createdNames = jest
|
||||
.mocked(EnvironmentVariableMutation.createForAppAsync)
|
||||
.mock.calls.map(call => call[1].name);
|
||||
expect(createdNames).toEqual(['EXPO_PUBLIC_POSTHOG_API_KEY', 'EXPO_PUBLIC_POSTHOG_HOST']);
|
||||
});
|
||||
|
||||
it('captures the install child output so --json stdout stays clean', async () => {
|
||||
await createCommand(['--json', '--posthog-cli-api-key', 'phx_x']).runAsync();
|
||||
|
||||
expect(jest.mocked(spawnAsync).mock.calls[0][2]).not.toHaveProperty('stdio');
|
||||
});
|
||||
|
||||
it('non-interactive defaults: analytics + replay, error tracking auto-skipped', async () => {
|
||||
await createCommand(['--non-interactive']).runAsync();
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
const createdNames = jest
|
||||
.mocked(EnvironmentVariableMutation.createForAppAsync)
|
||||
.mock.calls.map(call => call[1].name);
|
||||
expect(createdNames).not.toContain('POSTHOG_CLI_API_KEY');
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('Skipping error tracking'));
|
||||
// Session replay defaults on, so its package is still installed.
|
||||
expect(jest.mocked(spawnAsync).mock.calls[0][1]).toContain(
|
||||
'posthog-react-native-session-replay'
|
||||
);
|
||||
});
|
||||
|
||||
it('non-interactive --error-tracking without a key fails before any provisioning', async () => {
|
||||
jest
|
||||
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
|
||||
.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
createCommand(['--non-interactive', '--error-tracking']).runAsync()
|
||||
).rejects.toThrow(/personal API key in non-interactive/);
|
||||
|
||||
expect(PostHogMutation.createPostHogAccountRequestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('non-interactive --error-tracking with --posthog-cli-api-key sets up CLI vars without prompting', async () => {
|
||||
await createCommand([
|
||||
'--non-interactive',
|
||||
'--error-tracking',
|
||||
'--posthog-cli-api-key',
|
||||
'phx_from_flag',
|
||||
]).runAsync();
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
const cliKeyVar = jest
|
||||
.mocked(EnvironmentVariableMutation.createForAppAsync)
|
||||
.mock.calls.find(call => call[1].name === 'POSTHOG_CLI_API_KEY');
|
||||
expect(cliKeyVar?.[1].value).toBe('phx_from_flag');
|
||||
});
|
||||
|
||||
it('treats a whitespace-only --posthog-cli-api-key as absent (fails like no key)', async () => {
|
||||
await expect(
|
||||
createCommand([
|
||||
'--non-interactive',
|
||||
'--error-tracking',
|
||||
'--posthog-cli-api-key',
|
||||
' ',
|
||||
]).runAsync()
|
||||
).rejects.toThrow(/personal API key in non-interactive/);
|
||||
});
|
||||
|
||||
it('surfaces a manual plugin step and still writes env vars when the app config is dynamic', async () => {
|
||||
mockFeatureSelection(['analytics']);
|
||||
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({
|
||||
type: 'warn',
|
||||
message: 'Cannot automatically write to dynamic config',
|
||||
} as any);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Add "posthog-react-native/expo" to the "plugins"')
|
||||
);
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the plugin manual step (without a cause) when the config write fails', async () => {
|
||||
mockFeatureSelection(['analytics']);
|
||||
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({ type: 'fail' } as any);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Add "posthog-react-native/expo" to the "plugins"')
|
||||
);
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reuses an existing connection + project without provisioning', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(PostHogMutation.createPostHogAccountRequestAsync).not.toHaveBeenCalled();
|
||||
expect(PostHogMutation.setupPostHogProjectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces the existing-account dead-end without provisioning a project', async () => {
|
||||
jest
|
||||
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
|
||||
.mockResolvedValue(null);
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
jest.mocked(PostHogMutation.createPostHogAccountRequestAsync).mockRejectedValue(
|
||||
Object.assign(new Error('You already have a PostHog account.'), {
|
||||
graphQLErrors: [{ extensions: { errorCode: 'POSTHOG_EXISTING_USER_NOT_SUPPORTED_ERROR' } }],
|
||||
})
|
||||
);
|
||||
|
||||
await expect(createCommand(['--region', 'US']).runAsync()).rejects.toThrow(
|
||||
'You already have a PostHog account.'
|
||||
);
|
||||
|
||||
// Fails (non-zero exit) without provisioning a project or writing any config.
|
||||
expect(PostHogMutation.setupPostHogProjectAsync).not.toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON output with --json', async () => {
|
||||
await createCommand(['--json', '--posthog-cli-api-key', 'phx_x']).runAsync();
|
||||
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
project: expect.objectContaining({ apiKey: 'phc_public_key' }),
|
||||
// --json supplies the key, so error tracking is on and its vars are reported.
|
||||
features: expect.objectContaining({ analytics: true, errorTracking: true }),
|
||||
dashboardUrl: expect.any(String),
|
||||
environmentVariables: expect.arrayContaining([
|
||||
'EXPO_PUBLIC_POSTHOG_API_KEY',
|
||||
'POSTHOG_CLI_API_KEY',
|
||||
'POSTHOG_CLI_PROJECT_ID',
|
||||
'POSTHOG_CLI_HOST',
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('requires --region in non-interactive mode when provisioning', async () => {
|
||||
jest
|
||||
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
|
||||
.mockResolvedValue(null);
|
||||
|
||||
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
|
||||
/region is required in non-interactive/
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when --region differs from the already-connected region', async () => {
|
||||
await createCommand(['--region', 'EU']).runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('already connected to PostHog'));
|
||||
});
|
||||
|
||||
it('provisions a new connection and project when none exist', async () => {
|
||||
jest
|
||||
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
|
||||
.mockResolvedValue(null);
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
mockFeatureSelection(['analytics']);
|
||||
|
||||
await createCommand(['--region', 'US']).runAsync();
|
||||
|
||||
expect(PostHogMutation.createPostHogAccountRequestAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
accountId: testAccountId,
|
||||
region: PostHogRegion.Us,
|
||||
});
|
||||
expect(PostHogMutation.setupPostHogProjectAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
posthogOrganizationConnectionId: mockConnection.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows a non-dead-end provisioning failure', async () => {
|
||||
jest
|
||||
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
|
||||
.mockResolvedValue(null);
|
||||
jest
|
||||
.mocked(PostHogMutation.createPostHogAccountRequestAsync)
|
||||
.mockRejectedValue(new Error('network down'));
|
||||
|
||||
await expect(createCommand(['--region', 'US']).runAsync()).rejects.toThrow('network down');
|
||||
});
|
||||
|
||||
it('rethrows when project setup fails', async () => {
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
jest
|
||||
.mocked(PostHogMutation.setupPostHogProjectAsync)
|
||||
.mockRejectedValue(new Error('setup failed'));
|
||||
|
||||
await expect(createCommand([]).runAsync()).rejects.toThrow('setup failed');
|
||||
});
|
||||
|
||||
it('honors explicit --no-session-replay without showing the feature multiselect', async () => {
|
||||
await createCommand(['--no-session-replay']).runAsync();
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'features' }));
|
||||
expect(jest.mocked(spawnAsync).mock.calls[0][1]).not.toContain(
|
||||
'posthog-react-native-session-replay'
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for a personal API key and validates it', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
const apiKeyPrompt = jest
|
||||
.mocked(promptAsync)
|
||||
.mock.calls.map(call => call[0])
|
||||
.find((opts: any) => opts.name === 'apiKey') as any;
|
||||
expect(apiKeyPrompt).toBeDefined();
|
||||
expect(apiKeyPrompt.validate('')).toBe('Personal API key cannot be empty');
|
||||
expect(apiKeyPrompt.validate('phx_real')).toBe(true);
|
||||
});
|
||||
|
||||
it('continues (does not abort) and still writes env vars when SDK installation fails', async () => {
|
||||
mockFeatureSelection(['analytics']);
|
||||
jest.mocked(spawnAsync).mockRejectedValue(new Error('npm exploded'));
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
// Install failure is non-fatal: provisioning + env vars still happen, with a manual follow-up.
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
|
||||
expect(fs.writeFile).toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('npx expo install'));
|
||||
});
|
||||
|
||||
it('on a dynamic config, surfaces only the plugin step (not a reinstall step) when expo install reports the dynamic-config error', async () => {
|
||||
mockFeatureSelection(['analytics']);
|
||||
jest.mocked(spawnAsync).mockRejectedValue(
|
||||
Object.assign(new Error('Process exited with non-zero code: 1'), {
|
||||
stdout: 'Cannot automatically write to dynamic config at: app.config.js\n',
|
||||
stderr: '',
|
||||
})
|
||||
);
|
||||
jest.mocked(createOrModifyExpoConfigAsync).mockResolvedValue({
|
||||
type: 'warn',
|
||||
message: 'Cannot automatically write to dynamic config at: app.config.js',
|
||||
} as any);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Add "posthog-react-native/expo" to the "plugins"')
|
||||
);
|
||||
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining("didn't install"));
|
||||
});
|
||||
|
||||
it('skips the config plugin when it is already configured', async () => {
|
||||
mockFeatureSelection(['analytics']);
|
||||
const command = createCommand([]);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
exp: { name: 'testapp', slug: 'testapp', plugins: ['posthog-react-native/expo'] },
|
||||
projectDir: '/test/project',
|
||||
},
|
||||
loggedIn: { graphqlClient, actor: mockActor },
|
||||
} as never);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing local when no features are selected (no install, no writes)', async () => {
|
||||
mockFeatureSelection([]);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(spawnAsync).not.toHaveBeenCalled();
|
||||
expect(createOrModifyExpoConfigAsync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No PostHog features selected'));
|
||||
// Doesn't falsely claim the SDK is wired up when nothing was configured.
|
||||
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('PostHog is connected!'));
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('Re-run to add'));
|
||||
});
|
||||
|
||||
it('merges into an existing .env.local, replacing matching keys and appending new ones', async () => {
|
||||
mockFeatureSelection(['analytics']);
|
||||
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
|
||||
jest
|
||||
.mocked(fs.readFile)
|
||||
.mockResolvedValue('EXPO_PUBLIC_POSTHOG_API_KEY=old_value\nOTHER=keep' as never);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
const written = jest.mocked(fs.writeFile).mock.calls[0][1] as string;
|
||||
expect(written).toContain('EXPO_PUBLIC_POSTHOG_API_KEY=phc_public_key');
|
||||
expect(written).toContain('OTHER=keep');
|
||||
expect(written).toContain('EXPO_PUBLIC_POSTHOG_HOST=https://us.posthog.com');
|
||||
});
|
||||
|
||||
it('prompts for a region interactively when provisioning without --region', async () => {
|
||||
jest
|
||||
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
|
||||
.mockResolvedValue(null);
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
mockFeatureSelection(['analytics']);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(selectAsync).toHaveBeenCalledWith('Select a PostHog region', expect.any(Array));
|
||||
expect(PostHogMutation.createPostHogAccountRequestAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
accountId: testAccountId,
|
||||
region: PostHogRegion.Us,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips .env.local when the user declines to overwrite a conflict', async () => {
|
||||
mockFeatureSelection(['analytics']);
|
||||
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
|
||||
jest.mocked(fs.readFile).mockResolvedValue('EXPO_PUBLIC_POSTHOG_API_KEY=old_value\n' as never);
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('Skipped updating'));
|
||||
});
|
||||
|
||||
it('--overwrite writes .env.local without prompting on a conflict', async () => {
|
||||
mockFeatureSelection(['analytics']);
|
||||
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
|
||||
jest.mocked(fs.readFile).mockResolvedValue('EXPO_PUBLIC_POSTHOG_API_KEY=old_value\n' as never);
|
||||
|
||||
await createCommand(['--overwrite']).runAsync();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
const written = jest.mocked(fs.writeFile).mock.calls[0][1] as string;
|
||||
expect(written).toContain('EXPO_PUBLIC_POSTHOG_API_KEY=phc_public_key');
|
||||
});
|
||||
|
||||
it('non-interactively skips an existing .env.local key without --overwrite', async () => {
|
||||
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
|
||||
jest.mocked(fs.readFile).mockResolvedValue('EXPO_PUBLIC_POSTHOG_API_KEY=old_value\n' as never);
|
||||
|
||||
await createCommand(['--non-interactive']).runAsync();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('pass --overwrite to replace'));
|
||||
});
|
||||
|
||||
it('non-interactively overwrites an existing .env.local key with --overwrite', async () => {
|
||||
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
|
||||
jest.mocked(fs.readFile).mockResolvedValue('EXPO_PUBLIC_POSTHOG_API_KEY=old_value\n' as never);
|
||||
|
||||
await createCommand(['--non-interactive', '--overwrite']).runAsync();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
const written = jest.mocked(fs.writeFile).mock.calls[0][1] as string;
|
||||
expect(written).toContain('EXPO_PUBLIC_POSTHOG_API_KEY=phc_public_key');
|
||||
});
|
||||
|
||||
it('writes a personal API key containing $ verbatim (no replacement-pattern expansion)', async () => {
|
||||
const command = createCommand([]);
|
||||
const merged = (command as any).mergeEnvContent('POSTHOG_CLI_API_KEY=old\n', {
|
||||
POSTHOG_CLI_API_KEY: 'phx_a$2b$&c',
|
||||
});
|
||||
expect(merged).toContain('POSTHOG_CLI_API_KEY=phx_a$2b$&c');
|
||||
});
|
||||
|
||||
it('updates an existing EAS environment variable with --overwrite', async () => {
|
||||
mockFeatureSelection(['analytics']);
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
.mockResolvedValue([{ id: 'existing-1', scope: EnvironmentVariableScope.Project }] as any);
|
||||
jest
|
||||
.mocked(EnvironmentVariableMutation.updateAsync)
|
||||
.mockResolvedValue({ id: 'existing-1' } as any);
|
||||
|
||||
await createCommand(['--overwrite']).runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.updateAsync).toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honors explicit --error-tracking interactively and detects an array-form plugin', async () => {
|
||||
const command = createCommand(['--error-tracking', '--posthog-cli-api-key', 'phx_flag']);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
exp: { name: 'testapp', slug: 'testapp', plugins: [['expo-router', {}]] },
|
||||
projectDir: '/test/project',
|
||||
},
|
||||
loggedIn: { graphqlClient, actor: mockActor },
|
||||
} as never);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(promptAsync).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'features' }));
|
||||
const cliKeyVar = jest
|
||||
.mocked(EnvironmentVariableMutation.createForAppAsync)
|
||||
.mock.calls.find(call => call[1].name === 'POSTHOG_CLI_API_KEY');
|
||||
expect(cliKeyVar?.[1].value).toBe('phx_flag');
|
||||
expect(createOrModifyExpoConfigAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips an existing EAS environment variable non-interactively without --overwrite', async () => {
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
.mockResolvedValue([{ id: 'existing-1', scope: EnvironmentVariableScope.Project }] as any);
|
||||
|
||||
await createCommand(['--non-interactive', '--region', 'US']).runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('pass --overwrite to replace it')
|
||||
);
|
||||
});
|
||||
|
||||
it('skips an existing EAS environment variable when overwrite is declined', async () => {
|
||||
mockFeatureSelection(['analytics']);
|
||||
jest
|
||||
.mocked(EnvironmentVariablesQuery.byAppIdAsync)
|
||||
.mockResolvedValue([{ id: 'existing-1', scope: EnvironmentVariableScope.Project }] as any);
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(EnvironmentVariableMutation.updateAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Skipped updating EAS environment variable')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import openBrowserAsync from 'better-opn';
|
||||
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
|
||||
import { PostHogRegion } from '../../../../graphql/generated';
|
||||
import { PostHogQuery } from '../../../../graphql/queries/PostHogQuery';
|
||||
import {
|
||||
PostHogOrganizationConnectionData,
|
||||
PostHogProjectData,
|
||||
} from '../../../../graphql/types/PostHogConnection';
|
||||
import Log from '../../../../log';
|
||||
import { ora } from '../../../../ora';
|
||||
import { printJsonOnlyOutput } from '../../../../utils/json';
|
||||
import IntegrationsPostHogDashboard from '../dashboard';
|
||||
|
||||
jest.mock('better-opn');
|
||||
jest.mock('../../../../graphql/queries/PostHogQuery');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../utils/json');
|
||||
jest.mock('../../../../ora', () => ({
|
||||
ora: jest.fn(() => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe(IntegrationsPostHogDashboard, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
const mockConnection: PostHogOrganizationConnectionData = {
|
||||
id: 'connection-1',
|
||||
posthogOrganizationIdentifier: 'org-123',
|
||||
posthogOrganizationName: 'Test Org',
|
||||
posthogRegion: PostHogRegion.Us,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const mockProject: PostHogProjectData = {
|
||||
id: 'project-1',
|
||||
posthogProjectIdentifier: 'res-123',
|
||||
posthogProjectName: 'Test Project',
|
||||
posthogProjectToken: 'phc_public_key',
|
||||
posthogHost: 'https://us.posthog.com',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
posthogOrganizationConnection: mockConnection,
|
||||
};
|
||||
|
||||
function createCommand(argv: string[] = []): IntegrationsPostHogDashboard {
|
||||
const command = new IntegrationsPostHogDashboard(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
exp: { slug: 'testapp' },
|
||||
},
|
||||
loggedIn: { graphqlClient },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(mockProject);
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue({} as never);
|
||||
jest.mocked(ora).mockReturnValue({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('opens the linked PostHog project dashboard', async () => {
|
||||
await createCommand().runAsync();
|
||||
|
||||
expect(openBrowserAsync).toHaveBeenCalledWith('https://us.posthog.com/project/res-123');
|
||||
});
|
||||
|
||||
it('normalizes a trailing-slash host and percent-encodes the project identifier', async () => {
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue({
|
||||
...mockProject,
|
||||
posthogHost: 'https://us.posthog.com/',
|
||||
posthogProjectIdentifier: 'team a/b',
|
||||
});
|
||||
|
||||
await createCommand().runAsync();
|
||||
|
||||
expect(openBrowserAsync).toHaveBeenCalledWith('https://us.posthog.com/project/team%20a%2Fb');
|
||||
});
|
||||
|
||||
it('logs an empty state when no project link exists', async () => {
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
|
||||
await createCommand().runAsync();
|
||||
|
||||
expect(openBrowserAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No PostHog project is linked to Expo app')
|
||||
);
|
||||
});
|
||||
|
||||
it('fails the spinner when the browser cannot be opened', async () => {
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(false);
|
||||
const spinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
};
|
||||
jest.mocked(ora).mockReturnValue(spinner as any);
|
||||
|
||||
await createCommand().runAsync();
|
||||
|
||||
expect(spinner.fail).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unable to open a web browser')
|
||||
);
|
||||
});
|
||||
|
||||
it('prints the dashboard URL in non-interactive mode without opening a browser', async () => {
|
||||
await createCommand(['--non-interactive']).runAsync();
|
||||
|
||||
expect(openBrowserAsync).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith('https://us.posthog.com/project/res-123');
|
||||
});
|
||||
|
||||
it('emits the dashboard URL as JSON with --json', async () => {
|
||||
await createCommand(['--json']).runAsync();
|
||||
|
||||
expect(openBrowserAsync).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
dashboardUrl: 'https://us.posthog.com/project/res-123',
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a null dashboardUrl as JSON when no project is linked', async () => {
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
|
||||
await createCommand(['--json']).runAsync();
|
||||
|
||||
expect(openBrowserAsync).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({ dashboardUrl: null });
|
||||
});
|
||||
|
||||
it('fails the spinner and rethrows when opening the browser throws', async () => {
|
||||
jest.mocked(openBrowserAsync).mockRejectedValue(new Error('no browser'));
|
||||
const spinner = {
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
};
|
||||
jest.mocked(ora).mockReturnValue(spinner as any);
|
||||
|
||||
await expect(createCommand().runAsync()).rejects.toThrow('no browser');
|
||||
expect(spinner.fail).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
|
||||
import { PostHogRegion } from '../../../../graphql/generated';
|
||||
import { PostHogMutation } from '../../../../graphql/mutations/PostHogMutation';
|
||||
import { PostHogQuery } from '../../../../graphql/queries/PostHogQuery';
|
||||
import {
|
||||
PostHogOrganizationConnectionData,
|
||||
PostHogProjectData,
|
||||
} from '../../../../graphql/types/PostHogConnection';
|
||||
import Log from '../../../../log';
|
||||
import { confirmAsync } from '../../../../prompts';
|
||||
import { printJsonOnlyOutput } from '../../../../utils/json';
|
||||
import IntegrationsPostHogDisconnect from '../disconnect';
|
||||
|
||||
jest.mock('../../../../graphql/queries/PostHogQuery');
|
||||
jest.mock('../../../../graphql/mutations/PostHogMutation');
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('../../../../log');
|
||||
jest.mock('../../../../utils/json');
|
||||
jest.mock('../../../../ora', () => ({
|
||||
ora: () => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe(IntegrationsPostHogDisconnect, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
|
||||
const mockConnection: PostHogOrganizationConnectionData = {
|
||||
id: 'connection-1',
|
||||
posthogOrganizationIdentifier: 'org-123',
|
||||
posthogOrganizationName: 'Test Org',
|
||||
posthogRegion: PostHogRegion.Us,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const mockProject: PostHogProjectData = {
|
||||
id: 'project-1',
|
||||
posthogProjectIdentifier: 'res-123',
|
||||
posthogProjectName: 'Test Project',
|
||||
posthogProjectToken: 'phc_public_key',
|
||||
posthogHost: 'https://us.posthog.com',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
posthogOrganizationConnection: mockConnection,
|
||||
};
|
||||
|
||||
function createCommand(argv: string[]): IntegrationsPostHogDisconnect {
|
||||
const command = new IntegrationsPostHogDisconnect(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
exp: { slug: 'testapp' },
|
||||
},
|
||||
loggedIn: { graphqlClient },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'error').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(mockProject);
|
||||
jest.mocked(PostHogMutation.deletePostHogProjectAsync).mockResolvedValue(mockProject.id);
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('removes the project link after confirmation', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(PostHogMutation.deletePostHogProjectAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
mockProject.id
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the prompt with --yes', async () => {
|
||||
await createCommand(['--yes']).runAsync();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(PostHogMutation.deletePostHogProjectAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
mockProject.id
|
||||
);
|
||||
});
|
||||
|
||||
it('does nothing when the user declines confirmation', async () => {
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(PostHogMutation.deletePostHogProjectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes after a warning in non-interactive mode, noting PostHog data is preserved', async () => {
|
||||
await createCommand(['--non-interactive']).runAsync();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('does not delete the project on PostHog')
|
||||
);
|
||||
expect(PostHogMutation.deletePostHogProjectAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
mockProject.id
|
||||
);
|
||||
});
|
||||
|
||||
it('logs an empty state when no project link exists', async () => {
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(PostHogMutation.deletePostHogProjectAsync).not.toHaveBeenCalled();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No PostHog project is linked to Expo app')
|
||||
);
|
||||
});
|
||||
|
||||
it('emits JSON output with --json and skips the prompt', async () => {
|
||||
await createCommand(['--json']).runAsync();
|
||||
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(PostHogMutation.deletePostHogProjectAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
mockProject.id
|
||||
);
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
id: mockProject.id,
|
||||
name: mockProject.posthogProjectName,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits a null id as JSON when no project is linked', async () => {
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
|
||||
await createCommand(['--json']).runAsync();
|
||||
|
||||
expect(PostHogMutation.deletePostHogProjectAsync).not.toHaveBeenCalled();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({ id: null });
|
||||
});
|
||||
|
||||
it('rethrows when the delete mutation fails', async () => {
|
||||
jest
|
||||
.mocked(PostHogMutation.deletePostHogProjectAsync)
|
||||
.mockRejectedValue(new Error('delete boom'));
|
||||
|
||||
await expect(createCommand(['--yes']).runAsync()).rejects.toThrow('delete boom');
|
||||
});
|
||||
});
|
||||
@@ -1,568 +0,0 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
import dotenv from 'dotenv';
|
||||
import * as fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { DefaultEnvironment } from '../../../build/utils/environment';
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import { getPostHogProjectDashboardUrl } from '../../../commandUtils/posthog';
|
||||
import {
|
||||
EnvironmentSecretType,
|
||||
EnvironmentVariableScope,
|
||||
EnvironmentVariableVisibility,
|
||||
PostHogRegion,
|
||||
} from '../../../graphql/generated';
|
||||
import { EnvironmentVariableMutation } from '../../../graphql/mutations/EnvironmentVariableMutation';
|
||||
import { PostHogMutation } from '../../../graphql/mutations/PostHogMutation';
|
||||
import { EnvironmentVariablesQuery } from '../../../graphql/queries/EnvironmentVariablesQuery';
|
||||
import { PostHogQuery } from '../../../graphql/queries/PostHogQuery';
|
||||
import { PostHogProjectData } from '../../../graphql/types/PostHogConnection';
|
||||
import Log, { link } from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
|
||||
import { Choice, confirmAsync, promptAsync, selectAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
const POSTHOG_REGIONS = [
|
||||
{ title: 'United States (US)', value: PostHogRegion.Us },
|
||||
{ title: 'European Union (EU)', value: PostHogRegion.Eu },
|
||||
];
|
||||
|
||||
const EAS_POSTHOG_ENVIRONMENTS = [
|
||||
DefaultEnvironment.Production,
|
||||
DefaultEnvironment.Preview,
|
||||
DefaultEnvironment.Development,
|
||||
];
|
||||
|
||||
const SDK_PACKAGES = [
|
||||
'posthog-react-native',
|
||||
'expo-file-system',
|
||||
'expo-application',
|
||||
'expo-device',
|
||||
'expo-localization',
|
||||
];
|
||||
const SESSION_REPLAY_PACKAGE = 'posthog-react-native-session-replay';
|
||||
const CONFIG_PLUGIN = 'posthog-react-native/expo';
|
||||
|
||||
const EAS_POSTHOG_API_KEY_ENV_VAR_NAME = 'EXPO_PUBLIC_POSTHOG_API_KEY';
|
||||
const EAS_POSTHOG_HOST_ENV_VAR_NAME = 'EXPO_PUBLIC_POSTHOG_HOST';
|
||||
const POSTHOG_CLI_API_KEY_ENV_VAR_NAME = 'POSTHOG_CLI_API_KEY';
|
||||
const POSTHOG_CLI_PROJECT_ID_ENV_VAR_NAME = 'POSTHOG_CLI_PROJECT_ID';
|
||||
const POSTHOG_CLI_HOST_ENV_VAR_NAME = 'POSTHOG_CLI_HOST';
|
||||
|
||||
const PERSONAL_API_KEY_SETTINGS_PATH = '/settings/user-api-keys';
|
||||
const ERROR_TRACKING_NEEDS_KEY_MESSAGE = `Error tracking needs a PostHog personal API key in non-interactive mode. Pass --posthog-cli-api-key (create one in PostHog under Settings → Personal API keys (${PERSONAL_API_KEY_SETTINGS_PATH}) with the "Source map upload" preset), or drop --error-tracking.`;
|
||||
|
||||
type Features = { analytics: boolean; sessionReplay: boolean; errorTracking: boolean };
|
||||
type EnvVar = { name: string; value: string; visibility: EnvironmentVariableVisibility };
|
||||
|
||||
function getSpawnErrorOutput(error: unknown): string {
|
||||
const { stdout, stderr } = (error ?? {}) as { stdout?: string; stderr?: string };
|
||||
return `${stdout ?? ''}${stderr ?? ''}`;
|
||||
}
|
||||
|
||||
export default class IntegrationsPostHogConnect extends EasCommand {
|
||||
static override description = 'connect PostHog to your Expo project';
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
region: Flags.string({
|
||||
description: 'PostHog region',
|
||||
options: POSTHOG_REGIONS.map(r => r.value),
|
||||
}),
|
||||
'session-replay': Flags.boolean({
|
||||
allowNo: true,
|
||||
description: 'Set up PostHog session replay (default: yes)',
|
||||
}),
|
||||
'error-tracking': Flags.boolean({
|
||||
allowNo: true,
|
||||
description: 'Set up PostHog error tracking / source maps (requires a personal API key)',
|
||||
}),
|
||||
'posthog-cli-api-key': Flags.string({
|
||||
description:
|
||||
'PostHog personal API key for error-tracking source-map uploads (enables error tracking non-interactively)',
|
||||
}),
|
||||
overwrite: Flags.boolean({
|
||||
description: 'Overwrite existing PostHog environment variables without prompting',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsPostHogConnect);
|
||||
const { region: regionFlag, overwrite } = flags;
|
||||
const cliApiKeyFlag =
|
||||
(flags['posthog-cli-api-key'] ?? process.env[POSTHOG_CLI_API_KEY_ENV_VAR_NAME])?.trim() ||
|
||||
undefined;
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
if (nonInteractive && flags['error-tracking'] === true && !cliApiKeyFlag) {
|
||||
throw new Error(ERROR_TRACKING_NEEDS_KEY_MESSAGE);
|
||||
}
|
||||
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp, projectDir },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsPostHogConnect, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
|
||||
let connection = await PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync(
|
||||
graphqlClient,
|
||||
account.id
|
||||
);
|
||||
if (connection) {
|
||||
if (regionFlag && regionFlag !== connection.posthogRegion) {
|
||||
Log.warn(
|
||||
`This account is already connected to PostHog in the ${connection.posthogRegion} region; --region ${regionFlag} is ignored. An account has a single PostHog organization and its region can't be changed.`
|
||||
);
|
||||
}
|
||||
Log.withTick(
|
||||
`Using existing PostHog organization ${chalk.bold(connection.posthogOrganizationName)} (${connection.posthogRegion})`
|
||||
);
|
||||
} else {
|
||||
const region = await this.resolveRegionAsync(regionFlag, nonInteractive);
|
||||
const spinner = ora('Creating PostHog organization').start();
|
||||
try {
|
||||
connection = await PostHogMutation.createPostHogAccountRequestAsync(graphqlClient, {
|
||||
accountId: account.id,
|
||||
region,
|
||||
});
|
||||
spinner.succeed(
|
||||
`Created PostHog organization ${chalk.bold(connection.posthogOrganizationName)}`
|
||||
);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to create PostHog organization');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let project = await PostHogQuery.getPostHogProjectByAppIdAsync(graphqlClient, projectId);
|
||||
if (project) {
|
||||
Log.withTick(`Using existing PostHog project ${chalk.bold(project.posthogProjectName)}`);
|
||||
} else {
|
||||
const spinner = ora('Setting up PostHog project').start();
|
||||
try {
|
||||
project = await PostHogMutation.setupPostHogProjectAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
posthogOrganizationConnectionId: connection.id,
|
||||
});
|
||||
spinner.succeed(`Created PostHog project ${chalk.bold(project.posthogProjectName)}`);
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to set up PostHog project');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const features = await this.resolveFeaturesAsync(flags, cliApiKeyFlag, nonInteractive);
|
||||
const anyFeatureSelected =
|
||||
features.analytics || features.sessionReplay || features.errorTracking;
|
||||
|
||||
const envVars: EnvVar[] = [];
|
||||
if (anyFeatureSelected) {
|
||||
envVars.push(
|
||||
{
|
||||
name: EAS_POSTHOG_API_KEY_ENV_VAR_NAME,
|
||||
value: project.posthogProjectToken,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
},
|
||||
{
|
||||
name: EAS_POSTHOG_HOST_ENV_VAR_NAME,
|
||||
value: project.posthogHost,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (features.errorTracking) {
|
||||
const cliApiKey = await this.resolveCliApiKeyAsync(cliApiKeyFlag, project.posthogHost);
|
||||
envVars.push(
|
||||
{
|
||||
name: POSTHOG_CLI_API_KEY_ENV_VAR_NAME,
|
||||
value: cliApiKey,
|
||||
visibility: EnvironmentVariableVisibility.Sensitive,
|
||||
},
|
||||
{
|
||||
name: POSTHOG_CLI_PROJECT_ID_ENV_VAR_NAME,
|
||||
value: project.posthogProjectIdentifier,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
},
|
||||
{
|
||||
name: POSTHOG_CLI_HOST_ENV_VAR_NAME,
|
||||
value: project.posthogHost,
|
||||
visibility: EnvironmentVariableVisibility.Public,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const manualSteps: string[] = [];
|
||||
if (anyFeatureSelected) {
|
||||
const packages = [...SDK_PACKAGES];
|
||||
if (features.sessionReplay) {
|
||||
packages.push(SESSION_REPLAY_PACKAGE);
|
||||
}
|
||||
const installResult = await this.installSdkPackagesAsync(projectDir, packages, jsonFlag);
|
||||
if (installResult === 'failed') {
|
||||
manualSteps.push(
|
||||
`The PostHog SDK packages didn't install. Run npx expo install ${packages.join(' ')} from your project directory.`
|
||||
);
|
||||
}
|
||||
const pluginManualStep = await this.addConfigPluginAsync(projectDir, exp);
|
||||
if (pluginManualStep) {
|
||||
manualSteps.push(pluginManualStep);
|
||||
}
|
||||
}
|
||||
|
||||
await this.writeEnvLocalAsync(projectDir, envVars, nonInteractive, overwrite);
|
||||
await Promise.all(
|
||||
envVars.map(envVar =>
|
||||
this.upsertEasEnvVarAsync(graphqlClient, projectId, envVar, nonInteractive, overwrite)
|
||||
)
|
||||
);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
organizationConnection: {
|
||||
id: connection.id,
|
||||
name: connection.posthogOrganizationName,
|
||||
region: connection.posthogRegion,
|
||||
},
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.posthogProjectName,
|
||||
apiKey: project.posthogProjectToken,
|
||||
host: project.posthogHost,
|
||||
},
|
||||
features,
|
||||
dashboardUrl: getPostHogProjectDashboardUrl(project),
|
||||
environmentVariables: envVars.map(v => v.name),
|
||||
manualSteps,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!anyFeatureSelected) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.green('PostHog project created.'));
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`${chalk.bold('Dashboard')}: ${link(getPostHogProjectDashboardUrl(project), { dim: false })}`
|
||||
);
|
||||
Log.newLine();
|
||||
Log.warn(
|
||||
'No PostHog features selected, so no SDK, config plugin, or environment variables were set up. Re-run to add analytics, session replay, or error tracking.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.printNextSteps(project, features, manualSteps);
|
||||
}
|
||||
|
||||
private async resolveFeaturesAsync(
|
||||
flags: { 'session-replay'?: boolean; 'error-tracking'?: boolean },
|
||||
cliApiKey: string | undefined,
|
||||
nonInteractive: boolean
|
||||
): Promise<Features> {
|
||||
const sessionReplayFlag = flags['session-replay'];
|
||||
const errorTrackingFlag = flags['error-tracking'];
|
||||
const hasCliApiKey = !!cliApiKey;
|
||||
|
||||
if (nonInteractive) {
|
||||
const errorTracking = errorTrackingFlag ?? hasCliApiKey;
|
||||
if (errorTrackingFlag === undefined && !hasCliApiKey) {
|
||||
Log.warn(
|
||||
'Skipping error tracking (source maps) — it needs a personal API key. Re-run interactively, or pass --posthog-cli-api-key to enable it non-interactively.'
|
||||
);
|
||||
}
|
||||
return { analytics: true, sessionReplay: sessionReplayFlag ?? true, errorTracking };
|
||||
}
|
||||
|
||||
if (sessionReplayFlag !== undefined || errorTrackingFlag !== undefined) {
|
||||
return {
|
||||
analytics: true,
|
||||
sessionReplay: sessionReplayFlag ?? true,
|
||||
errorTracking: errorTrackingFlag ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
const { features } = await promptAsync({
|
||||
type: 'multiselect',
|
||||
name: 'features',
|
||||
message: 'PostHog features to set up',
|
||||
choices: [
|
||||
{ title: 'Analytics', value: 'analytics', selected: true },
|
||||
{ title: 'Session replay', value: 'session-replay', selected: true },
|
||||
{
|
||||
title: 'Error tracking (source maps) — requires a personal API key',
|
||||
value: 'error-tracking',
|
||||
selected: true,
|
||||
},
|
||||
] satisfies Choice[],
|
||||
instructions: false,
|
||||
min: 0,
|
||||
});
|
||||
const selected = new Set<string>(features as string[]);
|
||||
return {
|
||||
analytics: selected.has('analytics'),
|
||||
sessionReplay: selected.has('session-replay'),
|
||||
errorTracking: selected.has('error-tracking'),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveCliApiKeyAsync(
|
||||
cliApiKey: string | undefined,
|
||||
host: string
|
||||
): Promise<string> {
|
||||
if (cliApiKey) {
|
||||
return cliApiKey;
|
||||
}
|
||||
const settingsUrl = `${host.replace(/\/$/, '')}${PERSONAL_API_KEY_SETTINGS_PATH}`;
|
||||
const { apiKey } = await promptAsync({
|
||||
type: 'password',
|
||||
name: 'apiKey',
|
||||
message: `Paste a PostHog personal API key for source-map uploads.\nCreate one at ${settingsUrl} using the "Source map upload" preset.`,
|
||||
validate: (value: string) => (value.trim() ? true : 'Personal API key cannot be empty'),
|
||||
});
|
||||
return apiKey.trim();
|
||||
}
|
||||
|
||||
private async resolveRegionAsync(
|
||||
flagValue: string | undefined,
|
||||
nonInteractive: boolean
|
||||
): Promise<PostHogRegion> {
|
||||
const flagRegion = POSTHOG_REGIONS.find(r => r.value === flagValue);
|
||||
if (flagRegion) {
|
||||
return flagRegion.value;
|
||||
}
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'A PostHog region is required in non-interactive mode. Pass --region US or --region EU. The region sets data residency and cannot be changed after connecting.'
|
||||
);
|
||||
}
|
||||
return await selectAsync('Select a PostHog region', POSTHOG_REGIONS);
|
||||
}
|
||||
|
||||
private async installSdkPackagesAsync(
|
||||
projectDir: string,
|
||||
packages: string[],
|
||||
jsonFlag: boolean
|
||||
): Promise<'installed' | 'failed'> {
|
||||
const spinner = jsonFlag ? null : ora('Installing the PostHog SDK packages').start();
|
||||
try {
|
||||
await spawnAsync('npx', ['expo', 'install', ...packages], { cwd: projectDir });
|
||||
spinner?.succeed('Installed the PostHog SDK packages');
|
||||
return 'installed';
|
||||
} catch (error) {
|
||||
const output = getSpawnErrorOutput(error);
|
||||
Log.debug(output || error);
|
||||
if (output.includes('Cannot automatically write to dynamic config')) {
|
||||
spinner?.succeed('Installed the PostHog SDK packages');
|
||||
return 'installed';
|
||||
}
|
||||
spinner?.fail('Failed to install the PostHog SDK packages');
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
private async addConfigPluginAsync(projectDir: string, exp: ExpoConfig): Promise<string | null> {
|
||||
const plugins = exp.plugins ?? [];
|
||||
const alreadyAdded = plugins.some(p => (Array.isArray(p) ? p[0] : p) === CONFIG_PLUGIN);
|
||||
if (alreadyAdded) {
|
||||
Log.withTick(`Config plugin ${chalk.bold(CONFIG_PLUGIN)} is already configured`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const modification = await createOrModifyExpoConfigAsync(
|
||||
projectDir,
|
||||
{ plugins: [...plugins, CONFIG_PLUGIN] },
|
||||
{ skipSDKVersionRequirement: true }
|
||||
);
|
||||
if (modification.type === 'success') {
|
||||
Log.withTick(`Added the ${chalk.bold(CONFIG_PLUGIN)} config plugin`);
|
||||
return null;
|
||||
}
|
||||
if (modification.type === 'warn') {
|
||||
return `${modification.message} Add ${JSON.stringify(CONFIG_PLUGIN)} to the "plugins" array in your app config.`;
|
||||
}
|
||||
return `Add ${JSON.stringify(CONFIG_PLUGIN)} to the "plugins" array in your app config.`;
|
||||
}
|
||||
|
||||
private async writeEnvLocalAsync(
|
||||
projectDir: string,
|
||||
envVars: EnvVar[],
|
||||
nonInteractive: boolean,
|
||||
overwrite: boolean
|
||||
): Promise<void> {
|
||||
if (envVars.length === 0) {
|
||||
return;
|
||||
}
|
||||
const envPath = path.join(projectDir, '.env.local');
|
||||
let rawContent = '';
|
||||
if (await fs.pathExists(envPath)) {
|
||||
rawContent = await fs.readFile(envPath, 'utf8');
|
||||
const existing = dotenv.parse(rawContent);
|
||||
const conflicts = envVars.filter(v => existing[v.name] !== undefined);
|
||||
if (conflicts.length > 0 && !overwrite) {
|
||||
if (nonInteractive) {
|
||||
Log.warn(
|
||||
`.env.local already defines ${conflicts
|
||||
.map(v => v.name)
|
||||
.join(', ')}; skipped (pass --overwrite to replace).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const confirmed = await confirmAsync({
|
||||
message: `.env.local already defines ${conflicts
|
||||
.map(v => v.name)
|
||||
.join(', ')}. Overwrite with the PostHog values?`,
|
||||
});
|
||||
if (!confirmed) {
|
||||
Log.warn(`Skipped updating ${chalk.bold('.env.local')}.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updatedContent = this.mergeEnvContent(
|
||||
rawContent,
|
||||
Object.fromEntries(envVars.map(v => [v.name, v.value]))
|
||||
);
|
||||
await fs.writeFile(envPath, updatedContent);
|
||||
Log.withTick(`Wrote PostHog config to ${chalk.bold('.env.local')}`);
|
||||
}
|
||||
|
||||
private mergeEnvContent(rawContent: string, newVars: Record<string, string>): string {
|
||||
let content = rawContent;
|
||||
const keysToAdd: Record<string, string> = { ...newVars };
|
||||
for (const [key, value] of Object.entries(newVars)) {
|
||||
const regex = new RegExp(`^${key}=.*$`, 'm');
|
||||
if (regex.test(content)) {
|
||||
content = content.replace(regex, () => `${key}=${value}`);
|
||||
delete keysToAdd[key];
|
||||
}
|
||||
}
|
||||
const remaining = Object.entries(keysToAdd);
|
||||
if (remaining.length > 0) {
|
||||
if (content.length > 0 && !content.endsWith('\n')) {
|
||||
content += '\n';
|
||||
}
|
||||
for (const [key, value] of remaining) {
|
||||
content += `${key}=${value}\n`;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
private async upsertEasEnvVarAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
envVar: EnvVar,
|
||||
nonInteractive: boolean,
|
||||
overwrite: boolean
|
||||
): Promise<void> {
|
||||
const existingVariables = await EnvironmentVariablesQuery.byAppIdAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
filterNames: [envVar.name],
|
||||
});
|
||||
const existingProjectVariable = existingVariables.find(
|
||||
variable => variable.scope === EnvironmentVariableScope.Project
|
||||
);
|
||||
|
||||
if (existingProjectVariable) {
|
||||
const shouldOverwrite =
|
||||
overwrite ||
|
||||
(!nonInteractive &&
|
||||
(await confirmAsync({
|
||||
message: `EAS already has an ${envVar.name} environment variable for this project. Overwrite it?`,
|
||||
})));
|
||||
if (!shouldOverwrite) {
|
||||
Log.warn(
|
||||
`Skipped updating EAS environment variable ${chalk.bold(envVar.name)}${
|
||||
nonInteractive ? ' (pass --overwrite to replace it)' : ''
|
||||
}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
await EnvironmentVariableMutation.updateAsync(graphqlClient, {
|
||||
id: existingProjectVariable.id,
|
||||
name: envVar.name,
|
||||
value: envVar.value,
|
||||
environments: EAS_POSTHOG_ENVIRONMENTS,
|
||||
visibility: envVar.visibility,
|
||||
type: EnvironmentSecretType.String,
|
||||
});
|
||||
Log.withTick(`Updated EAS environment variable ${chalk.bold(envVar.name)} for builds`);
|
||||
return;
|
||||
}
|
||||
|
||||
await EnvironmentVariableMutation.createForAppAsync(
|
||||
graphqlClient,
|
||||
{
|
||||
name: envVar.name,
|
||||
value: envVar.value,
|
||||
environments: EAS_POSTHOG_ENVIRONMENTS,
|
||||
visibility: envVar.visibility,
|
||||
type: EnvironmentSecretType.String,
|
||||
},
|
||||
projectId
|
||||
);
|
||||
Log.withTick(`Created EAS environment variable ${chalk.bold(envVar.name)} for builds`);
|
||||
}
|
||||
|
||||
private printNextSteps(
|
||||
project: PostHogProjectData,
|
||||
features: Features,
|
||||
manualSteps: string[]
|
||||
): void {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(chalk.green('PostHog is connected!'));
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`${chalk.bold('Dashboard')}: ${link(getPostHogProjectDashboardUrl(project), { dim: false })}`
|
||||
);
|
||||
Log.newLine();
|
||||
Log.log('Next steps:');
|
||||
const steps: string[] = [
|
||||
`Wrap your app in ${chalk.cyan('<PostHogProvider>')} following our guide (${chalk.cyan('https://docs.expo.dev/guides/using-posthog')}).`,
|
||||
];
|
||||
if (features.sessionReplay) {
|
||||
steps.push(
|
||||
`Enable session replay in the provider options (the ${chalk.bold(SESSION_REPLAY_PACKAGE)} package is installed).`
|
||||
);
|
||||
}
|
||||
if (features.errorTracking) {
|
||||
steps.push(
|
||||
`Error-tracking source maps: your ${chalk.bold('POSTHOG_CLI_*')} env vars are set. Wrap ${chalk.bold('metro.config.js')} with ${chalk.cyan('getPostHogExpoConfig')} (see ${chalk.cyan('https://docs.expo.dev/guides/using-posthog#source-maps')}); uploads then run automatically on EAS Build, and via ${chalk.cyan('posthog-cli hermes upload')} for EAS Update.`
|
||||
);
|
||||
}
|
||||
steps.forEach((step, index) => {
|
||||
Log.log(` ${index + 1}. ${step}`);
|
||||
});
|
||||
|
||||
if (manualSteps.length > 0) {
|
||||
Log.newLine();
|
||||
Log.warn('Finish setup manually:');
|
||||
manualSteps.forEach(step => {
|
||||
Log.warn(` • ${step}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import openBrowserAsync from 'better-opn';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import { getPostHogProjectDashboardUrl, logNoPostHogProject } from '../../../commandUtils/posthog';
|
||||
import { PostHogQuery } from '../../../graphql/queries/PostHogQuery';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class IntegrationsPostHogDashboard extends EasCommand {
|
||||
static override description = 'open the PostHog dashboard for the linked PostHog project';
|
||||
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsPostHogDashboard);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsPostHogDashboard, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const posthogProject = await PostHogQuery.getPostHogProjectByAppIdAsync(
|
||||
graphqlClient,
|
||||
projectId
|
||||
);
|
||||
|
||||
if (!posthogProject) {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ dashboardUrl: null });
|
||||
} else {
|
||||
logNoPostHogProject(exp.slug);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboardUrl = getPostHogProjectDashboardUrl(posthogProject);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ dashboardUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
if (nonInteractive) {
|
||||
Log.log(dashboardUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const failedMessage = `Unable to open a web browser. PostHog dashboard is available at: ${dashboardUrl}`;
|
||||
const spinner = ora(`Opening ${dashboardUrl}`).start();
|
||||
try {
|
||||
const opened = await openBrowserAsync(dashboardUrl);
|
||||
if (opened) {
|
||||
spinner.succeed(`Opened ${dashboardUrl}`);
|
||||
} else {
|
||||
spinner.fail(failedMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
spinner.fail(failedMessage);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import { formatPostHogProject, logNoPostHogProject } from '../../../commandUtils/posthog';
|
||||
import { PostHogMutation } from '../../../graphql/mutations/PostHogMutation';
|
||||
import { PostHogQuery } from '../../../graphql/queries/PostHogQuery';
|
||||
import Log from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
export default class IntegrationsPostHogDisconnect extends EasCommand {
|
||||
static override description =
|
||||
'remove the PostHog project link for the current Expo app from EAS servers';
|
||||
|
||||
static override flags = {
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
yes: Flags.boolean({
|
||||
char: 'y',
|
||||
description: 'Skip confirmation prompt',
|
||||
default: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsPostHogDisconnect);
|
||||
const { yes } = flags;
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsPostHogDisconnect, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const posthogProject = await PostHogQuery.getPostHogProjectByAppIdAsync(
|
||||
graphqlClient,
|
||||
projectId
|
||||
);
|
||||
|
||||
if (!posthogProject) {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ id: null });
|
||||
} else {
|
||||
logNoPostHogProject(exp.slug);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jsonFlag) {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(formatPostHogProject(posthogProject));
|
||||
Log.newLine();
|
||||
}
|
||||
|
||||
if (!nonInteractive && !yes) {
|
||||
const confirmed = await confirmAsync({
|
||||
message:
|
||||
'Remove this PostHog project link from EAS servers? This does not delete the project on PostHog.',
|
||||
});
|
||||
if (!confirmed) {
|
||||
Log.warn('Canceled removal of the PostHog project link.');
|
||||
return;
|
||||
}
|
||||
} else if (!jsonFlag) {
|
||||
Log.warn(
|
||||
'Removing the PostHog project link from EAS servers. This does not delete the project on PostHog.'
|
||||
);
|
||||
}
|
||||
|
||||
const spinner = jsonFlag ? null : ora('Removing PostHog project link').start();
|
||||
try {
|
||||
await PostHogMutation.deletePostHogProjectAsync(graphqlClient, posthogProject.id);
|
||||
spinner?.succeed(
|
||||
`Removed PostHog project ${chalk.bold(posthogProject.posthogProjectName)} from EAS servers`
|
||||
);
|
||||
} catch (error) {
|
||||
spinner?.fail('Failed to remove PostHog project link');
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ id: posthogProject.id, name: posthogProject.posthogProjectName });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import { fetchObserveSessionEventsAsync } from '../../../observe/fetchSessions';
|
||||
import {
|
||||
buildObserveSessionEventsJson,
|
||||
buildObserveSessionEventsTable,
|
||||
} from '../../../observe/formatSessions';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import ObserveSession from '../session';
|
||||
|
||||
jest.mock('../../../observe/fetchSessions');
|
||||
jest.mock('../../../observe/formatSessions', () => ({
|
||||
buildObserveSessionEventsTable: jest.fn().mockReturnValue('events-table'),
|
||||
buildObserveSessionEventsJson: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockFetchObserveSessionEventsAsync = jest.mocked(fetchObserveSessionEventsAsync);
|
||||
const mockBuildObserveSessionEventsTable = jest.mocked(buildObserveSessionEventsTable);
|
||||
const mockBuildObserveSessionEventsJson = jest.mocked(buildObserveSessionEventsJson);
|
||||
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
|
||||
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
|
||||
|
||||
describe(ObserveSession, () => {
|
||||
const graphqlClient = {} as any as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const projectId = 'test-project-id';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveSessionEventsAsync.mockResolvedValue({
|
||||
entries: [],
|
||||
metadata: null,
|
||||
hasMoreMetricEvents: false,
|
||||
hasMoreLogEvents: false,
|
||||
});
|
||||
});
|
||||
|
||||
function createCommand(argv: string[]): ObserveSession {
|
||||
const command = new ObserveSession(argv, mockConfig);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId,
|
||||
loggedIn: { graphqlClient },
|
||||
});
|
||||
return command;
|
||||
}
|
||||
|
||||
it('requires a session ID argument', async () => {
|
||||
const command = createCommand([]);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockFetchObserveSessionEventsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the session ID argument to the fetcher', async () => {
|
||||
const command = createCommand(['session-abc']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveSessionEventsAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchObserveSessionEventsAsync.mock.calls[0][2].sessionId).toBe('session-abc');
|
||||
});
|
||||
|
||||
it('hardcodes the per-source page size at 100', async () => {
|
||||
const command = createCommand(['session-abc']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockFetchObserveSessionEventsAsync.mock.calls[0][2].limit).toBe(100);
|
||||
});
|
||||
|
||||
it('renders the timeline table by default', async () => {
|
||||
const command = createCommand(['session-abc']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockBuildObserveSessionEventsTable).toHaveBeenCalled();
|
||||
expect(mockBuildObserveSessionEventsJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits JSON output when --json is set', async () => {
|
||||
const command = createCommand(['session-abc', '--json', '--non-interactive']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockEnableJsonOutput).toHaveBeenCalled();
|
||||
expect(mockBuildObserveSessionEventsJson).toHaveBeenCalled();
|
||||
expect(mockBuildObserveSessionEventsTable).not.toHaveBeenCalled();
|
||||
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unsupported filter flags like --platform', async () => {
|
||||
const command = createCommand(['session-abc', '--platform', 'ios']);
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import { Args } from '@oclif/core';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasNonInteractiveAndJsonFlags } from '../../commandUtils/flags';
|
||||
import Log from '../../log';
|
||||
import { fetchObserveSessionEventsAsync } from '../../observe/fetchSessions';
|
||||
import { ObserveProjectIdFlag } from '../../observe/flags';
|
||||
import {
|
||||
buildObserveSessionEventsJson,
|
||||
buildObserveSessionEventsTable,
|
||||
} from '../../observe/formatSessions';
|
||||
import { resolveObserveCommandContextAsync } from '../../observe/resolveProjectContext';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
// Fixed at 100 — the maximum page size accepted by the underlying events and
|
||||
// customEventList queries. Until there's a dedicated sessions query, this
|
||||
// command pulls one page of each and merges client-side.
|
||||
const SESSION_PAGE_SIZE = 100;
|
||||
|
||||
export default class ObserveSession extends EasCommand {
|
||||
static override description =
|
||||
'display the timeline of metric and log events for a specific session';
|
||||
|
||||
static override args = {
|
||||
sessionId: Args.string({
|
||||
description: 'Session ID to inspect',
|
||||
required: true,
|
||||
}),
|
||||
};
|
||||
|
||||
static override flags = {
|
||||
...ObserveProjectIdFlag,
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectId,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
private static loggedInOnlyContextDefinition = {
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags, args } = await this.parse(ObserveSession);
|
||||
|
||||
const { projectId, graphqlClient } = await resolveObserveCommandContextAsync({
|
||||
command: this,
|
||||
commandClass: ObserveSession,
|
||||
loggedInOnlyContextDefinition: ObserveSession.loggedInOnlyContextDefinition,
|
||||
projectIdOverride: flags['project-id'],
|
||||
nonInteractive: flags['non-interactive'],
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const { entries, metadata, hasMoreMetricEvents, hasMoreLogEvents } =
|
||||
await fetchObserveSessionEventsAsync(graphqlClient, projectId, {
|
||||
sessionId: args.sessionId,
|
||||
limit: SESSION_PAGE_SIZE,
|
||||
});
|
||||
|
||||
if (flags.json) {
|
||||
printJsonOnlyOutput(
|
||||
buildObserveSessionEventsJson(
|
||||
entries,
|
||||
args.sessionId,
|
||||
metadata,
|
||||
hasMoreMetricEvents,
|
||||
hasMoreLogEvents
|
||||
)
|
||||
);
|
||||
} else {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
buildObserveSessionEventsTable(entries, {
|
||||
metadata,
|
||||
hasMoreMetricEvents,
|
||||
hasMoreLogEvents,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,11 +62,6 @@ export default class SimulatorStart extends EasCommand {
|
||||
description:
|
||||
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
|
||||
}),
|
||||
'max-duration-minutes': Flags.integer({
|
||||
description:
|
||||
'Maximum duration of the device run session in minutes before it is automatically stopped. Only customizable on paid plans. Defaults to a value derived from the job run priority when omitted.',
|
||||
min: 0,
|
||||
}),
|
||||
force: Flags.boolean({
|
||||
description:
|
||||
'[default: true] Create a new device session even when an existing simulator session is present in the environment.',
|
||||
@@ -130,7 +125,6 @@ export default class SimulatorStart extends EasCommand {
|
||||
platform,
|
||||
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
|
||||
packageVersion: flags['package-version'],
|
||||
maxRunTimeMinutes: flags['max-duration-minutes'],
|
||||
});
|
||||
deviceRunSessionId = session.id;
|
||||
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
import { AppJSONConfig, ExpoConfig, PackageJSONConfig, getConfig } from '@expo/config';
|
||||
import { DirectoryJSON, vol } from 'memfs';
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
|
||||
import LoggedInContextField from '../../../commandUtils/context/LoggedInContextField';
|
||||
import { PrivateProjectConfigContextField } from '../../../commandUtils/context/PrivateProjectConfigContextField';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import FeatureGateEnvOverrides from '../../../commandUtils/gating/FeatureGateEnvOverrides';
|
||||
import FeatureGating from '../../../commandUtils/gating/FeatureGating';
|
||||
import { jester } from '../../../credentials/__tests__/fixtures-constants';
|
||||
import { UpdateFragment } from '../../../graphql/generated';
|
||||
import { AppQuery } from '../../../graphql/queries/AppQuery';
|
||||
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
|
||||
import UpdateRepublish from '../republish';
|
||||
import UpdateRollBackToEmbedded from '../roll-back-to-embedded';
|
||||
import UpdateRollback from '../rollback';
|
||||
|
||||
const projectRoot = '/test-project';
|
||||
const commandOptions = getMockOclifConfig({ root: projectRoot });
|
||||
|
||||
const updateStub: UpdateFragment = {
|
||||
id: 'update-1234',
|
||||
group: 'group-source',
|
||||
branch: { id: 'branch-1234', name: 'main' },
|
||||
message: 'source message',
|
||||
runtimeVersion: 'exposdk:47.0.0',
|
||||
platform: 'ios',
|
||||
gitCommitHash: 'commit',
|
||||
isGitWorkingTreeDirty: false,
|
||||
manifestFragment: JSON.stringify({ fake: 'manifest' }),
|
||||
isRollBackToEmbedded: false,
|
||||
manifestPermalink: 'https://expo.dev/fake/manifest/link',
|
||||
codeSigningInfo: null,
|
||||
createdAt: '2022-01-01T12:00:00Z',
|
||||
};
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/config');
|
||||
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
|
||||
jest.mock('../../../graphql/queries/AppQuery');
|
||||
jest.mock('../../../graphql/queries/UpdateQuery');
|
||||
|
||||
describe(UpdateRollback.name, () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(UpdateRepublish, 'run').mockResolvedValue(undefined as any);
|
||||
jest.spyOn(UpdateRollBackToEmbedded, 'run').mockResolvedValue(undefined as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vol.reset();
|
||||
jest.restoreAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('errors in non-interactive mode when no group ID is provided', async () => {
|
||||
const flags = ['--non-interactive'];
|
||||
mockTestProject();
|
||||
|
||||
await expect(new UpdateRollback(flags, commandOptions).run()).rejects.toThrow(
|
||||
'The update group ID argument is required in non-interactive mode.'
|
||||
);
|
||||
|
||||
expect(UpdateRepublish.run).not.toHaveBeenCalled();
|
||||
expect(UpdateRollBackToEmbedded.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('republishes the previous update group when the source group is the latest', async () => {
|
||||
const flags = ['group-source', '--non-interactive'];
|
||||
mockTestProject();
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
|
||||
|
||||
// Most-recent-first: the source group is the latest, the previous group follows.
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
|
||||
.mockResolvedValue([
|
||||
[{ ...updateStub, group: 'group-source', message: 'source message' }],
|
||||
[{ ...updateStub, group: 'group-previous', message: 'previous message' }],
|
||||
]);
|
||||
|
||||
await new UpdateRollback(flags, commandOptions).run();
|
||||
|
||||
expect(UpdateRollBackToEmbedded.run).not.toHaveBeenCalled();
|
||||
expect(UpdateRepublish.run).toHaveBeenCalledWith([
|
||||
'--group',
|
||||
'group-previous',
|
||||
'--message',
|
||||
'Roll back to "previous message" (group: group-previous)',
|
||||
'--non-interactive',
|
||||
'--platform',
|
||||
'all',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rolls back to embedded when the source group is the only update for its runtime version', async () => {
|
||||
const flags = ['group-source', '--non-interactive'];
|
||||
mockTestProject();
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
|
||||
|
||||
// Only the source group exists for this runtime version -> no previous group.
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
|
||||
.mockResolvedValue([[{ ...updateStub, group: 'group-source', message: 'source message' }]]);
|
||||
|
||||
await new UpdateRollback(flags, commandOptions).run();
|
||||
|
||||
expect(UpdateRepublish.run).not.toHaveBeenCalled();
|
||||
expect(UpdateRollBackToEmbedded.run).toHaveBeenCalledWith([
|
||||
'--branch',
|
||||
'main',
|
||||
'--runtime-version',
|
||||
'exposdk:47.0.0',
|
||||
'--message',
|
||||
'Roll back to embedded',
|
||||
'--non-interactive',
|
||||
'--platform',
|
||||
'all',
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards --message, --platform, and --private-key-path to the republish path', async () => {
|
||||
const flags = [
|
||||
'group-source',
|
||||
'--non-interactive',
|
||||
'--message',
|
||||
'custom rollback message',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--private-key-path',
|
||||
'./keys/private-key.pem',
|
||||
];
|
||||
mockTestProject();
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
|
||||
.mockResolvedValue([
|
||||
[{ ...updateStub, group: 'group-source', message: 'source message' }],
|
||||
[{ ...updateStub, group: 'group-previous', message: 'previous message' }],
|
||||
]);
|
||||
|
||||
await new UpdateRollback(flags, commandOptions).run();
|
||||
|
||||
expect(UpdateRepublish.run).toHaveBeenCalledWith([
|
||||
'--group',
|
||||
'group-previous',
|
||||
'--message',
|
||||
'custom rollback message',
|
||||
'--non-interactive',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--private-key-path',
|
||||
'./keys/private-key.pem',
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards --json to the republish path', async () => {
|
||||
const flags = ['group-source', '--json', '--non-interactive'];
|
||||
mockTestProject();
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
|
||||
.mockResolvedValue([
|
||||
[{ ...updateStub, group: 'group-source', message: 'source message' }],
|
||||
[{ ...updateStub, group: 'group-previous', message: 'previous message' }],
|
||||
]);
|
||||
|
||||
await new UpdateRollback(flags, commandOptions).run();
|
||||
|
||||
expect(UpdateRepublish.run).toHaveBeenCalledWith(expect.arrayContaining(['--json']));
|
||||
});
|
||||
|
||||
it('forwards --platform and --private-key-path to the embedded rollback path', async () => {
|
||||
const flags = [
|
||||
'group-source',
|
||||
'--non-interactive',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--private-key-path',
|
||||
'./keys/private-key.pem',
|
||||
];
|
||||
mockTestProject();
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
|
||||
|
||||
// No previous group -> roll back to embedded.
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
|
||||
.mockResolvedValue([[{ ...updateStub, group: 'group-source', message: 'source message' }]]);
|
||||
|
||||
await new UpdateRollback(flags, commandOptions).run();
|
||||
|
||||
expect(UpdateRepublish.run).not.toHaveBeenCalled();
|
||||
expect(UpdateRollBackToEmbedded.run).toHaveBeenCalledWith([
|
||||
'--branch',
|
||||
'main',
|
||||
'--runtime-version',
|
||||
'exposdk:47.0.0',
|
||||
'--message',
|
||||
'Roll back to embedded',
|
||||
'--non-interactive',
|
||||
'--platform',
|
||||
'ios',
|
||||
'--private-key-path',
|
||||
'./keys/private-key.pem',
|
||||
]);
|
||||
});
|
||||
|
||||
it('errors when the source group is not the latest update for its runtime version', async () => {
|
||||
const flags = ['group-source', '--non-interactive'];
|
||||
mockTestProject();
|
||||
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupAsync)
|
||||
.mockResolvedValue([{ ...updateStub, group: 'group-source' }]);
|
||||
|
||||
// A newer group is the latest for this runtime version, so the source group is stale.
|
||||
jest
|
||||
.mocked(UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync)
|
||||
.mockResolvedValue([
|
||||
[{ ...updateStub, group: 'group-newer', message: 'newer message' }],
|
||||
[{ ...updateStub, group: 'group-source', message: 'source message' }],
|
||||
]);
|
||||
|
||||
await expect(new UpdateRollback(flags, commandOptions).run()).rejects.toThrow(
|
||||
'Update group "group-source" is not the latest update on branch "main" for runtime version "exposdk:47.0.0" (the latest is "group-newer"). Only the latest update can be rolled back.'
|
||||
);
|
||||
|
||||
expect(UpdateRepublish.run).not.toHaveBeenCalled();
|
||||
expect(UpdateRollBackToEmbedded.run).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function mockTestProject({
|
||||
configuredProjectId = '1234',
|
||||
extraManifest,
|
||||
extraVol,
|
||||
}: {
|
||||
configuredProjectId?: string;
|
||||
extraManifest?: Partial<ExpoConfig>;
|
||||
extraVol?: DirectoryJSON;
|
||||
} = {}): void {
|
||||
const packageJSON: PackageJSONConfig = {
|
||||
name: 'testing123',
|
||||
version: '0.1.0',
|
||||
description: 'fake description',
|
||||
main: 'index.js',
|
||||
};
|
||||
|
||||
const appJSON: AppJSONConfig = {
|
||||
expo: {
|
||||
name: 'testing 123',
|
||||
version: '0.1.0',
|
||||
slug: 'testing-123',
|
||||
sdkVersion: '33.0.0',
|
||||
owner: jester.accounts[0].name,
|
||||
extra: {
|
||||
eas: {
|
||||
projectId: configuredProjectId,
|
||||
},
|
||||
},
|
||||
...extraManifest,
|
||||
},
|
||||
};
|
||||
|
||||
vol.fromJSON(
|
||||
{
|
||||
'package.json': JSON.stringify(packageJSON),
|
||||
'app.json': JSON.stringify(appJSON),
|
||||
...extraVol,
|
||||
},
|
||||
projectRoot
|
||||
);
|
||||
|
||||
const mockManifest = { exp: appJSON.expo };
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>({}));
|
||||
|
||||
jest.mocked(getConfig).mockReturnValue(mockManifest as any);
|
||||
jest.spyOn(PrivateProjectConfigContextField.prototype, 'getValueAsync').mockResolvedValue({
|
||||
exp: mockManifest.exp,
|
||||
projectDir: projectRoot,
|
||||
projectId: configuredProjectId,
|
||||
});
|
||||
|
||||
jest.spyOn(LoggedInContextField.prototype, 'getValueAsync').mockResolvedValue({
|
||||
actor: jester,
|
||||
featureGating: new FeatureGating({}, new FeatureGateEnvOverrides()),
|
||||
graphqlClient,
|
||||
authenticationInfo: { accessToken: null, sessionSecret: '1234' },
|
||||
});
|
||||
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
|
||||
id: '1234',
|
||||
slug: 'testing-123',
|
||||
name: 'testing-123',
|
||||
fullName: '@jester/testing-123',
|
||||
ownerAccount: jester.accounts[0],
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
import UpdateView from '../view';
|
||||
|
||||
jest.mock('../../../graphql/queries/UpdateQuery', () => ({
|
||||
UpdateQuery: { viewUpdateGroupAsync: jest.fn(), viewByUpdateAsync: jest.fn() },
|
||||
UpdateQuery: { viewUpdateGroupAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/UpdateInsightsQuery', () => ({
|
||||
UpdateInsightsQuery: { viewUpdateGroupInsightsAsync: jest.fn() },
|
||||
@@ -16,7 +16,6 @@ jest.mock('../../../log');
|
||||
jest.mock('../../../utils/json');
|
||||
|
||||
const mockViewUpdateGroupAsync = jest.mocked(UpdateQuery.viewUpdateGroupAsync);
|
||||
const mockViewByUpdateAsync = jest.mocked(UpdateQuery.viewByUpdateAsync);
|
||||
const mockViewUpdateGroupInsightsAsync = jest.mocked(
|
||||
UpdateInsightsQuery.viewUpdateGroupInsightsAsync
|
||||
);
|
||||
@@ -156,42 +155,4 @@ describe(UpdateView, () => {
|
||||
expect(arg.insights).toBeDefined();
|
||||
expect(arg.insights.platforms[0].totals.installs).toBe(990);
|
||||
});
|
||||
|
||||
it('does not look up a single update when the ID resolves as a group', async () => {
|
||||
const command = createCommand(['group-1']);
|
||||
await command.runAsync();
|
||||
expect(mockViewUpdateGroupAsync).toHaveBeenCalledWith(graphqlClient, { groupId: 'group-1' });
|
||||
expect(mockViewByUpdateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves a platform-specific update ID to its group', async () => {
|
||||
mockViewUpdateGroupAsync.mockReset();
|
||||
mockViewUpdateGroupAsync
|
||||
.mockRejectedValueOnce(new Error('Could not find any updates with group ID: "u1"'))
|
||||
.mockResolvedValueOnce(updateGroup);
|
||||
mockViewByUpdateAsync.mockResolvedValue({ ...updateGroup[0], group: 'group-1' });
|
||||
|
||||
const command = createCommand(['u1', '--insights']);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockViewByUpdateAsync).toHaveBeenCalledWith(graphqlClient, { updateId: 'u1' });
|
||||
expect(mockViewUpdateGroupAsync).toHaveBeenLastCalledWith(graphqlClient, {
|
||||
groupId: 'group-1',
|
||||
});
|
||||
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ groupId: 'group-1' })
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces the original group lookup error when the ID matches neither a group nor an update', async () => {
|
||||
mockViewUpdateGroupAsync.mockReset();
|
||||
mockViewUpdateGroupAsync.mockRejectedValue(
|
||||
new Error('Could not find any updates with group ID: "bogus"')
|
||||
);
|
||||
mockViewByUpdateAsync.mockRejectedValue(new Error('update not found'));
|
||||
|
||||
const command = createCommand(['bogus']);
|
||||
await expect(command.runAsync()).rejects.toThrow(/Could not find any updates with group ID/);
|
||||
});
|
||||
});
|
||||
@@ -1,188 +1,41 @@
|
||||
import { Args, Flags } from '@oclif/core';
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import UpdateRepublish from './republish';
|
||||
import UpdateRollBackToEmbedded from './roll-back-to-embedded';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../commandUtils/flags';
|
||||
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
|
||||
import { promptAsync } from '../../prompts';
|
||||
|
||||
const defaultRollbackPlatforms = ['android', 'ios'] as const;
|
||||
|
||||
type SourceUpdateGroup = {
|
||||
groupId: string;
|
||||
branchName: string;
|
||||
runtimeVersion: string;
|
||||
};
|
||||
|
||||
type PreviousUpdateGroup = {
|
||||
groupId: string;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
export default class UpdateRollback extends EasCommand {
|
||||
static override description = 'roll back to an embedded update or an existing update';
|
||||
|
||||
static override args = {
|
||||
groupId: Args.string({
|
||||
description:
|
||||
'The ID of the update group to roll back. Must be the latest update for its branch and runtime version. The update group published before it is republished; if there is none, a roll back to the embedded update is published. Required in non-interactive mode.',
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
static override description =
|
||||
'Roll back to an embedded update or an existing update. Users wishing to run this command non-interactively should instead execute "eas update:republish" or "eas update:roll-back-to-embedded".';
|
||||
|
||||
static override flags = {
|
||||
message: Flags.string({
|
||||
char: 'm',
|
||||
description: 'Short message describing the rollback update',
|
||||
required: false,
|
||||
}),
|
||||
platform: Flags.option({
|
||||
char: 'p',
|
||||
options: [...defaultRollbackPlatforms, 'all'] as const,
|
||||
default: 'all',
|
||||
required: false,
|
||||
})(),
|
||||
'private-key-path': Flags.string({
|
||||
description: `File containing the PEM-encoded private key corresponding to the certificate in expo-updates' configuration. Defaults to a file named "private-key.pem" in the certificate's directory. Only relevant if you are using code signing: https://docs.expo.dev/eas-update/code-signing/`,
|
||||
required: false,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
...this.ContextOptions.LoggedIn,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { args, flags } = await this.parse(UpdateRollback);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
const groupId = args.groupId;
|
||||
const platform = flags.platform;
|
||||
const messageArg = flags.message;
|
||||
const { flags } = await this.parse(UpdateRollback);
|
||||
|
||||
const { choice } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Which type of update would you like to roll back to?',
|
||||
name: 'choice',
|
||||
choices: [
|
||||
{ title: 'Published Update', value: 'published' },
|
||||
{ title: 'Embedded Update', value: 'embedded' },
|
||||
],
|
||||
});
|
||||
|
||||
const privateKeyPathArg = flags['private-key-path']
|
||||
? ['--private-key-path', flags['private-key-path']]
|
||||
: [];
|
||||
|
||||
if (!groupId) {
|
||||
if (nonInteractive) {
|
||||
throw new Error('The update group ID argument is required in non-interactive mode.');
|
||||
}
|
||||
|
||||
const { choice } = await promptAsync({
|
||||
type: 'select',
|
||||
message: 'Which type of update would you like to roll back to?',
|
||||
name: 'choice',
|
||||
choices: [
|
||||
{ title: 'Published Update', value: 'published' },
|
||||
{ title: 'Embedded Update', value: 'embedded' },
|
||||
],
|
||||
});
|
||||
|
||||
if (choice === 'published') {
|
||||
await UpdateRepublish.run(privateKeyPathArg);
|
||||
} else {
|
||||
await UpdateRollBackToEmbedded.run(privateKeyPathArg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
loggedIn: { graphqlClient },
|
||||
privateProjectConfig: { projectId },
|
||||
} = await this.getContextAsync(UpdateRollback, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const sourceGroup = await getSourceUpdateGroupAsync(graphqlClient, groupId);
|
||||
const previousGroup = await getPreviousUpdateGroupAsync(graphqlClient, projectId, sourceGroup);
|
||||
|
||||
const commonArgs = [
|
||||
'--non-interactive',
|
||||
'--platform',
|
||||
platform,
|
||||
...privateKeyPathArg,
|
||||
...(json ? ['--json'] : []),
|
||||
];
|
||||
|
||||
if (previousGroup) {
|
||||
const message =
|
||||
messageArg ??
|
||||
`Roll back to "${previousGroup.message ?? ''}" (group: ${previousGroup.groupId})`;
|
||||
await UpdateRepublish.run([
|
||||
'--group',
|
||||
previousGroup.groupId,
|
||||
'--message',
|
||||
message,
|
||||
...commonArgs,
|
||||
]);
|
||||
if (choice === 'published') {
|
||||
await UpdateRepublish.run(privateKeyPathArg);
|
||||
} else {
|
||||
const message = messageArg ?? 'Roll back to embedded';
|
||||
await UpdateRollBackToEmbedded.run([
|
||||
'--branch',
|
||||
sourceGroup.branchName,
|
||||
'--runtime-version',
|
||||
sourceGroup.runtimeVersion,
|
||||
'--message',
|
||||
message,
|
||||
...commonArgs,
|
||||
]);
|
||||
await UpdateRollBackToEmbedded.run(privateKeyPathArg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getSourceUpdateGroupAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
groupId: string
|
||||
): Promise<SourceUpdateGroup> {
|
||||
// viewUpdateGroupAsync throws if no updates are found for the group ID.
|
||||
const updateGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, { groupId });
|
||||
const arbitraryUpdate = updateGroup[0];
|
||||
return {
|
||||
groupId,
|
||||
branchName: arbitraryUpdate.branch.name,
|
||||
runtimeVersion: arbitraryUpdate.runtimeVersion,
|
||||
};
|
||||
}
|
||||
|
||||
async function getPreviousUpdateGroupAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
sourceGroup: SourceUpdateGroup
|
||||
): Promise<PreviousUpdateGroup | null> {
|
||||
// Clients on a given runtime version are served the latest update group on the branch,
|
||||
// so a rollback is only meaningful when the source group is that latest group. Fetch the
|
||||
// two most recent groups for the runtime version (returned most-recent-first): the first
|
||||
// must be the source group, and the second (if any) is the update to roll back to.
|
||||
const latestGroups = await UpdateQuery.viewUpdateGroupsPaginatedOnBranchAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
branchName: sourceGroup.branchName,
|
||||
first: 2,
|
||||
filter: { runtimeVersions: [sourceGroup.runtimeVersion] },
|
||||
});
|
||||
|
||||
const latestGroup = latestGroups[0];
|
||||
if (!latestGroup?.length || latestGroup[0].group !== sourceGroup.groupId) {
|
||||
throw new Error(
|
||||
`Update group "${sourceGroup.groupId}" is not the latest update on branch "${
|
||||
sourceGroup.branchName
|
||||
}" for runtime version "${sourceGroup.runtimeVersion}"${
|
||||
latestGroup?.length ? ` (the latest is "${latestGroup[0].group}")` : ''
|
||||
}. Only the latest update can be rolled back.`
|
||||
);
|
||||
}
|
||||
|
||||
// Source group is the only update for this runtime version: roll back to embedded.
|
||||
const previousGroup = latestGroups[1];
|
||||
if (!previousGroup?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { groupId: previousGroup[0].group, message: previousGroup[0].message ?? null };
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { UpdateFragment } from '../../graphql/generated';
|
||||
import { UpdateInsightsQuery } from '../../graphql/queries/UpdateInsightsQuery';
|
||||
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
|
||||
import { resolveInsightsTimeRange } from '../../insights/timeRange';
|
||||
@@ -28,7 +26,7 @@ export default class UpdateView extends EasCommand {
|
||||
static override args = {
|
||||
groupId: Args.string({
|
||||
required: true,
|
||||
description: 'The ID of an update group, or the ID of a platform-specific update.',
|
||||
description: 'The ID of an update group.',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -60,7 +58,7 @@ export default class UpdateView extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { groupId: idArg },
|
||||
args: { groupId },
|
||||
flags: { json: jsonFlag, insights: insightsFlag, days, start, end },
|
||||
} = await this.parse(UpdateView);
|
||||
|
||||
@@ -76,10 +74,7 @@ export default class UpdateView extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const { groupId, updatesByGroup } = await UpdateView.resolveUpdateGroupAsync(
|
||||
graphqlClient,
|
||||
idArg
|
||||
);
|
||||
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, { groupId });
|
||||
|
||||
let insightsSummary: UpdateInsightsSummary | null = null;
|
||||
if (insightsFlag) {
|
||||
@@ -117,34 +112,4 @@ export default class UpdateView extends EasCommand {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the provided ID into an update group and its updates. The ID may be either an
|
||||
* update group ID or the ID of a single platform-specific update. We first try to look it up
|
||||
* as a group; if no updates are found, we fall back to resolving it as a platform-specific
|
||||
* update and then fetch the group that update belongs to.
|
||||
*/
|
||||
private static async resolveUpdateGroupAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
id: string
|
||||
): Promise<{ groupId: string; updatesByGroup: UpdateFragment[] }> {
|
||||
try {
|
||||
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, { groupId: id });
|
||||
return { groupId: id, updatesByGroup };
|
||||
} catch (groupError) {
|
||||
let update: UpdateFragment | undefined;
|
||||
try {
|
||||
update = await UpdateQuery.viewByUpdateAsync(graphqlClient, { updateId: id });
|
||||
} catch {
|
||||
// The ID is neither a valid update group nor a valid platform-specific update; surface
|
||||
// the original group lookup error since the group ID is the primary input.
|
||||
throw groupError;
|
||||
}
|
||||
|
||||
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, {
|
||||
groupId: update.group,
|
||||
});
|
||||
return { groupId: update.group, updatesByGroup };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,7 @@ import path from 'path';
|
||||
import { UserRole } from '@expo/apple-utils';
|
||||
|
||||
import { formatAppleTeam } from './AppleTeamFormatting';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
|
||||
import { fromNow } from '../../../utils/date';
|
||||
@@ -16,11 +14,7 @@ import {
|
||||
getCredentialsFromUserAsync,
|
||||
shouldAutoGenerateCredentialsAsync,
|
||||
} from '../../utils/promptForCredentials';
|
||||
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
|
||||
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
|
||||
import { AscApiKey } from '../appstore/Credentials.types';
|
||||
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { hasAscEnvVars } from '../appstore/resolveCredentials';
|
||||
import {
|
||||
AscApiKeyPath,
|
||||
MinimalAscApiKey,
|
||||
@@ -283,78 +277,3 @@ export function formatAscApiKey(ascApiKey: AppStoreConnectApiKeyFragment): strin
|
||||
line += chalk.gray(`\n Updated: ${fromNow(new Date(updatedAt))} ago`);
|
||||
return line;
|
||||
}
|
||||
|
||||
export async function resolveAscApiKeyForAppCredentialsAsync({
|
||||
graphqlClient,
|
||||
app,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
app: AppLookupParams;
|
||||
}): Promise<{
|
||||
ascApiKey: MinimalAscApiKey;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
} | null> {
|
||||
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
|
||||
if (!ascKeyFragment) {
|
||||
return null;
|
||||
}
|
||||
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
|
||||
return {
|
||||
ascApiKey: {
|
||||
keyP8: fullKey.keyP8,
|
||||
keyId: fullKey.keyIdentifier,
|
||||
issuerId: fullKey.issuerIdentifier,
|
||||
},
|
||||
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
|
||||
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort helper that populates `ctx.appStore.authCtx` in non-interactive mode
|
||||
* by loading an App Store Connect API key (from env vars, or by fetching the app's
|
||||
* stored key from the www GraphQL API).
|
||||
*
|
||||
* Returns true if `ctx.appStore.authCtx` is set after the call, false otherwise.
|
||||
* Never throws.
|
||||
*/
|
||||
export async function tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
ctx: CredentialsContext,
|
||||
app: AppLookupParams,
|
||||
teamType: AppleTeamType
|
||||
): Promise<boolean> {
|
||||
if (ctx.appStore.authCtx) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
if (hasAscEnvVars()) {
|
||||
await ctx.appStore.ensureAuthenticatedAsync({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
teamType,
|
||||
});
|
||||
return !!ctx.appStore.authCtx;
|
||||
}
|
||||
const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
app,
|
||||
});
|
||||
if (!resolvedKey) {
|
||||
return false;
|
||||
}
|
||||
Log.log('Using App Store Connect API Key from EAS credentials service.');
|
||||
await ctx.appStore.ensureAuthenticatedAsync({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: resolvedKey.ascApiKey,
|
||||
teamId: resolvedKey.teamId,
|
||||
teamName: resolvedKey.teamName,
|
||||
teamType,
|
||||
});
|
||||
return !!ctx.appStore.authCtx;
|
||||
} catch (err: any) {
|
||||
Log.warn(
|
||||
`Failed to authenticate with the App Store Connect API key from EAS credentials service: ${err.message ?? err}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation';
|
||||
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
|
||||
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
|
||||
import { AuthCtx } from '../appstore/authenticateTypes';
|
||||
import { AuthCtx, AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { Target } from '../types';
|
||||
|
||||
export class ConfigureProvisioningProfile {
|
||||
@@ -34,9 +34,12 @@ export class ConfigureProvisioningProfile {
|
||||
throw new ForbidCredentialModificationError(
|
||||
'Remove the --freeze-credentials flag to configure a Provisioning Profile.'
|
||||
);
|
||||
} else if (ctx.nonInteractive && !ctx.appStore.authCtx) {
|
||||
} else if (
|
||||
ctx.nonInteractive &&
|
||||
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
|
||||
) {
|
||||
throw new InsufficientAuthenticationNonInteractiveError(
|
||||
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for submissions for bundle identifier ${this.app.bundleIdentifier} on EAS. ${learnMore(
|
||||
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
|
||||
)}`
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
|
||||
import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation';
|
||||
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
|
||||
import { ProvisioningProfile } from '../appstore/Credentials.types';
|
||||
import { AuthCtx } from '../appstore/authenticateTypes';
|
||||
import { AuthCtx, AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { provisioningProfileSchema } from '../credentials';
|
||||
import { Target } from '../types';
|
||||
|
||||
@@ -30,9 +30,12 @@ export class CreateProvisioningProfile {
|
||||
throw new ForbidCredentialModificationError(
|
||||
'Run this command again without the --freeze-credentials flag in order to generate a new Provisioning Profile.'
|
||||
);
|
||||
} else if (ctx.nonInteractive && !ctx.appStore.authCtx) {
|
||||
} else if (
|
||||
ctx.nonInteractive &&
|
||||
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
|
||||
) {
|
||||
throw new InsufficientAuthenticationNonInteractiveError(
|
||||
`In order to generate a new Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for submissions for bundle identifier ${this.app.bundleIdentifier} on EAS. ${learnMore(
|
||||
`In order to generate a new Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
|
||||
)}`
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
filterDevicesForApplePlatform,
|
||||
formatDeviceLabel,
|
||||
} from './DeviceUtils';
|
||||
import { resolveAscApiKeyForAppCredentialsAsync } from './AscApiKeyUtils';
|
||||
import { SetUpDistributionCertificate } from './SetUpDistributionCertificate';
|
||||
import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action';
|
||||
import {
|
||||
@@ -23,6 +22,8 @@ import {
|
||||
IosAppBuildCredentialsFragment,
|
||||
IosDistributionType,
|
||||
} from '../../../graphql/generated';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import Log from '../../../log';
|
||||
import { getApplePlatformFromTarget } from '../../../project/ios/target';
|
||||
import {
|
||||
@@ -34,11 +35,13 @@ import {
|
||||
import differenceBy from '../../../utils/expodash/differenceBy';
|
||||
import { CredentialsContext } from '../../context';
|
||||
import { MissingCredentialsNonInteractiveError } from '../../errors';
|
||||
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
|
||||
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
|
||||
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { ProvisioningProfile } from '../appstore/Credentials.types';
|
||||
import { ApplePlatform } from '../appstore/constants';
|
||||
import { hasAscEnvVars } from '../appstore/resolveCredentials';
|
||||
import { MinimalAscApiKey } from '../credentials';
|
||||
import { Target } from '../types';
|
||||
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
|
||||
|
||||
@@ -421,7 +424,6 @@ export class SetUpAdhocProvisioningProfile {
|
||||
);
|
||||
}
|
||||
|
||||
Log.log('Using App Store Connect API Key from EAS credentials service.');
|
||||
await ctx.appStore.ensureAuthenticatedAsync({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: resolvedKey.ascApiKey,
|
||||
@@ -434,6 +436,35 @@ export class SetUpAdhocProvisioningProfile {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAscApiKeyForAppCredentialsAsync({
|
||||
graphqlClient,
|
||||
app,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
app: AppLookupParams;
|
||||
}): Promise<{
|
||||
ascApiKey: MinimalAscApiKey;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
} | null> {
|
||||
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
|
||||
if (!ascKeyFragment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Log.log('Using App Store Connect API Key from EAS credentials service.');
|
||||
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
|
||||
return {
|
||||
ascApiKey: {
|
||||
keyP8: fullKey.keyP8,
|
||||
keyId: fullKey.keyIdentifier,
|
||||
issuerId: fullKey.issuerIdentifier,
|
||||
},
|
||||
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
|
||||
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean {
|
||||
const setA = new Set(udidsA);
|
||||
const setB = new Set(udidsB);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from './AscApiKeyUtils';
|
||||
import {
|
||||
assignBuildCredentialsAsync,
|
||||
getBuildCredentialsAsync,
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
IosAppBuildCredentialsFragment,
|
||||
IosDistributionType,
|
||||
} from '../../../graphql/generated';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
import { learnMore } from '../../../log';
|
||||
import { getApplePlatformFromTarget } from '../../../project/ios/target';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import { CredentialsContext } from '../../context';
|
||||
@@ -26,8 +25,7 @@ import {
|
||||
} from '../../errors';
|
||||
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
|
||||
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
|
||||
import { resolveAppleTeamTypeFromEnvironment } from '../appstore/resolveCredentials';
|
||||
import { AppleTeamType } from '../appstore/authenticateTypes';
|
||||
import { AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { Target } from '../types';
|
||||
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
|
||||
|
||||
@@ -104,28 +102,8 @@ export class SetUpProvisioningProfile {
|
||||
this.app,
|
||||
this.distributionType
|
||||
).runAsync(ctx);
|
||||
if (ctx.nonInteractive && !ctx.appStore.authCtx) {
|
||||
await tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
ctx,
|
||||
this.app,
|
||||
this.resolveTeamTypeForAuthentication()
|
||||
);
|
||||
}
|
||||
|
||||
let areBuildCredentialsSetup: boolean;
|
||||
try {
|
||||
areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
|
||||
} catch (error: any) {
|
||||
if (ctx.nonInteractive) {
|
||||
Log.warn(
|
||||
'Skipping Provisioning Profile validation on Apple servers due to an unexpected validation error. Continuing with local validation result.'
|
||||
);
|
||||
Log.debug('Provisioning profile validation on Apple servers failed:', error);
|
||||
areBuildCredentialsSetup = true;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
|
||||
if (areBuildCredentialsSetup) {
|
||||
return nullthrows(await getBuildCredentialsAsync(ctx, this.app, this.distributionType));
|
||||
}
|
||||
@@ -133,11 +111,12 @@ export class SetUpProvisioningProfile {
|
||||
throw new ForbidCredentialModificationError(
|
||||
'Provisioning profile is not configured correctly. Remove the --freeze-credentials flag to configure it.'
|
||||
);
|
||||
}
|
||||
|
||||
if (ctx.nonInteractive && !ctx.appStore.authCtx) {
|
||||
} else if (
|
||||
ctx.nonInteractive &&
|
||||
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
|
||||
) {
|
||||
throw new InsufficientAuthenticationNonInteractiveError(
|
||||
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for submissions for bundle identifier ${this.app.bundleIdentifier} on EAS. ${learnMore(
|
||||
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
|
||||
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
|
||||
)}`
|
||||
);
|
||||
@@ -188,24 +167,6 @@ export class SetUpProvisioningProfile {
|
||||
return updatedProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* The team type determines `team.inHouse`, which in turn selects the Apple profile
|
||||
* type used for every subsequent profile lookup and creation (IOS_APP_INHOUSE for
|
||||
* enterprise vs IOS_APP_STORE otherwise). We derive it from the distribution
|
||||
* type, which is exactly what the requested operation needs: enterprise
|
||||
* builds require an in-house team, other distribution types don't.
|
||||
* A genuine team/distribution mismatch is rejected by Apple regardless of this value.
|
||||
*/
|
||||
private getDerivedTeamTypeForAuthentication(): AppleTeamType {
|
||||
return this.distributionType === IosDistributionType.Enterprise
|
||||
? AppleTeamType.IN_HOUSE
|
||||
: AppleTeamType.COMPANY_OR_ORGANIZATION;
|
||||
}
|
||||
|
||||
private resolveTeamTypeForAuthentication(): AppleTeamType {
|
||||
return resolveAppleTeamTypeFromEnvironment() ?? this.getDerivedTeamTypeForAuthentication();
|
||||
}
|
||||
|
||||
private getCurrentProfileStoreInfo(
|
||||
profiles: ProvisioningProfileStoreInfo[],
|
||||
currentProfile: AppleProvisioningProfileFragment
|
||||
|
||||
+1
-272
@@ -19,9 +19,8 @@ import {
|
||||
ForbidCredentialModificationError,
|
||||
InsufficientAuthenticationNonInteractiveError,
|
||||
} from '../../../errors';
|
||||
import { AppleTeamType, AuthenticationMode } from '../../appstore/authenticateTypes';
|
||||
import { AuthenticationMode } from '../../appstore/authenticateTypes';
|
||||
import { validateProvisioningProfileAsync } from '../../validators/validateProvisioningProfile';
|
||||
import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from '../AscApiKeyUtils';
|
||||
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
|
||||
import { SetUpProvisioningProfile } from '../SetUpProvisioningProfile';
|
||||
|
||||
@@ -31,15 +30,11 @@ jest.mock('../SetUpDistributionCertificate');
|
||||
jest.mock('../ConfigureProvisioningProfile');
|
||||
jest.mock('../CreateProvisioningProfile');
|
||||
jest.mock('../../validators/validateProvisioningProfile');
|
||||
jest.mock('../AscApiKeyUtils');
|
||||
jest.mock('../../../../graphql/queries/AppQuery');
|
||||
|
||||
describe('SetUpProvisioningProfile', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.EXPO_APPLE_TEAM_TYPE;
|
||||
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
|
||||
jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockReset();
|
||||
jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
const testCases = ['NON_INTERACTIVE', 'INTERACTIVE'];
|
||||
@@ -267,270 +262,4 @@ describe('SetUpProvisioningProfile', () => {
|
||||
// expect provisioning profile not to be deleted on expo servers
|
||||
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('auto-authenticates before validation in nonInteractive mode when credentials are already valid', async () => {
|
||||
let authAttemptedBeforeValidation = false;
|
||||
jest
|
||||
.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync)
|
||||
.mockImplementation(async authedCtx => {
|
||||
authAttemptedBeforeValidation = true;
|
||||
(authedCtx.appStore as any).authCtx = testAuthCtx;
|
||||
return true;
|
||||
});
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => {
|
||||
expect(authAttemptedBeforeValidation).toBe(true);
|
||||
return true;
|
||||
});
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: undefined,
|
||||
},
|
||||
ios: {
|
||||
...getNewIosApiMock(),
|
||||
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
|
||||
() => testCommonIosAppCredentialsFragment
|
||||
),
|
||||
},
|
||||
});
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.AppStore
|
||||
);
|
||||
await setupProvisioningProfileAction.runAsync(ctx);
|
||||
|
||||
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
|
||||
[ctx, appLookupParams, AppleTeamType.COMPANY_OR_ORGANIZATION],
|
||||
]);
|
||||
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
|
||||
0
|
||||
);
|
||||
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('uses best-effort validation when Apple validation fails in nonInteractive mode', async () => {
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => {
|
||||
throw new Error('Apple API request failed');
|
||||
});
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: testAuthCtx,
|
||||
},
|
||||
ios: {
|
||||
...getNewIosApiMock(),
|
||||
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
|
||||
() => testCommonIosAppCredentialsFragment
|
||||
),
|
||||
},
|
||||
});
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.AppStore
|
||||
);
|
||||
|
||||
await expect(setupProvisioningProfileAction.runAsync(ctx)).resolves.toEqual(
|
||||
testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0]
|
||||
);
|
||||
|
||||
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls.length).toBe(0);
|
||||
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
|
||||
0
|
||||
);
|
||||
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('rethrows Apple validation failures in interactive mode', async () => {
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => {
|
||||
throw new Error('Apple API request failed');
|
||||
});
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: testAuthCtx,
|
||||
},
|
||||
ios: {
|
||||
...getNewIosApiMock(),
|
||||
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
|
||||
() => testCommonIosAppCredentialsFragment
|
||||
),
|
||||
},
|
||||
});
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.AppStore
|
||||
);
|
||||
|
||||
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrow(
|
||||
'Apple API request failed'
|
||||
);
|
||||
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
|
||||
0
|
||||
);
|
||||
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('repairs the Provisioning Profile in nonInteractive mode after auto-authenticating with the EAS-stored ASC API key', async () => {
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
|
||||
const appstoreMock = getAppstoreMock();
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
appStore: {
|
||||
...appstoreMock,
|
||||
defaultAuthenticationMode: AuthenticationMode.USER,
|
||||
authCtx: undefined,
|
||||
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
|
||||
listProvisioningProfilesAsync: jest.fn(() => [
|
||||
{
|
||||
provisioningProfileId: nullthrows(
|
||||
testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0].provisioningProfile
|
||||
).developerPortalIdentifier,
|
||||
},
|
||||
]),
|
||||
},
|
||||
ios: {
|
||||
...getNewIosApiMock(),
|
||||
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
|
||||
() => testCommonIosAppCredentialsFragment
|
||||
),
|
||||
createOrGetExistingAppleAppIdentifierAsync: jest.fn(() => testAppleAppIdentifierFragment),
|
||||
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(
|
||||
() => testIosAppBuildCredentialsFragment
|
||||
),
|
||||
},
|
||||
});
|
||||
jest
|
||||
.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync)
|
||||
.mockImplementation(async authedCtx => {
|
||||
(authedCtx.appStore as any).authCtx = testAuthCtx;
|
||||
return true;
|
||||
});
|
||||
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.AppStore
|
||||
);
|
||||
await setupProvisioningProfileAction.runAsync(ctx);
|
||||
|
||||
// expect we attempted to auto-authenticate with the EAS-stored ASC API key
|
||||
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
|
||||
[ctx, appLookupParams, AppleTeamType.COMPANY_OR_ORGANIZATION],
|
||||
]);
|
||||
// expect build credentials to be created or updated on expo servers
|
||||
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
|
||||
1
|
||||
);
|
||||
// expect provisioning profile not to be deleted on expo servers (it still exists on Apple)
|
||||
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('passes IN_HOUSE team type for enterprise profiles when auto-authenticating in nonInteractive mode', async () => {
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
|
||||
const appstoreMock = getAppstoreMock();
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
appStore: {
|
||||
...appstoreMock,
|
||||
defaultAuthenticationMode: AuthenticationMode.USER,
|
||||
authCtx: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.Enterprise
|
||||
);
|
||||
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError(
|
||||
InsufficientAuthenticationNonInteractiveError
|
||||
);
|
||||
|
||||
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
|
||||
[ctx, appLookupParams, AppleTeamType.IN_HOUSE],
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses EXPO_APPLE_TEAM_TYPE over derived team type when auto-authenticating in nonInteractive mode', async () => {
|
||||
process.env.EXPO_APPLE_TEAM_TYPE = AppleTeamType.IN_HOUSE;
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
|
||||
const appstoreMock = getAppstoreMock();
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
appStore: {
|
||||
...appstoreMock,
|
||||
defaultAuthenticationMode: AuthenticationMode.USER,
|
||||
authCtx: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.AppStore
|
||||
);
|
||||
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError(
|
||||
InsufficientAuthenticationNonInteractiveError
|
||||
);
|
||||
|
||||
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
|
||||
[ctx, appLookupParams, AppleTeamType.IN_HOUSE],
|
||||
]);
|
||||
});
|
||||
|
||||
it('errors in nonInteractive mode when the profile needs regeneration but no Apple authentication can be established', async () => {
|
||||
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
|
||||
// tryAuthenticate fails to establish auth (default mock returns false and leaves authCtx unset).
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: true,
|
||||
});
|
||||
const appLookupParams = await getAppLookupParamsFromContextAsync(
|
||||
ctx,
|
||||
findApplicationTarget(testTargets)
|
||||
);
|
||||
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
|
||||
appLookupParams,
|
||||
testTarget,
|
||||
IosDistributionType.AppStore
|
||||
);
|
||||
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError(
|
||||
InsufficientAuthenticationNonInteractiveError
|
||||
);
|
||||
|
||||
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls.length).toBe(1);
|
||||
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
|
||||
0
|
||||
);
|
||||
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { promptAsync } from '../../../../prompts';
|
||||
import { AppleTeamType } from '../authenticateTypes';
|
||||
import * as Keychain from '../keychain';
|
||||
import {
|
||||
resolveAppleTeamTypeFromEnvironment,
|
||||
resolveAppleTeamAsync,
|
||||
resolveAscApiKeyAsync,
|
||||
resolveUserCredentialsAsync,
|
||||
@@ -74,17 +73,6 @@ const testTeam = {
|
||||
teamName: 'test-name',
|
||||
teamType: AppleTeamType.IN_HOUSE,
|
||||
};
|
||||
describe(resolveAppleTeamTypeFromEnvironment, () => {
|
||||
it('returns undefined when EXPO_APPLE_TEAM_TYPE is not set', () => {
|
||||
expect(resolveAppleTeamTypeFromEnvironment()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns parsed team type when EXPO_APPLE_TEAM_TYPE is set', () => {
|
||||
process.env.EXPO_APPLE_TEAM_TYPE = AppleTeamType.COMPANY_OR_ORGANIZATION;
|
||||
expect(resolveAppleTeamTypeFromEnvironment()).toBe(AppleTeamType.COMPANY_OR_ORGANIZATION);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveAppleTeamAsync, () => {
|
||||
it(`uses option overrides over environment variables`, async () => {
|
||||
process.env.EXPO_APPLE_TEAM_ID = 'not supposed to be here';
|
||||
|
||||
@@ -118,7 +118,7 @@ function assertAppleTeamType(maybeTeamType: any): AppleTeamType {
|
||||
return maybeTeamType;
|
||||
}
|
||||
|
||||
export function resolveAppleTeamTypeFromEnvironment(): AppleTeamType | undefined {
|
||||
function resolveAppleTeamTypeFromEnvironment(): AppleTeamType | undefined {
|
||||
if (!process.env.EXPO_APPLE_TEAM_TYPE) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
+5
-357
@@ -168,8 +168,6 @@ export type Account = {
|
||||
/** Owning UserActor of this account if personal account */
|
||||
ownerUserActor?: Maybe<UserActor>;
|
||||
pendingSentryInstallation?: Maybe<PendingSentryInstallation>;
|
||||
/** PostHog organization connection for this account */
|
||||
posthogOrganizationConnection?: Maybe<PostHogOrganizationConnection>;
|
||||
profileImageUrl: Scalars['String']['output'];
|
||||
pushSecurityEnabled: Scalars['Boolean']['output'];
|
||||
requireTwoFactor: Scalars['Boolean']['output'];
|
||||
@@ -1532,7 +1530,6 @@ export type App = Project & {
|
||||
* @deprecated Classic updates have been deprecated.
|
||||
*/
|
||||
playStoreUrl?: Maybe<Scalars['String']['output']>;
|
||||
posthogProject?: Maybe<PostHogProject>;
|
||||
/** @deprecated No longer supported */
|
||||
privacy: Scalars['String']['output'];
|
||||
/** @deprecated No longer supported */
|
||||
@@ -3619,7 +3616,6 @@ export enum AppsFilter {
|
||||
|
||||
export type ArgentRunSessionRemoteConfig = {
|
||||
__typename?: 'ArgentRunSessionRemoteConfig';
|
||||
toolsAuthToken?: Maybe<Scalars['String']['output']>;
|
||||
toolsUrl: Scalars['String']['output'];
|
||||
/**
|
||||
* URL of the web preview surface for the session. Null when web previews are
|
||||
@@ -4723,18 +4719,6 @@ export type CreateConvexTeamConnectionInput = {
|
||||
deploymentRegion: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type CreateDeviceRunSessionArtifactUploadSessionInput = {
|
||||
filename: Scalars['String']['input'];
|
||||
metadata?: InputMaybe<Scalars['JSONObject']['input']>;
|
||||
name: Scalars['String']['input'];
|
||||
size: Scalars['Int']['input'];
|
||||
};
|
||||
|
||||
export type CreateDeviceRunSessionArtifactUploadSessionResult = {
|
||||
__typename?: 'CreateDeviceRunSessionArtifactUploadSessionResult';
|
||||
uploadSession: DeviceRunSessionArtifactUploadSession;
|
||||
};
|
||||
|
||||
export type CreateDeviceRunSessionInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
/**
|
||||
@@ -4909,11 +4893,6 @@ export type CreateIosSubmissionInput = {
|
||||
submittedBuildId?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
|
||||
export type CreatePostHogAccountRequestInput = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
region: PostHogRegion;
|
||||
};
|
||||
|
||||
export type CreateSentryProjectInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
sentryProjectId: Scalars['String']['input'];
|
||||
@@ -5380,12 +5359,6 @@ export type DeviceRunSession = {
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type DeviceRunSessionArtifactUploadSession = {
|
||||
__typename?: 'DeviceRunSessionArtifactUploadSession';
|
||||
headers: Scalars['JSONObject']['output'];
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type DeviceRunSessionFilterInput = {
|
||||
platforms?: InputMaybe<Array<AppPlatform>>;
|
||||
statuses?: InputMaybe<Array<DeviceRunSessionStatus>>;
|
||||
@@ -5394,8 +5367,6 @@ export type DeviceRunSessionFilterInput = {
|
||||
|
||||
export type DeviceRunSessionMutation = {
|
||||
__typename?: 'DeviceRunSessionMutation';
|
||||
/** Create an upload session for an artifact produced by an active device run session */
|
||||
createArtifactUploadSession: CreateDeviceRunSessionArtifactUploadSessionResult;
|
||||
/** Create a device run session */
|
||||
createDeviceRunSession: DeviceRunSession;
|
||||
/**
|
||||
@@ -5409,12 +5380,6 @@ export type DeviceRunSessionMutation = {
|
||||
};
|
||||
|
||||
|
||||
export type DeviceRunSessionMutationCreateArtifactUploadSessionArgs = {
|
||||
deviceRunSessionId: Scalars['ID']['input'];
|
||||
input: CreateDeviceRunSessionArtifactUploadSessionInput;
|
||||
};
|
||||
|
||||
|
||||
export type DeviceRunSessionMutationCreateDeviceRunSessionArgs = {
|
||||
deviceRunSessionInput: CreateDeviceRunSessionInput;
|
||||
};
|
||||
@@ -6330,8 +6295,6 @@ export enum EntityTypeName {
|
||||
IosAppCredentialsEntity = 'IosAppCredentialsEntity',
|
||||
LogRocketOrganizationEntity = 'LogRocketOrganizationEntity',
|
||||
LogRocketProjectEntity = 'LogRocketProjectEntity',
|
||||
PostHogOrganizationConnectionEntity = 'PostHogOrganizationConnectionEntity',
|
||||
PostHogProjectEntity = 'PostHogProjectEntity',
|
||||
UserInvitationEntity = 'UserInvitationEntity',
|
||||
UserPermissionEntity = 'UserPermissionEntity',
|
||||
VexoAccountConnectionEntity = 'VexoAccountConnectionEntity',
|
||||
@@ -6572,123 +6535,6 @@ export type EstimatedUsagePlatformDetail = {
|
||||
value: Scalars['Float']['output'];
|
||||
};
|
||||
|
||||
export type ExitInterviewBeginChatTurnInput = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type ExitInterviewBeginChatTurnResult = {
|
||||
__typename?: 'ExitInterviewBeginChatTurnResult';
|
||||
ok: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type ExitInterviewCompleteInput = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
conversationId: Scalars['ID']['input'];
|
||||
messages: Array<ExitInterviewMessageInput>;
|
||||
outcome: ExitInterviewOutcome;
|
||||
/**
|
||||
* Optional classification tags. Present when the website's tagging call
|
||||
* succeeded; omitted when it failed or when the user dismissed without
|
||||
* engaging. When present, drives the conversation-tagged event and the
|
||||
* tagged Slack notification.
|
||||
*/
|
||||
tags?: InputMaybe<ExitInterviewTagsInput>;
|
||||
};
|
||||
|
||||
export type ExitInterviewCompleteResult = {
|
||||
__typename?: 'ExitInterviewCompleteResult';
|
||||
success: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type ExitInterviewFeedbackInput = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
conversationId: Scalars['ID']['input'];
|
||||
feedback: Scalars['String']['input'];
|
||||
outcome: ExitInterviewOutcome;
|
||||
};
|
||||
|
||||
export type ExitInterviewFeedbackResult = {
|
||||
__typename?: 'ExitInterviewFeedbackResult';
|
||||
success: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
export type ExitInterviewMessageInput = {
|
||||
content: Scalars['String']['input'];
|
||||
role: ExitInterviewMessageRole;
|
||||
};
|
||||
|
||||
export enum ExitInterviewMessageRole {
|
||||
Assistant = 'ASSISTANT',
|
||||
User = 'USER'
|
||||
}
|
||||
|
||||
export type ExitInterviewMutation = {
|
||||
__typename?: 'ExitInterviewMutation';
|
||||
/**
|
||||
* Gate a chat turn before the website route calls OpenAI. Enforces
|
||||
* authorization (account admin), eligibility (active paid subscription,
|
||||
* not cancelling), and a per-user rate limit (30 turns/hour). The
|
||||
* website's /api/exit-interview/chat route calls this server-to-server
|
||||
* on every turn; failures map to HTTP 4xx and abort the stream before
|
||||
* any OpenAI tokens are spent.
|
||||
*/
|
||||
beginChatTurn: ExitInterviewBeginChatTurnResult;
|
||||
/**
|
||||
* Mark a treatment-variant exit-interview chat conversation complete. Emits
|
||||
* the conversation-completed event to RudderStack with the full transcript.
|
||||
* If the transcript carries at least one server-signed assistant turn (i.e.
|
||||
* the user actually engaged the bot), fans out the raw transcript to Slack.
|
||||
* If the client also provides tags, emits a separate conversation-tagged
|
||||
* event and a tagged Slack notification. Control-variant one-shot feedback
|
||||
* goes through submitFeedback.
|
||||
*/
|
||||
complete: ExitInterviewCompleteResult;
|
||||
/**
|
||||
* Record a control-variant one-shot feedback submission. Emits the
|
||||
* feedback-submitted event to RudderStack. No Slack notification, no
|
||||
* tagging — those pipelines are scoped to the treatment chat flow via
|
||||
* complete.
|
||||
*/
|
||||
submitFeedback: ExitInterviewFeedbackResult;
|
||||
};
|
||||
|
||||
|
||||
export type ExitInterviewMutationBeginChatTurnArgs = {
|
||||
input: ExitInterviewBeginChatTurnInput;
|
||||
};
|
||||
|
||||
|
||||
export type ExitInterviewMutationCompleteArgs = {
|
||||
input: ExitInterviewCompleteInput;
|
||||
};
|
||||
|
||||
|
||||
export type ExitInterviewMutationSubmitFeedbackArgs = {
|
||||
input: ExitInterviewFeedbackInput;
|
||||
};
|
||||
|
||||
export enum ExitInterviewOutcome {
|
||||
CancelledImmediately = 'CANCELLED_IMMEDIATELY',
|
||||
ContinuedToStripe = 'CONTINUED_TO_STRIPE',
|
||||
Dismissed = 'DISMISSED',
|
||||
KeptPlan = 'KEPT_PLAN'
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags produced by the website's churn-classification step. The website runs
|
||||
* this synchronously before calling complete, then forwards the structured
|
||||
* result. Allowed category / sentiment values are enforced by the resolver.
|
||||
*/
|
||||
export type ExitInterviewTagsInput = {
|
||||
actionableInsight: Scalars['Boolean']['input'];
|
||||
category: Scalars['String']['input'];
|
||||
competitorMention?: InputMaybe<Scalars['String']['input']>;
|
||||
confidence?: InputMaybe<Scalars['String']['input']>;
|
||||
productArea?: InputMaybe<Scalars['String']['input']>;
|
||||
sentiment: Scalars['String']['input'];
|
||||
summary?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export enum Experiment {
|
||||
Orbit = 'ORBIT'
|
||||
}
|
||||
@@ -8281,76 +8127,6 @@ export type PinnedDashboardView = {
|
||||
|
||||
export type PlanEnablement = Concurrencies | EasTotalPlanEnablement;
|
||||
|
||||
export type PostHogIntegrationQuery = {
|
||||
__typename?: 'PostHogIntegrationQuery';
|
||||
clientIdentifier: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type PostHogOrganizationConnection = {
|
||||
__typename?: 'PostHogOrganizationConnection';
|
||||
account: Account;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
posthogOrganizationIdentifier: Scalars['String']['output'];
|
||||
posthogOrganizationName: Scalars['String']['output'];
|
||||
posthogProjects: Array<PostHogProject>;
|
||||
posthogRegion: PostHogRegion;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type PostHogOrganizationConnectionMutation = {
|
||||
__typename?: 'PostHogOrganizationConnectionMutation';
|
||||
createPostHogAccountRequest: PostHogOrganizationConnection;
|
||||
/** Removes the Expo-side connection only; the PostHog organization is preserved. */
|
||||
deletePostHogOrganizationConnection: Scalars['ID']['output'];
|
||||
};
|
||||
|
||||
|
||||
export type PostHogOrganizationConnectionMutationCreatePostHogAccountRequestArgs = {
|
||||
input: CreatePostHogAccountRequestInput;
|
||||
};
|
||||
|
||||
|
||||
export type PostHogOrganizationConnectionMutationDeletePostHogOrganizationConnectionArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type PostHogProject = {
|
||||
__typename?: 'PostHogProject';
|
||||
app: App;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
posthogHost: Scalars['String']['output'];
|
||||
posthogOrganizationConnection: PostHogOrganizationConnection;
|
||||
posthogProjectIdentifier: Scalars['String']['output'];
|
||||
posthogProjectName: Scalars['String']['output'];
|
||||
posthogProjectToken: Scalars['String']['output'];
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type PostHogProjectMutation = {
|
||||
__typename?: 'PostHogProjectMutation';
|
||||
/** Removes the Expo-side project link only; the PostHog project is preserved. */
|
||||
deletePostHogProject: Scalars['ID']['output'];
|
||||
/** Provisions a PostHog project for the app; the project name is derived from the app. */
|
||||
setupPostHogProject: PostHogProject;
|
||||
};
|
||||
|
||||
|
||||
export type PostHogProjectMutationDeletePostHogProjectArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type PostHogProjectMutationSetupPostHogProjectArgs = {
|
||||
input: SetupPostHogProjectInput;
|
||||
};
|
||||
|
||||
export enum PostHogRegion {
|
||||
Eu = 'EU',
|
||||
Us = 'US'
|
||||
}
|
||||
|
||||
export type Project = {
|
||||
description: Scalars['String']['output'];
|
||||
fullName: Scalars['String']['output'];
|
||||
@@ -8672,8 +8448,6 @@ export type RootMutation = {
|
||||
environmentSecret: EnvironmentSecretMutation;
|
||||
/** Mutations that create and delete EnvironmentVariables */
|
||||
environmentVariable: EnvironmentVariableMutation;
|
||||
/** Mutations for the exit-interview chat shown when a user cancels their plan. */
|
||||
exitInterview: ExitInterviewMutation;
|
||||
/** Mutations that modify App fingerprints */
|
||||
fingerprint: FingerprintMutation;
|
||||
/** Mutations that utilize services facilitated by the GitHub App */
|
||||
@@ -8706,8 +8480,6 @@ export type RootMutation = {
|
||||
me: MeMutation;
|
||||
/** Notification preference management */
|
||||
notificationPreference: NotificationPreferenceMutation;
|
||||
posthogOrganizationConnection: PostHogOrganizationConnectionMutation;
|
||||
posthogProject: PostHogProjectMutation;
|
||||
/** Mutations that create, update, and delete Robots */
|
||||
robot: RobotMutation;
|
||||
/** Mutations for Sentry installations */
|
||||
@@ -8716,7 +8488,6 @@ export type RootMutation = {
|
||||
sentryProject: SentryProjectMutation;
|
||||
/** Mutations that modify an EAS Submit submission */
|
||||
submission: SubmissionMutation;
|
||||
tunnels: TunnelsMutation;
|
||||
turtleBrownfieldArtifacts: TurtleBrownfieldArtifactMutation;
|
||||
update: UpdateMutation;
|
||||
updateBranch: UpdateBranchMutation;
|
||||
@@ -8855,8 +8626,6 @@ export type RootQuery = {
|
||||
* this is the appropriate top-level query object
|
||||
*/
|
||||
meUserActor?: Maybe<UserActor>;
|
||||
/** Top-level query object for querying PostHog Integration information. */
|
||||
posthogIntegration: PostHogIntegrationQuery;
|
||||
/** @deprecated Snacks and apps should be queried separately */
|
||||
project: ProjectQuery;
|
||||
/** Top-level query object for querying Runtimes. */
|
||||
@@ -9151,6 +8920,7 @@ export type SecondFactorDeviceConfiguration = {
|
||||
isPrimary: Scalars['Boolean']['input'];
|
||||
method: SecondFactorMethod;
|
||||
name: Scalars['String']['input'];
|
||||
smsPhoneNumber?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type SecondFactorDeviceConfigurationResult = {
|
||||
@@ -9169,10 +8939,7 @@ export type SecondFactorInitiationResult = {
|
||||
export enum SecondFactorMethod {
|
||||
/** Google Authenticator (TOTP) */
|
||||
Authenticator = 'AUTHENTICATOR',
|
||||
/**
|
||||
* SMS
|
||||
* @deprecated No longer supported
|
||||
*/
|
||||
/** SMS */
|
||||
Sms = 'SMS'
|
||||
}
|
||||
|
||||
@@ -9276,11 +9043,6 @@ export type SetupConvexProjectResult = {
|
||||
deployKey: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type SetupPostHogProjectInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
posthogOrganizationConnectionId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type SizeBreakdownCategory = {
|
||||
__typename?: 'SizeBreakdownCategory';
|
||||
assetCount: Scalars['Int']['output'];
|
||||
@@ -9648,23 +9410,6 @@ export type TimelineActivityFilterInput = {
|
||||
types?: InputMaybe<Array<ActivityTimelineProjectActivityType>>;
|
||||
};
|
||||
|
||||
export type TunnelSignedUrlResult = {
|
||||
__typename?: 'TunnelSignedUrlResult';
|
||||
label: Scalars['ID']['output'];
|
||||
url: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type TunnelsMutation = {
|
||||
__typename?: 'TunnelsMutation';
|
||||
/** Create a signed tunnel URL for an account */
|
||||
createSignedTunnelUrl: TunnelSignedUrlResult;
|
||||
};
|
||||
|
||||
|
||||
export type TunnelsMutationCreateSignedTunnelUrlArgs = {
|
||||
accountId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export type TurtleBrownfieldArtifactMutation = {
|
||||
__typename?: 'TurtleBrownfieldArtifactMutation';
|
||||
createTurtleBrownfieldArtifact: CreateBrownfieldArtifactResult;
|
||||
@@ -10391,8 +10136,6 @@ export type User = Actor & UserActor & {
|
||||
location?: Maybe<Scalars['String']['output']>;
|
||||
newEmailPendingVerification?: Maybe<Scalars['String']['output']>;
|
||||
oAuthIdentities: Array<OAuthIdentity>;
|
||||
/** Registered passkey credentials */
|
||||
passkeyCredentials: Array<UserPasskeyCredential>;
|
||||
/** Pending UserInvitations for this user. Only resolves for the viewer. */
|
||||
pendingUserInvitations: Array<UserInvitation>;
|
||||
pinnedApps: Array<App>;
|
||||
@@ -10773,7 +10516,6 @@ export enum UserEntityTypeName {
|
||||
PasswordEntity = 'PasswordEntity',
|
||||
SsoUserEntity = 'SSOUserEntity',
|
||||
UserEntity = 'UserEntity',
|
||||
UserPasskeyCredentialEntity = 'UserPasskeyCredentialEntity',
|
||||
UserPermissionEntity = 'UserPermissionEntity',
|
||||
UserSecondFactorBackupCodesEntity = 'UserSecondFactorBackupCodesEntity',
|
||||
UserSecondFactorDeviceEntity = 'UserSecondFactorDeviceEntity'
|
||||
@@ -10903,21 +10645,6 @@ export type UserLogNameTypeMapping = {
|
||||
typeName: UserEntityTypeName;
|
||||
};
|
||||
|
||||
/** A passkey credential belonging to a User */
|
||||
export type UserPasskeyCredential = {
|
||||
__typename?: 'UserPasskeyCredential';
|
||||
/**
|
||||
* Authenticator Attestation Globally Unique Identifier — identifies the authenticator model
|
||||
* (e.g., 1Password, iCloud Keychain). See https://passkeydeveloper.github.io/passkey-authenticator-aaguids/explorer/
|
||||
*/
|
||||
aaguid?: Maybe<Scalars['ID']['output']>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
credentialDeviceType: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
lastUsedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
name: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type UserPermission = {
|
||||
__typename?: 'UserPermission';
|
||||
actor: Actor;
|
||||
@@ -11713,11 +11440,6 @@ export type WorkflowArtifact = {
|
||||
filename: Scalars['String']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
jobRun: JobRun;
|
||||
/**
|
||||
* Opaque JSON attached by the worker at upload time (e.g. the Maestro test screenshot
|
||||
* flow/attempt mapping). Interpretation is owned by the client.
|
||||
*/
|
||||
metadata?: Maybe<Scalars['JSONObject']['output']>;
|
||||
name: Scalars['String']['output'];
|
||||
storageType: WorkflowArtifactStorageType;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
@@ -12035,14 +11757,6 @@ export type WorkflowJob = {
|
||||
approvals: Array<WorkflowJobApproval>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
credentialsAppleDeviceRegistrationRequest?: Maybe<AppleDeviceRegistrationRequest>;
|
||||
/**
|
||||
* All test case attempt rows produced by this job's own execution (turtle job
|
||||
* run), ordered by path then retryCount. Unlike deviceTestCaseResults and
|
||||
* allDeviceTestCaseResults, this never includes rows from other jobs in the
|
||||
* workflow retry chain.
|
||||
*/
|
||||
deviceTestCaseResultAttempts: Array<WorkflowDeviceTestCaseResult>;
|
||||
/** @deprecated Use deviceTestCaseResultAttempts instead */
|
||||
deviceTestCaseResults: Array<WorkflowDeviceTestCaseResult>;
|
||||
environment?: Maybe<Scalars['String']['output']>;
|
||||
errors: Array<WorkflowJobError>;
|
||||
@@ -12381,7 +12095,6 @@ export enum WorkflowRunTriggerEventType {
|
||||
GithubPullRequestReopened = 'GITHUB_PULL_REQUEST_REOPENED',
|
||||
GithubPullRequestSynchronize = 'GITHUB_PULL_REQUEST_SYNCHRONIZE',
|
||||
GithubPush = 'GITHUB_PUSH',
|
||||
GithubRefDelete = 'GITHUB_REF_DELETE',
|
||||
Manual = 'MANUAL',
|
||||
RepackExpoGo = 'REPACK_EXPO_GO',
|
||||
Schedule = 'SCHEDULE'
|
||||
@@ -13233,27 +12946,6 @@ export type CreateLocalBuildMutationVariables = Exact<{
|
||||
|
||||
export type CreateLocalBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', createLocalBuild: { __typename?: 'CreateBuildResult', build: { __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 CreatePostHogAccountRequestMutationVariables = Exact<{
|
||||
input: CreatePostHogAccountRequestInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreatePostHogAccountRequestMutation = { __typename?: 'RootMutation', posthogOrganizationConnection: { __typename?: 'PostHogOrganizationConnectionMutation', createPostHogAccountRequest: { __typename?: 'PostHogOrganizationConnection', id: string, posthogOrganizationIdentifier: string, posthogOrganizationName: string, posthogRegion: PostHogRegion, createdAt: any, updatedAt: any } } };
|
||||
|
||||
export type SetupPostHogProjectMutationVariables = Exact<{
|
||||
input: SetupPostHogProjectInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type SetupPostHogProjectMutation = { __typename?: 'RootMutation', posthogProject: { __typename?: 'PostHogProjectMutation', setupPostHogProject: { __typename?: 'PostHogProject', id: string, posthogProjectIdentifier: string, posthogProjectName: string, posthogProjectToken: string, posthogHost: string, createdAt: any, updatedAt: any, posthogOrganizationConnection: { __typename?: 'PostHogOrganizationConnection', id: string, posthogOrganizationIdentifier: string, posthogOrganizationName: string, posthogRegion: PostHogRegion, createdAt: any, updatedAt: any } } } };
|
||||
|
||||
export type DeletePostHogProjectMutationVariables = Exact<{
|
||||
id: Scalars['ID']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type DeletePostHogProjectMutation = { __typename?: 'RootMutation', posthogProject: { __typename?: 'PostHogProjectMutation', deletePostHogProject: string } };
|
||||
|
||||
export type GetSignedUploadMutationVariables = Exact<{
|
||||
contentTypes: Array<Scalars['String']['input']> | Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -13503,17 +13195,6 @@ export type GetAssetSignedUrlsQueryVariables = Exact<{
|
||||
|
||||
export type GetAssetSignedUrlsQuery = { __typename?: 'RootQuery', asset: { __typename?: 'AssetQuery', signedUrls: Array<{ __typename?: 'AssetSignedUrlResult', storageKey: string, url: string, headers?: any | null }> } };
|
||||
|
||||
export type AuditLogsByAccountQueryVariables = Exact<{
|
||||
accountId: Scalars['String']['input'];
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type AuditLogsByAccountQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, auditLogsPaginated: { __typename?: 'AuditLogConnection', edges: Array<{ __typename?: 'AuditLogEdge', cursor: string, node: { __typename?: 'AuditLog', id: string, createdAt: any, websiteMessage: string, targetEntityTypePublicName: string, targetEntityMutationType: TargetEntityMutationType, actor?: { __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 } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
|
||||
|
||||
export type BackgroundJobReceiptByIdQueryVariables = Exact<{
|
||||
id: Scalars['ID']['input'];
|
||||
}>;
|
||||
@@ -13668,7 +13349,7 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null } | { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null } | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, streamUrl: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
|
||||
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null } | { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, webPreviewUrl?: string | null } | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, streamUrl: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
|
||||
|
||||
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
@@ -13796,7 +13477,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, customParams?: any | null, routeName?: string | null } }> } } } } };
|
||||
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 AppObserveCustomEventListQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
@@ -13830,20 +13511,6 @@ export type AppObserveNavigationRoutesQueryVariables = Exact<{
|
||||
|
||||
export type AppObserveNavigationRoutesQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', navigationRoutes: { __typename?: 'AppObserveNavigationRoutesConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveNavigationRouteEdge', cursor: string, node: { __typename?: 'AppObserveNavigationRoute', routeName: string, coldTtr: { __typename?: 'AppObserveNavigationStat', count: number, median?: number | null, p90?: number | null }, warmTtr: { __typename?: 'AppObserveNavigationStat', count: number, median?: number | null, p90?: number | null }, tti: { __typename?: 'AppObserveNavigationStat', count: number, median?: number | null, p90?: number | null } } }> } } } } };
|
||||
|
||||
export type PostHogOrganizationConnectionByAccountIdQueryVariables = Exact<{
|
||||
accountId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type PostHogOrganizationConnectionByAccountIdQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, posthogOrganizationConnection?: { __typename?: 'PostHogOrganizationConnection', id: string, posthogOrganizationIdentifier: string, posthogOrganizationName: string, posthogRegion: PostHogRegion, createdAt: any, updatedAt: any } | null } } };
|
||||
|
||||
export type PostHogProjectByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type PostHogProjectByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, posthogProject?: { __typename?: 'PostHogProject', id: string, posthogProjectIdentifier: string, posthogProjectName: string, posthogProjectToken: string, posthogHost: string, createdAt: any, updatedAt: any, posthogOrganizationConnection: { __typename?: 'PostHogOrganizationConnection', id: string, posthogOrganizationIdentifier: string, posthogOrganizationName: string, posthogRegion: PostHogRegion, createdAt: any, updatedAt: any } } | null } } };
|
||||
|
||||
export type GetAssetMetadataQueryVariables = Exact<{
|
||||
storageKeys: Array<Scalars['String']['input']> | Scalars['String']['input'];
|
||||
}>;
|
||||
@@ -13922,19 +13589,6 @@ export type ViewUpdateGroupsOnBranchQueryVariables = Exact<{
|
||||
|
||||
export type ViewUpdateGroupsOnBranchQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateBranchByName?: { __typename?: 'UpdateBranch', id: string, updateGroups: Array<Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, isGitWorkingTreeDirty: boolean, environment?: any | null, rolloutPercentage?: number | null, manifestHostOverride?: string | null, assetHostOverride?: string | null, actor?: { __typename: 'PartnerActor', username: string, id: string } | { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null, rolloutControlUpdate?: { __typename?: 'Update', id: string, group: string } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, source?: { __typename?: 'FingerprintSource', type: FingerprintSourceType, bucketKey: string, isDebugFingerprint?: boolean | null } | null } | null }>> } | null } } };
|
||||
|
||||
export type ViewUpdateGroupsPaginatedOnBranchQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
branchName: Scalars['String']['input'];
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<UpdatesFilterV2>;
|
||||
}>;
|
||||
|
||||
|
||||
export type ViewUpdateGroupsPaginatedOnBranchQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateBranchByName?: { __typename?: 'UpdateBranch', id: string, updateGroupsPaginated: { __typename?: 'UpdateGroupsConnection', edges: Array<{ __typename?: 'UpdateGroupEdge', node: Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, runtimeVersion: string, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, isGitWorkingTreeDirty: boolean, environment?: any | null, rolloutPercentage?: number | null, manifestHostOverride?: string | null, assetHostOverride?: string | null, actor?: { __typename: 'PartnerActor', username: string, id: string } | { __typename: 'Robot', firstName?: string | null, id: string } | { __typename: 'SSOUser', username: string, id: string } | { __typename: 'User', username: string, id: string } | null, branch: { __typename?: 'UpdateBranch', id: string, name: string }, codeSigningInfo?: { __typename?: 'CodeSigningInfo', keyid: string, sig: string, alg: string } | null, rolloutControlUpdate?: { __typename?: 'Update', id: string, group: string } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, source?: { __typename?: 'FingerprintSource', type: FingerprintSourceType, bucketKey: string, isDebugFingerprint?: boolean | null } | null } | null }> }> } } | null } } };
|
||||
|
||||
export type ViewUpdateGroupsOnAppQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
limit: Scalars['Int']['input'];
|
||||
@@ -14041,8 +13695,6 @@ export type AccountUsageMetricFragment = { __typename?: 'AccountUsageMetric', id
|
||||
|
||||
export type AppFragment = { __typename?: 'App', id: string, name: string, fullName: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string, ownerUserActor?: { __typename?: 'SSOUser', id: string, username: string } | { __typename?: 'User', id: string, username: string } | null, users: Array<{ __typename?: 'UserPermission', role: Role, actor: { __typename?: 'PartnerActor', id: string } | { __typename?: 'Robot', id: string } | { __typename?: 'SSOUser', id: string } | { __typename?: 'User', id: string } }> }, githubRepository?: { __typename?: 'GitHubRepository', id: string, metadata: { __typename?: 'GitHubRepositoryMetadata', id: string, githubRepoOwnerName: string, githubRepoName: string } } | null };
|
||||
|
||||
export type AuditLogFragment = { __typename?: 'AuditLog', id: string, createdAt: any, websiteMessage: string, targetEntityTypePublicName: string, targetEntityMutationType: TargetEntityMutationType, actor?: { __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 };
|
||||
|
||||
export type BackgroundJobReceiptDataFragment = { __typename?: 'BackgroundJobReceipt', id: string, state: BackgroundJobState, tries: number, willRetry: boolean, resultId?: string | null, resultType: BackgroundJobResultType, resultData?: any | null, errorCode?: string | null, errorMessage?: string | null, createdAt: any, updatedAt: any };
|
||||
|
||||
export type BuildFragment = { __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 };
|
||||
@@ -14069,14 +13721,10 @@ export type AppObserveTimeSeriesFragment = { __typename?: 'AppObserveTimeSeries'
|
||||
|
||||
export type AppObserveCustomEventFragment = { __typename?: 'AppObserveCustomEvent', id: string, eventName: string, timestamp: any, sessionId?: string | null, severityNumber?: number | null, severityText?: string | null, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, appEasBuildId?: string | null, deviceOs: string, deviceOsVersion: string, deviceModel: string, environment?: string | null, easClientId: string, countryCode?: string | null, properties: Array<{ __typename?: 'AppObserveEventProperty', key: string, value: string, type: AppObservePropertyType }> };
|
||||
|
||||
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, routeName?: string | 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, 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 } }> };
|
||||
|
||||
export type PostHogOrganizationConnectionFragment = { __typename?: 'PostHogOrganizationConnection', id: string, posthogOrganizationIdentifier: string, posthogOrganizationName: string, posthogRegion: PostHogRegion, createdAt: any, updatedAt: any };
|
||||
|
||||
export type PostHogProjectFragment = { __typename?: 'PostHogProject', id: string, posthogProjectIdentifier: string, posthogProjectName: string, posthogProjectToken: string, posthogHost: string, createdAt: any, updatedAt: any, posthogOrganizationConnection: { __typename?: 'PostHogOrganizationConnection', id: string, posthogOrganizationIdentifier: string, posthogOrganizationName: string, posthogRegion: PostHogRegion, createdAt: any, updatedAt: any } };
|
||||
|
||||
export type RuntimeFragment = { __typename?: 'Runtime', id: string, version: string };
|
||||
|
||||
export type StatuspageServiceFragment = { __typename?: 'StatuspageService', id: string, name: StatuspageServiceName, status: StatuspageServiceStatus, incidents: Array<{ __typename?: 'StatuspageIncident', id: string, status: StatuspageIncidentStatus, name: string, impact: StatuspageIncidentImpact, shortlink: string }> };
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import { CreatePostHogAccountRequestInput, SetupPostHogProjectInput } from '../generated';
|
||||
import {
|
||||
PostHogOrganizationConnectionData,
|
||||
PostHogOrganizationConnectionFragmentNode,
|
||||
PostHogProjectData,
|
||||
PostHogProjectFragmentNode,
|
||||
} from '../types/PostHogConnection';
|
||||
|
||||
type CreatePostHogAccountRequestMutation = {
|
||||
posthogOrganizationConnection: {
|
||||
createPostHogAccountRequest: PostHogOrganizationConnectionData;
|
||||
};
|
||||
};
|
||||
|
||||
type CreatePostHogAccountRequestMutationVariables = {
|
||||
input: CreatePostHogAccountRequestInput;
|
||||
};
|
||||
|
||||
type SetupPostHogProjectMutation = {
|
||||
posthogProject: {
|
||||
setupPostHogProject: PostHogProjectData;
|
||||
};
|
||||
};
|
||||
|
||||
type SetupPostHogProjectMutationVariables = {
|
||||
input: SetupPostHogProjectInput;
|
||||
};
|
||||
|
||||
type DeletePostHogProjectMutation = {
|
||||
posthogProject: {
|
||||
deletePostHogProject: string;
|
||||
};
|
||||
};
|
||||
|
||||
type DeletePostHogProjectMutationVariables = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const PostHogMutation = {
|
||||
async createPostHogAccountRequestAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
input: CreatePostHogAccountRequestInput
|
||||
): Promise<PostHogOrganizationConnectionData> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<
|
||||
CreatePostHogAccountRequestMutation,
|
||||
CreatePostHogAccountRequestMutationVariables
|
||||
>(
|
||||
gql`
|
||||
mutation CreatePostHogAccountRequest($input: CreatePostHogAccountRequestInput!) {
|
||||
posthogOrganizationConnection {
|
||||
createPostHogAccountRequest(input: $input) {
|
||||
id
|
||||
...PostHogOrganizationConnectionFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(PostHogOrganizationConnectionFragmentNode)}
|
||||
`,
|
||||
{ input }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.posthogOrganizationConnection.createPostHogAccountRequest;
|
||||
},
|
||||
|
||||
async setupPostHogProjectAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
input: SetupPostHogProjectInput
|
||||
): Promise<PostHogProjectData> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<SetupPostHogProjectMutation, SetupPostHogProjectMutationVariables>(
|
||||
gql`
|
||||
mutation SetupPostHogProject($input: SetupPostHogProjectInput!) {
|
||||
posthogProject {
|
||||
setupPostHogProject(input: $input) {
|
||||
id
|
||||
...PostHogProjectFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(PostHogOrganizationConnectionFragmentNode)}
|
||||
${print(PostHogProjectFragmentNode)}
|
||||
`,
|
||||
{ input }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.posthogProject.setupPostHogProject;
|
||||
},
|
||||
|
||||
async deletePostHogProjectAsync(graphqlClient: ExpoGraphqlClient, id: string): Promise<string> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<DeletePostHogProjectMutation, DeletePostHogProjectMutationVariables>(
|
||||
gql`
|
||||
mutation DeletePostHogProject($id: ID!) {
|
||||
posthogProject {
|
||||
deletePostHogProject(id: $id)
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ id },
|
||||
{ additionalTypenames: ['App', 'PostHogProject'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.posthogProject.deletePostHogProject;
|
||||
},
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { Connection, QueryParams } from '../../utils/relay';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
AuditLogFragment,
|
||||
AuditLogsByAccountQuery,
|
||||
AuditLogsByAccountQueryVariables,
|
||||
} from '../generated';
|
||||
import { AuditLogFragmentNode } from '../types/AuditLog';
|
||||
|
||||
export const AuditLogQuery = {
|
||||
async getAllForAccountAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string,
|
||||
queryParams: QueryParams
|
||||
): Promise<Connection<AuditLogFragment>> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<AuditLogsByAccountQuery, AuditLogsByAccountQueryVariables>(
|
||||
gql`
|
||||
query AuditLogsByAccount(
|
||||
$accountId: String!
|
||||
$first: Int
|
||||
$after: String
|
||||
$last: Int
|
||||
$before: String
|
||||
) {
|
||||
account {
|
||||
byId(accountId: $accountId) {
|
||||
id
|
||||
auditLogsPaginated(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
) {
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
...AuditLogFragment
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(AuditLogFragmentNode)}
|
||||
`,
|
||||
{
|
||||
accountId,
|
||||
first: queryParams.first,
|
||||
after: queryParams.after,
|
||||
last: queryParams.last,
|
||||
before: queryParams.before,
|
||||
},
|
||||
{ additionalTypenames: ['AuditLog'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.account.byId.auditLogsPaginated;
|
||||
},
|
||||
};
|
||||
@@ -42,7 +42,6 @@ export const DeviceRunSessionQuery = {
|
||||
}
|
||||
... on ArgentRunSessionRemoteConfig {
|
||||
toolsUrl
|
||||
toolsAuthToken
|
||||
webPreviewUrl
|
||||
}
|
||||
... on ServeSimRunSessionRemoteConfig {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { print } from 'graphql';
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
PostHogOrganizationConnectionData,
|
||||
PostHogOrganizationConnectionFragmentNode,
|
||||
PostHogProjectData,
|
||||
PostHogProjectFragmentNode,
|
||||
} from '../types/PostHogConnection';
|
||||
|
||||
type PostHogOrganizationConnectionByAccountIdQuery = {
|
||||
account: {
|
||||
byId: {
|
||||
id: string;
|
||||
posthogOrganizationConnection?: PostHogOrganizationConnectionData | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type PostHogOrganizationConnectionByAccountIdQueryVariables = {
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
type PostHogProjectByAppIdQuery = {
|
||||
app: {
|
||||
byId: {
|
||||
id: string;
|
||||
posthogProject?: PostHogProjectData | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type PostHogProjectByAppIdQueryVariables = {
|
||||
appId: string;
|
||||
};
|
||||
|
||||
export const PostHogQuery = {
|
||||
async getPostHogOrganizationConnectionByAccountIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string
|
||||
): Promise<PostHogOrganizationConnectionData | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<
|
||||
PostHogOrganizationConnectionByAccountIdQuery,
|
||||
PostHogOrganizationConnectionByAccountIdQueryVariables
|
||||
>(
|
||||
gql`
|
||||
query PostHogOrganizationConnectionByAccountId($accountId: String!) {
|
||||
account {
|
||||
byId(accountId: $accountId) {
|
||||
id
|
||||
posthogOrganizationConnection {
|
||||
id
|
||||
...PostHogOrganizationConnectionFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(PostHogOrganizationConnectionFragmentNode)}
|
||||
`,
|
||||
{ accountId },
|
||||
{ additionalTypenames: ['PostHogOrganizationConnection'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.account.byId.posthogOrganizationConnection ?? null;
|
||||
},
|
||||
|
||||
async getPostHogProjectByAppIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string
|
||||
): Promise<PostHogProjectData | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<PostHogProjectByAppIdQuery, PostHogProjectByAppIdQueryVariables>(
|
||||
gql`
|
||||
query PostHogProjectByAppId($appId: String!) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
posthogProject {
|
||||
id
|
||||
...PostHogProjectFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(PostHogOrganizationConnectionFragmentNode)}
|
||||
${print(PostHogProjectFragmentNode)}
|
||||
`,
|
||||
{ appId },
|
||||
{ additionalTypenames: ['App', 'PostHogProject'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
|
||||
return data.app.byId.posthogProject ?? null;
|
||||
},
|
||||
};
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
ViewUpdateGroupsOnAppQueryVariables,
|
||||
ViewUpdateGroupsOnBranchQuery,
|
||||
ViewUpdateGroupsOnBranchQueryVariables,
|
||||
ViewUpdateGroupsPaginatedOnBranchQuery,
|
||||
ViewUpdateGroupsPaginatedOnBranchQueryVariables,
|
||||
ViewUpdatesByGroupQuery,
|
||||
ViewUpdatesByGroupQueryVariables,
|
||||
} from '../generated';
|
||||
@@ -98,80 +96,6 @@ export const UpdateQuery = {
|
||||
|
||||
return branch.updateGroups;
|
||||
},
|
||||
async viewUpdateGroupsPaginatedOnBranchAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{
|
||||
appId,
|
||||
branchName,
|
||||
first,
|
||||
last,
|
||||
after,
|
||||
before,
|
||||
filter,
|
||||
}: ViewUpdateGroupsPaginatedOnBranchQueryVariables
|
||||
): Promise<UpdateFragment[][]> {
|
||||
const response = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<
|
||||
ViewUpdateGroupsPaginatedOnBranchQuery,
|
||||
ViewUpdateGroupsPaginatedOnBranchQueryVariables
|
||||
>(
|
||||
gql`
|
||||
query ViewUpdateGroupsPaginatedOnBranch(
|
||||
$appId: String!
|
||||
$branchName: String!
|
||||
$first: Int
|
||||
$last: Int
|
||||
$after: String
|
||||
$before: String
|
||||
$filter: UpdatesFilterV2
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
updateBranchByName(name: $branchName) {
|
||||
id
|
||||
updateGroupsPaginated(
|
||||
first: $first
|
||||
last: $last
|
||||
after: $after
|
||||
before: $before
|
||||
filter: $filter
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...UpdateFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(UpdateFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
branchName,
|
||||
first,
|
||||
last,
|
||||
after,
|
||||
before,
|
||||
filter,
|
||||
},
|
||||
{ additionalTypenames: ['Update'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const branch = response.app.byId.updateBranchByName;
|
||||
|
||||
if (!branch) {
|
||||
throw new Error(`Could not find branch "${branchName}"`);
|
||||
}
|
||||
|
||||
return branch.updateGroupsPaginated.edges.map(edge => edge.node);
|
||||
},
|
||||
async viewUpdateGroupsOnAppAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
{ limit, offset, appId, filter }: ViewUpdateGroupsOnAppQueryVariables
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { anything, capture, instance, mock, when } from 'ts-mockito';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { AuditLogQuery } from '../AuditLogQuery';
|
||||
|
||||
describe(AuditLogQuery.getAllForAccountAsync.name, () => {
|
||||
it('returns the audit log connection for an account and forwards pagination params', async () => {
|
||||
const connection = {
|
||||
edges: [
|
||||
{
|
||||
cursor: 'cursor-1',
|
||||
node: {
|
||||
id: 'audit-log-id',
|
||||
createdAt: '2026-05-21T17:43:32.433Z',
|
||||
websiteMessage: 'Created a new Project',
|
||||
targetEntityTypePublicName: 'Project',
|
||||
targetEntityMutationType: 'CREATE',
|
||||
actor: { id: 'actor-id', displayName: 'tester' },
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
startCursor: 'cursor-1',
|
||||
endCursor: 'cursor-1',
|
||||
},
|
||||
};
|
||||
|
||||
const graphqlClientMock = mock<ExpoGraphqlClient>();
|
||||
when(graphqlClientMock.query(anything(), anything(), anything())).thenReturn({
|
||||
toPromise: () =>
|
||||
Promise.resolve({ data: { account: { byId: { auditLogsPaginated: connection } } } }),
|
||||
} as any);
|
||||
const graphqlClient = instance(graphqlClientMock);
|
||||
|
||||
const result = await AuditLogQuery.getAllForAccountAsync(graphqlClient, 'account-id', {
|
||||
first: 10,
|
||||
after: 'cursor-0',
|
||||
});
|
||||
|
||||
expect(result).toBe(connection);
|
||||
|
||||
const [, variables] = capture<any, any, any>(graphqlClientMock.query as any).first();
|
||||
expect(variables).toMatchObject({ accountId: 'account-id', first: 10, after: 'cursor-0' });
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const AuditLogFragmentNode = gql`
|
||||
fragment AuditLogFragment on AuditLog {
|
||||
id
|
||||
createdAt
|
||||
websiteMessage
|
||||
targetEntityTypePublicName
|
||||
targetEntityMutationType
|
||||
actor {
|
||||
id
|
||||
displayName
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -60,7 +60,6 @@ export const AppObserveEventFragmentNode = gql`
|
||||
sessionId
|
||||
easClientId
|
||||
customParams
|
||||
routeName
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import { PostHogOrganizationConnection, PostHogProject } from '../generated';
|
||||
|
||||
export type PostHogOrganizationConnectionData = Pick<
|
||||
PostHogOrganizationConnection,
|
||||
| 'id'
|
||||
| 'posthogOrganizationIdentifier'
|
||||
| 'posthogOrganizationName'
|
||||
| 'posthogRegion'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
>;
|
||||
|
||||
export type PostHogProjectData = Pick<
|
||||
PostHogProject,
|
||||
| 'id'
|
||||
| 'posthogProjectIdentifier'
|
||||
| 'posthogProjectName'
|
||||
| 'posthogProjectToken'
|
||||
| 'posthogHost'
|
||||
| 'createdAt'
|
||||
| 'updatedAt'
|
||||
> & {
|
||||
posthogOrganizationConnection: PostHogOrganizationConnectionData;
|
||||
};
|
||||
|
||||
export const PostHogOrganizationConnectionFragmentNode = gql`
|
||||
fragment PostHogOrganizationConnectionFragment on PostHogOrganizationConnection {
|
||||
id
|
||||
posthogOrganizationIdentifier
|
||||
posthogOrganizationName
|
||||
posthogRegion
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
`;
|
||||
|
||||
export const PostHogProjectFragmentNode = gql`
|
||||
fragment PostHogProjectFragment on PostHogProject {
|
||||
id
|
||||
posthogProjectIdentifier
|
||||
posthogProjectName
|
||||
posthogProjectToken
|
||||
posthogHost
|
||||
createdAt
|
||||
updatedAt
|
||||
posthogOrganizationConnection {
|
||||
id
|
||||
...PostHogOrganizationConnectionFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,193 +0,0 @@
|
||||
import {
|
||||
AppObserveCustomEvent,
|
||||
AppObserveEvent,
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
} from '../../graphql/generated';
|
||||
import { fetchObserveCustomEventsAsync } from '../fetchCustomEvents';
|
||||
import { fetchObserveEventsAsync } from '../fetchEvents';
|
||||
import { fetchObserveSessionEventsAsync } from '../fetchSessions';
|
||||
|
||||
jest.mock('../fetchCustomEvents');
|
||||
jest.mock('../fetchEvents');
|
||||
|
||||
const mockFetchObserveEventsAsync = jest.mocked(fetchObserveEventsAsync);
|
||||
const mockFetchObserveCustomEventsAsync = jest.mocked(fetchObserveCustomEventsAsync);
|
||||
|
||||
function makeMetricEvent(overrides: Partial<AppObserveEvent> = {}): AppObserveEvent {
|
||||
return {
|
||||
__typename: 'AppObserveEvent' as const,
|
||||
id: 'evt-m-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 0.5,
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: 'update-xyz',
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
sessionId: 'session-1',
|
||||
easClientId: 'client-1',
|
||||
customParams: null,
|
||||
...overrides,
|
||||
} as AppObserveEvent;
|
||||
}
|
||||
|
||||
function makeCustomEvent(overrides: Partial<AppObserveCustomEvent> = {}): AppObserveCustomEvent {
|
||||
return {
|
||||
__typename: 'AppObserveCustomEvent' as const,
|
||||
id: 'evt-c-1',
|
||||
eventName: 'login_pressed',
|
||||
timestamp: '2025-01-15T10:01:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
severityNumber: null,
|
||||
severityText: null,
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
appEasBuildId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
environment: 'production',
|
||||
easClientId: 'client-1',
|
||||
countryCode: 'US',
|
||||
properties: [],
|
||||
...overrides,
|
||||
} as AppObserveCustomEvent;
|
||||
}
|
||||
|
||||
const baseOptions = {
|
||||
limit: 100,
|
||||
};
|
||||
|
||||
describe('fetchObserveSessionEventsAsync', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards sessionId to both event sources and orders metric events oldest-first', async () => {
|
||||
await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
...baseOptions,
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
expect(mockFetchObserveEventsAsync.mock.calls[0][2].sessionId).toBe('session-1');
|
||||
expect(mockFetchObserveEventsAsync.mock.calls[0][2].orderBy).toEqual({
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
});
|
||||
expect(mockFetchObserveCustomEventsAsync.mock.calls[0][2].sessionId).toBe('session-1');
|
||||
});
|
||||
|
||||
it('returns combined entries sorted chronologically, tagged with their source', async () => {
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [makeMetricEvent({ timestamp: '2025-01-15T10:05:00.000Z' })],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [
|
||||
makeCustomEvent({ timestamp: '2025-01-15T10:01:00.000Z' }),
|
||||
makeCustomEvent({
|
||||
id: 'evt-c-2',
|
||||
timestamp: '2025-01-15T10:10:00.000Z',
|
||||
eventName: 'logout',
|
||||
}),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
...baseOptions,
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
expect(result.entries.map(e => e.timestamp)).toEqual([
|
||||
'2025-01-15T10:01:00.000Z',
|
||||
'2025-01-15T10:05:00.000Z',
|
||||
'2025-01-15T10:10:00.000Z',
|
||||
]);
|
||||
expect(result.entries.map(e => e.source)).toEqual(['log', 'metric', 'log']);
|
||||
});
|
||||
|
||||
it('derives session metadata from the entries (first/last timestamps, device, app version)', async () => {
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [
|
||||
makeMetricEvent({
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
deviceModel: 'iPhone 15',
|
||||
appUpdateId: 'update-xyz',
|
||||
countryCode: 'US',
|
||||
}),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [
|
||||
makeCustomEvent({
|
||||
timestamp: '2025-01-15T10:05:00.000Z',
|
||||
appUpdateId: 'update-xyz',
|
||||
}),
|
||||
],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
...baseOptions,
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
expect(result.metadata).toEqual({
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: 'update-xyz',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
deviceModel: 'iPhone 15',
|
||||
countryCode: 'US',
|
||||
firstSeenAt: '2025-01-15T10:00:00.000Z',
|
||||
lastSeenAt: '2025-01-15T10:05:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null metadata when no entries are found for the session', async () => {
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
...baseOptions,
|
||||
sessionId: 'unknown',
|
||||
});
|
||||
expect(result.metadata).toBeNull();
|
||||
});
|
||||
|
||||
it('reports hasMore* flags from the underlying page info', async () => {
|
||||
mockFetchObserveEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: true, hasPreviousPage: false, endCursor: 'cm' },
|
||||
});
|
||||
mockFetchObserveCustomEventsAsync.mockResolvedValue({
|
||||
events: [],
|
||||
pageInfo: { hasNextPage: false, hasPreviousPage: false },
|
||||
});
|
||||
|
||||
const result = await fetchObserveSessionEventsAsync({} as any, 'project-1', {
|
||||
...baseOptions,
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
expect(result.hasMoreMetricEvents).toBe(true);
|
||||
expect(result.hasMoreLogEvents).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -159,7 +159,6 @@ describe(buildObserveEventsJson, () => {
|
||||
easClientId: 'client-1',
|
||||
timestamp: '2025-01-15T10:30:00.000Z',
|
||||
customParams: null,
|
||||
routeName: null,
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { SessionEventEntry, SessionMetadata } from '../fetchSessions';
|
||||
import { buildObserveSessionEventsJson, buildObserveSessionEventsTable } from '../formatSessions';
|
||||
|
||||
function makeMetricEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventEntry {
|
||||
return {
|
||||
source: 'metric',
|
||||
timestamp: '2025-01-15T10:00:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
easClientId: 'client-1',
|
||||
metricName: 'expo.app_startup.tti',
|
||||
metricValue: 0.8,
|
||||
customParams: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeLogEntry(overrides: Partial<SessionEventEntry> = {}): SessionEventEntry {
|
||||
return {
|
||||
source: 'log',
|
||||
timestamp: '2025-01-15T10:01:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
deviceModel: 'iPhone 15',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
countryCode: 'US',
|
||||
easClientId: 'client-1',
|
||||
eventName: 'login_pressed',
|
||||
severityText: null,
|
||||
severityNumber: null,
|
||||
properties: [],
|
||||
environment: 'production',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe(buildObserveSessionEventsTable, () => {
|
||||
it('renders a chronological table with display name for metric rows and event name for log rows', () => {
|
||||
const entries: SessionEventEntry[] = [
|
||||
makeMetricEntry(),
|
||||
makeLogEntry({ timestamp: '2025-01-15T10:02:00.000Z' }),
|
||||
];
|
||||
|
||||
const output = buildObserveSessionEventsTable(entries);
|
||||
|
||||
expect(output).not.toContain('Session session-1');
|
||||
expect(output).not.toContain('for the last');
|
||||
expect(output).toContain('TTI');
|
||||
expect(output).toContain('0.80s');
|
||||
expect(output).toContain('login_pressed');
|
||||
expect(output).toContain('metric');
|
||||
expect(output).toContain('log');
|
||||
});
|
||||
|
||||
it('appends the routeName to navigation metric rows', () => {
|
||||
const entries = [
|
||||
makeMetricEntry({
|
||||
metricName: 'expo.navigation.tti',
|
||||
metricValue: 0.32,
|
||||
routeName: '/home',
|
||||
}),
|
||||
];
|
||||
const output = buildObserveSessionEventsTable(entries);
|
||||
expect(output).toContain('Nav TTI · /home');
|
||||
});
|
||||
|
||||
it('shows event times as offsets in seconds from the first event', () => {
|
||||
const entries: SessionEventEntry[] = [
|
||||
makeMetricEntry({ timestamp: '2025-01-15T10:00:00.000Z' }),
|
||||
makeLogEntry({ timestamp: '2025-01-15T10:00:01.234Z' }),
|
||||
makeLogEntry({ timestamp: '2025-01-15T10:00:12.500Z' }),
|
||||
];
|
||||
|
||||
const output = buildObserveSessionEventsTable(entries);
|
||||
|
||||
expect(output).toContain('Offset');
|
||||
expect(output).toContain('0.00s');
|
||||
expect(output).toContain('1.23s');
|
||||
expect(output).toContain('12.50s');
|
||||
// The absolute-time column header is gone now.
|
||||
expect(output).not.toContain('Timestamp');
|
||||
});
|
||||
|
||||
it('renders separate Value, Properties, and Severity columns', () => {
|
||||
const output = buildObserveSessionEventsTable([makeLogEntry()]);
|
||||
const headerLine = output.split('\n').find(line => line.includes('Offset'));
|
||||
expect(headerLine).toContain('Value');
|
||||
expect(headerLine).toContain('Properties');
|
||||
expect(headerLine).toContain('Severity');
|
||||
expect(headerLine).not.toContain('Value / Severity');
|
||||
});
|
||||
|
||||
it('shows severity text in the Severity column for log entries', () => {
|
||||
const entries = [makeLogEntry({ severityText: 'WARN' })];
|
||||
const output = buildObserveSessionEventsTable(entries);
|
||||
expect(output).toContain('WARN');
|
||||
});
|
||||
|
||||
it('falls back to severity number in the Severity column when only a number is set', () => {
|
||||
const entries = [makeLogEntry({ severityNumber: 13 })];
|
||||
const output = buildObserveSessionEventsTable(entries);
|
||||
expect(output).toContain('13');
|
||||
});
|
||||
|
||||
it('shows primitive properties in the Properties column, one row per property', () => {
|
||||
const entries = [
|
||||
makeLogEntry({
|
||||
properties: [
|
||||
{ key: 'user_id', value: 'abc', type: 'STRING' },
|
||||
{ key: 'is_premium', value: 'true', type: 'BOOLEAN' },
|
||||
{ key: 'level', value: '7', type: 'NUMBER' },
|
||||
],
|
||||
}),
|
||||
];
|
||||
const output = buildObserveSessionEventsTable(entries);
|
||||
expect(output).toContain('user_id=abc');
|
||||
expect(output).toContain('is_premium=true');
|
||||
expect(output).toContain('level=7');
|
||||
});
|
||||
|
||||
it('omits non-primitive (JSON) properties from the Properties column', () => {
|
||||
const entries = [
|
||||
makeLogEntry({
|
||||
properties: [
|
||||
{ key: 'user_id', value: 'abc', type: 'STRING' },
|
||||
{ key: 'context', value: '{"foo":"bar"}', type: 'JSON' },
|
||||
],
|
||||
}),
|
||||
];
|
||||
const output = buildObserveSessionEventsTable(entries);
|
||||
expect(output).toContain('user_id=abc');
|
||||
expect(output).not.toContain('context=');
|
||||
});
|
||||
|
||||
it('leaves the Value column as "-" for log entries (metric rows keep their value)', () => {
|
||||
const entries = [
|
||||
makeMetricEntry({ metricValue: 0.8 }),
|
||||
makeLogEntry({
|
||||
properties: [{ key: 'user_id', value: 'abc', type: 'STRING' }],
|
||||
}),
|
||||
];
|
||||
const output = buildObserveSessionEventsTable(entries);
|
||||
expect(output).toContain('0.80s');
|
||||
// The log row's Value cell is '-'; the property goes into Properties.
|
||||
expect(output).toContain('user_id=abc');
|
||||
});
|
||||
|
||||
it('shows "-" in the Properties column when a log entry has no primitive properties', () => {
|
||||
const entries = [
|
||||
makeLogEntry({
|
||||
properties: [{ key: 'context', value: '{}', type: 'JSON' }],
|
||||
}),
|
||||
];
|
||||
const output = buildObserveSessionEventsTable(entries);
|
||||
const dataLines = output.split('\n').filter(line => line.includes('log'));
|
||||
expect(dataLines.some(line => line.includes('-'))).toBe(true);
|
||||
});
|
||||
|
||||
it('shows an empty-state message when entries are empty', () => {
|
||||
const output = buildObserveSessionEventsTable([]);
|
||||
expect(output).toContain('No events found for this session.');
|
||||
});
|
||||
|
||||
it('warns when more events are available from either source', () => {
|
||||
const output = buildObserveSessionEventsTable([makeMetricEntry()], {
|
||||
hasMoreMetricEvents: true,
|
||||
hasMoreLogEvents: true,
|
||||
});
|
||||
expect(output).toContain('More metric events and log events are available');
|
||||
expect(output).toContain('only the first 100 of each are shown');
|
||||
});
|
||||
|
||||
it('renders metadata (app version, device, first/last seen) in the header when provided', () => {
|
||||
const metadata: SessionMetadata = {
|
||||
appVersion: '1.2.0',
|
||||
appBuildNumber: '99',
|
||||
appUpdateId: 'update-xyz',
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
deviceModel: 'iPhone 15',
|
||||
countryCode: 'US',
|
||||
firstSeenAt: '2025-01-15T10:00:00.000Z',
|
||||
lastSeenAt: '2025-01-15T10:05:00.000Z',
|
||||
};
|
||||
const output = buildObserveSessionEventsTable([makeMetricEntry()], { metadata });
|
||||
|
||||
expect(output).toContain('App version: 1.2.0 (99)');
|
||||
expect(output).toContain('Device:');
|
||||
expect(output).toContain('iPhone 15');
|
||||
expect(output).toContain('iOS 17.0');
|
||||
expect(output).toContain('First seen:');
|
||||
expect(output).toContain('Last seen:');
|
||||
});
|
||||
|
||||
it('omits the metadata block when metadata is null', () => {
|
||||
const output = buildObserveSessionEventsTable([makeMetricEntry()], {
|
||||
metadata: null,
|
||||
});
|
||||
expect(output).not.toContain('App version:');
|
||||
expect(output).not.toContain('Device:');
|
||||
expect(output).not.toContain('First seen:');
|
||||
});
|
||||
});
|
||||
|
||||
describe(buildObserveSessionEventsJson, () => {
|
||||
it('returns the entries, sessionId, metadata, and hasMore flags', () => {
|
||||
const entries = [makeMetricEntry()];
|
||||
const metadata: SessionMetadata = {
|
||||
appVersion: '1.0.0',
|
||||
appBuildNumber: '42',
|
||||
appUpdateId: null,
|
||||
deviceOs: 'iOS',
|
||||
deviceOsVersion: '17.0',
|
||||
deviceModel: 'iPhone 15',
|
||||
countryCode: 'US',
|
||||
firstSeenAt: '2025-01-15T10:00:00.000Z',
|
||||
lastSeenAt: '2025-01-15T10:05:00.000Z',
|
||||
};
|
||||
const result = buildObserveSessionEventsJson(entries, 'session-1', metadata, false, true);
|
||||
expect(result).toEqual({
|
||||
sessionId: 'session-1',
|
||||
metadata,
|
||||
entries,
|
||||
hasMoreMetricEvents: false,
|
||||
hasMoreLogEvents: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null metadata when there were no entries', () => {
|
||||
const result = buildObserveSessionEventsJson([], 'session-1', null, false, false);
|
||||
expect(result.metadata).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,8 @@ interface FetchCustomEventsOptions {
|
||||
eventName?: string;
|
||||
limit: number;
|
||||
after?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platform?: AppObservePlatform;
|
||||
appVersion?: string;
|
||||
updateId?: string;
|
||||
@@ -30,8 +30,8 @@ export async function fetchObserveCustomEventsAsync(
|
||||
options: FetchCustomEventsOptions
|
||||
): Promise<FetchCustomEventsResult> {
|
||||
const filter: AppObserveCustomEventListFilter = {
|
||||
...(options.startTime && { startTime: options.startTime }),
|
||||
...(options.endTime && { endTime: options.endTime }),
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
...(options.eventName && { eventName: options.eventName }),
|
||||
...(options.platform && { platform: options.platform }),
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
|
||||
@@ -45,16 +45,15 @@ export function resolveOrderBy(input: string): AppObserveEventsOrderBy {
|
||||
}
|
||||
|
||||
interface FetchObserveEventsOptions {
|
||||
metricName?: string;
|
||||
metricName: string;
|
||||
orderBy: AppObserveEventsOrderBy;
|
||||
limit: number;
|
||||
after?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
platform?: AppObservePlatform;
|
||||
appVersion?: string;
|
||||
updateId?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
interface FetchObserveEventsResult {
|
||||
@@ -68,13 +67,12 @@ export async function fetchObserveEventsAsync(
|
||||
options: FetchObserveEventsOptions
|
||||
): Promise<FetchObserveEventsResult> {
|
||||
const filter: AppObserveEventsFilter = {
|
||||
...(options.startTime && { startTime: options.startTime }),
|
||||
...(options.endTime && { endTime: options.endTime }),
|
||||
...(options.metricName && { metricName: options.metricName }),
|
||||
metricName: options.metricName,
|
||||
startTime: options.startTime,
|
||||
endTime: options.endTime,
|
||||
...(options.platform && { platform: options.platform }),
|
||||
...(options.appVersion && { appVersion: options.appVersion }),
|
||||
...(options.updateId && { appUpdateId: options.updateId }),
|
||||
...(options.sessionId && { sessionId: options.sessionId }),
|
||||
};
|
||||
|
||||
return await ObserveQuery.eventsAsync(graphqlClient, {
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
AppObserveCustomEvent,
|
||||
AppObserveEvent,
|
||||
AppObserveEventsOrderByDirection,
|
||||
AppObserveEventsOrderByField,
|
||||
} from '../graphql/generated';
|
||||
import { fetchObserveCustomEventsAsync } from './fetchCustomEvents';
|
||||
import { fetchObserveEventsAsync } from './fetchEvents';
|
||||
|
||||
export interface SessionEventEntry {
|
||||
source: 'metric' | 'log';
|
||||
timestamp: string;
|
||||
sessionId: string;
|
||||
appVersion: string;
|
||||
appBuildNumber: string;
|
||||
appUpdateId: string | null;
|
||||
deviceModel: string;
|
||||
deviceOs: string;
|
||||
deviceOsVersion: string;
|
||||
countryCode: string | null;
|
||||
easClientId: string;
|
||||
// metric-only fields
|
||||
metricName?: string;
|
||||
metricValue?: number;
|
||||
customParams?: { [key: string]: any } | null;
|
||||
routeName?: string | null;
|
||||
// log-only fields
|
||||
eventName?: string;
|
||||
severityText?: string | null;
|
||||
severityNumber?: number | null;
|
||||
properties?: Array<{ key: string; value: string; type: string }>;
|
||||
environment?: string | null;
|
||||
}
|
||||
|
||||
function metricEventToEntry(event: AppObserveEvent): SessionEventEntry {
|
||||
return {
|
||||
source: 'metric',
|
||||
timestamp: event.timestamp,
|
||||
sessionId: event.sessionId ?? '',
|
||||
appVersion: event.appVersion,
|
||||
appBuildNumber: event.appBuildNumber,
|
||||
appUpdateId: event.appUpdateId ?? null,
|
||||
deviceModel: event.deviceModel,
|
||||
deviceOs: event.deviceOs,
|
||||
deviceOsVersion: event.deviceOsVersion,
|
||||
countryCode: event.countryCode ?? null,
|
||||
easClientId: event.easClientId,
|
||||
metricName: event.metricName,
|
||||
metricValue: event.metricValue,
|
||||
customParams: event.customParams ?? null,
|
||||
routeName: event.routeName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function customEventToEntry(event: AppObserveCustomEvent): SessionEventEntry {
|
||||
return {
|
||||
source: 'log',
|
||||
timestamp: event.timestamp,
|
||||
sessionId: event.sessionId ?? '',
|
||||
appVersion: event.appVersion,
|
||||
appBuildNumber: event.appBuildNumber,
|
||||
appUpdateId: event.appUpdateId ?? null,
|
||||
deviceModel: event.deviceModel,
|
||||
deviceOs: event.deviceOs,
|
||||
deviceOsVersion: event.deviceOsVersion,
|
||||
countryCode: event.countryCode ?? null,
|
||||
easClientId: event.easClientId,
|
||||
eventName: event.eventName,
|
||||
severityText: event.severityText ?? null,
|
||||
severityNumber: event.severityNumber ?? null,
|
||||
properties: event.properties.map(p => ({ key: p.key, value: p.value, type: p.type })),
|
||||
environment: event.environment ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionMetadata {
|
||||
appVersion: string;
|
||||
appBuildNumber: string;
|
||||
appUpdateId: string | null;
|
||||
deviceOs: string;
|
||||
deviceOsVersion: string;
|
||||
deviceModel: string;
|
||||
countryCode: string | null;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
}
|
||||
|
||||
export interface FetchSessionEventsOptions {
|
||||
sessionId: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface FetchSessionEventsResult {
|
||||
entries: SessionEventEntry[];
|
||||
metadata: SessionMetadata | null;
|
||||
hasMoreMetricEvents: boolean;
|
||||
hasMoreLogEvents: boolean;
|
||||
}
|
||||
|
||||
export async function fetchObserveSessionEventsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
options: FetchSessionEventsOptions
|
||||
): Promise<FetchSessionEventsResult> {
|
||||
const [metricResult, logResult] = await Promise.all([
|
||||
fetchObserveEventsAsync(graphqlClient, appId, {
|
||||
orderBy: {
|
||||
field: AppObserveEventsOrderByField.Timestamp,
|
||||
direction: AppObserveEventsOrderByDirection.Asc,
|
||||
},
|
||||
limit: options.limit,
|
||||
sessionId: options.sessionId,
|
||||
}),
|
||||
fetchObserveCustomEventsAsync(graphqlClient, appId, {
|
||||
limit: options.limit,
|
||||
sessionId: options.sessionId,
|
||||
}),
|
||||
]);
|
||||
|
||||
const entries = [
|
||||
...metricResult.events.map(metricEventToEntry),
|
||||
...logResult.events.map(customEventToEntry),
|
||||
].sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0));
|
||||
|
||||
let metadata: SessionMetadata | null = null;
|
||||
if (entries.length > 0) {
|
||||
const newest = entries[entries.length - 1];
|
||||
metadata = {
|
||||
appVersion: newest.appVersion,
|
||||
appBuildNumber: newest.appBuildNumber,
|
||||
appUpdateId: newest.appUpdateId,
|
||||
deviceOs: newest.deviceOs,
|
||||
deviceOsVersion: newest.deviceOsVersion,
|
||||
deviceModel: newest.deviceModel,
|
||||
countryCode: newest.countryCode,
|
||||
firstSeenAt: entries[0].timestamp,
|
||||
lastSeenAt: newest.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
metadata,
|
||||
hasMoreMetricEvents: metricResult.pageInfo.hasNextPage,
|
||||
hasMoreLogEvents: logResult.pageInfo.hasNextPage,
|
||||
};
|
||||
}
|
||||
@@ -20,7 +20,6 @@ export interface ObserveEventJson {
|
||||
easClientId: string;
|
||||
timestamp: string;
|
||||
customParams: { [key: string]: any } | null;
|
||||
routeName: string | null;
|
||||
}
|
||||
|
||||
function resolveCustomParams(event: AppObserveEvent): { [key: string]: any } | null {
|
||||
@@ -107,7 +106,6 @@ export function buildObserveEventsJson(
|
||||
easClientId: event.easClientId,
|
||||
timestamp: event.timestamp,
|
||||
customParams: resolveCustomParams(event),
|
||||
routeName: event.routeName ?? null,
|
||||
})),
|
||||
pageInfo: {
|
||||
hasNextPage: pageInfo.hasNextPage,
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { SessionEventEntry, SessionMetadata } from './fetchSessions';
|
||||
import { formatLogTimestamp } from './formatUtils';
|
||||
import { getMetricDisplayName } from './metricNames';
|
||||
import renderTextTable from '../utils/renderTextTable';
|
||||
|
||||
export interface BuildSessionEventsOptions {
|
||||
metadata?: SessionMetadata | null;
|
||||
hasMoreMetricEvents?: boolean;
|
||||
hasMoreLogEvents?: boolean;
|
||||
}
|
||||
|
||||
function formatEntryName(entry: SessionEventEntry): string {
|
||||
if (entry.source === 'metric' && entry.metricName) {
|
||||
const name = getMetricDisplayName(entry.metricName);
|
||||
return entry.routeName ? `${name} · ${entry.routeName}` : name;
|
||||
}
|
||||
return entry.eventName ?? '-';
|
||||
}
|
||||
|
||||
function formatMetricEntryValue(entry: SessionEventEntry): string {
|
||||
if (typeof entry.metricValue === 'number') {
|
||||
return `${entry.metricValue.toFixed(2)}s`;
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
function formatEntrySeverity(entry: SessionEventEntry): string {
|
||||
if (entry.source !== 'log') {
|
||||
return '-';
|
||||
}
|
||||
if (entry.severityText) {
|
||||
return entry.severityText;
|
||||
}
|
||||
if (entry.severityNumber != null) {
|
||||
return String(entry.severityNumber);
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
function primitivePropertyLines(entry: SessionEventEntry): string[] {
|
||||
if (entry.source !== 'log' || !entry.properties) {
|
||||
return [];
|
||||
}
|
||||
return entry.properties
|
||||
.filter(p => p.type === 'STRING' || p.type === 'NUMBER' || p.type === 'BOOLEAN')
|
||||
.map(p => `${p.key}=${p.value}`);
|
||||
}
|
||||
|
||||
function formatOffsetSeconds(startIso: string, currentIso: string): string {
|
||||
const offsetMs = new Date(currentIso).getTime() - new Date(startIso).getTime();
|
||||
return `${(offsetMs / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
export function buildObserveSessionEventsTable(
|
||||
entries: SessionEventEntry[],
|
||||
options?: BuildSessionEventsOptions
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (options?.metadata) {
|
||||
const { metadata } = options;
|
||||
lines.push(
|
||||
`App version: ${metadata.appVersion} (${metadata.appBuildNumber})`,
|
||||
`Device: ${metadata.deviceModel} · ${metadata.deviceOs} ${metadata.deviceOsVersion}`,
|
||||
`First seen: ${formatLogTimestamp(metadata.firstSeenAt)}`,
|
||||
`Last seen: ${formatLogTimestamp(metadata.lastSeenAt)}`,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
lines.push(chalk.yellow('No events found for this session.'));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const startIso = entries[0].timestamp;
|
||||
const headers = ['Offset', 'Type', 'Name', 'Value', 'Properties', 'Severity'];
|
||||
const rows: string[][] = [];
|
||||
for (const entry of entries) {
|
||||
const offset = formatOffsetSeconds(startIso, entry.timestamp);
|
||||
const type = entry.source === 'metric' ? 'metric' : 'log';
|
||||
const name = formatEntryName(entry);
|
||||
const severity = formatEntrySeverity(entry);
|
||||
if (entry.source === 'metric') {
|
||||
rows.push([offset, type, name, formatMetricEntryValue(entry), '-', severity]);
|
||||
continue;
|
||||
}
|
||||
const propLines = primitivePropertyLines(entry);
|
||||
if (propLines.length === 0) {
|
||||
rows.push([offset, type, name, '-', '-', severity]);
|
||||
continue;
|
||||
}
|
||||
rows.push([offset, type, name, '-', propLines[0], severity]);
|
||||
for (let i = 1; i < propLines.length; i++) {
|
||||
rows.push(['', '', '', '', propLines[i], '']);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(renderTextTable(headers, rows));
|
||||
|
||||
if (options?.hasMoreMetricEvents || options?.hasMoreLogEvents) {
|
||||
const sources: string[] = [];
|
||||
if (options.hasMoreMetricEvents) {
|
||||
sources.push('metric events');
|
||||
}
|
||||
if (options.hasMoreLogEvents) {
|
||||
sources.push('log events');
|
||||
}
|
||||
lines.push(
|
||||
'',
|
||||
chalk.yellow(
|
||||
`More ${sources.join(' and ')} are available for this session; only the first 100 of each are shown.`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export interface ObserveSessionEventsJson {
|
||||
sessionId: string;
|
||||
metadata: SessionMetadata | null;
|
||||
entries: SessionEventEntry[];
|
||||
hasMoreMetricEvents: boolean;
|
||||
hasMoreLogEvents: boolean;
|
||||
}
|
||||
|
||||
export function buildObserveSessionEventsJson(
|
||||
entries: SessionEventEntry[],
|
||||
sessionId: string,
|
||||
metadata: SessionMetadata | null,
|
||||
hasMoreMetricEvents: boolean,
|
||||
hasMoreLogEvents: boolean
|
||||
): ObserveSessionEventsJson {
|
||||
return {
|
||||
sessionId,
|
||||
metadata,
|
||||
entries,
|
||||
hasMoreMetricEvents,
|
||||
hasMoreLogEvents,
|
||||
};
|
||||
}
|
||||
@@ -31,10 +31,6 @@ export function getRemoteSessionEnvironmentVariables(
|
||||
AGENT_DEVICE_DAEMON_AUTH_TOKEN: remoteConfig.agentDeviceRemoteSessionToken,
|
||||
};
|
||||
case 'ArgentRunSessionRemoteConfig':
|
||||
return {
|
||||
ARGENT_TOOLS_URL: remoteConfig.toolsUrl,
|
||||
...(remoteConfig.toolsAuthToken ? { ARGENT_AUTH_TOKEN: remoteConfig.toolsAuthToken } : {}),
|
||||
};
|
||||
case 'ServeSimRunSessionRemoteConfig':
|
||||
return {};
|
||||
}
|
||||
@@ -74,33 +70,11 @@ export function formatRemoteSessionInstructions(
|
||||
return lines.join('\n');
|
||||
}
|
||||
case 'ArgentRunSessionRemoteConfig': {
|
||||
const environmentVariables = getRemoteSessionEnvironmentVariables(remoteConfig);
|
||||
const lines =
|
||||
configType === 'dotenv'
|
||||
? [
|
||||
'🔑 Run the following to link your local Argent client to this simulator session:',
|
||||
'',
|
||||
[
|
||||
'argent',
|
||||
'link',
|
||||
`'${remoteConfig.toolsUrl}'`,
|
||||
remoteConfig.toolsAuthToken
|
||||
? `--token '${remoteConfig.toolsAuthToken}'`
|
||||
: undefined,
|
||||
'--yes',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
'',
|
||||
'Restart your editor after linking so its Argent MCP process uses the remote session.',
|
||||
]
|
||||
: [
|
||||
'🔑 Run the following in your shell to attach Argent to this simulator session:',
|
||||
'',
|
||||
...Object.entries(environmentVariables).map(
|
||||
([key, value]) => `export ${key}='${value}'`
|
||||
),
|
||||
];
|
||||
const lines = [
|
||||
'🔑 Open the following URL to access the Argent tools for this session:',
|
||||
'',
|
||||
remoteConfig.toolsUrl,
|
||||
];
|
||||
if (remoteConfig.webPreviewUrl) {
|
||||
lines.push(
|
||||
'',
|
||||
|
||||
+17
-61
@@ -1,8 +1,5 @@
|
||||
import { instance, mock } from 'ts-mockito';
|
||||
|
||||
import { ensureBranchExistsAsync } from '../../branch/queries';
|
||||
import { ChannelNotFoundError } from '../../channel/errors';
|
||||
import { createChannelOnAppAsync } from '../../channel/queries';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
App,
|
||||
@@ -17,8 +14,6 @@ import { getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync } from '../get
|
||||
|
||||
jest.mock('../../graphql/queries/ChannelQuery');
|
||||
jest.mock('../../graphql/queries/BranchQuery');
|
||||
jest.mock('../../branch/queries');
|
||||
jest.mock('../../channel/queries');
|
||||
|
||||
describe(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync, () => {
|
||||
beforeEach(() => {
|
||||
@@ -45,7 +40,7 @@ describe(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync, () => {
|
||||
expect(result.branchName).toBe('test-branch-name');
|
||||
});
|
||||
|
||||
test('creates and links a branch when channel exists but has no branches (e.g. auto-created by a build)', async () => {
|
||||
test('errors when no branch is connected to channel', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelAsync).mockImplementationOnce(
|
||||
async () =>
|
||||
@@ -53,29 +48,16 @@ describe(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync, () => {
|
||||
channelName: 'test-channel-name',
|
||||
}) as any
|
||||
);
|
||||
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
|
||||
branch: { __typename: 'UpdateBranch', id: 'test-branch-id', name: 'test-channel-name' },
|
||||
createdBranch: true,
|
||||
});
|
||||
jest.mocked(createChannelOnAppAsync).mockResolvedValue({
|
||||
updateChannel: {
|
||||
createUpdateChannelForApp: {
|
||||
id: 'test-channel-id',
|
||||
name: 'test-channel-name',
|
||||
branchMapping:
|
||||
'{"data":[{"branchId":"test-branch-id","branchMappingLogic":"true"}],"version":0}',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
|
||||
graphqlClient,
|
||||
'test-project-id',
|
||||
'test-channel-name'
|
||||
await expect(
|
||||
getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
|
||||
graphqlClient,
|
||||
'test-project-id',
|
||||
'test-channel-name'
|
||||
)
|
||||
).rejects.toThrow(
|
||||
"Channel has no branches associated with it. Run 'eas channel:edit' to map a branch"
|
||||
);
|
||||
|
||||
expect(result.branchId).toBe('test-branch-id');
|
||||
expect(result.branchName).toBe('test-channel-name');
|
||||
});
|
||||
|
||||
test('errors when more than one branch is connected to channel', async () => {
|
||||
@@ -99,40 +81,15 @@ describe(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync, () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('rethrows unexpected errors from the channel lookup', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelAsync).mockImplementationOnce(async () => {
|
||||
throw new Error('Network request failed');
|
||||
});
|
||||
|
||||
await expect(
|
||||
getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
|
||||
graphqlClient,
|
||||
'test-project-id',
|
||||
'test-channel-name'
|
||||
)
|
||||
).rejects.toThrow('Network request failed');
|
||||
});
|
||||
|
||||
test('creates channel and branch when channel does not exist, and returns branch name', async () => {
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelAsync).mockImplementationOnce(async () => {
|
||||
throw new ChannelNotFoundError('Could not find channel with the name test-channel-name');
|
||||
});
|
||||
jest.mocked(ensureBranchExistsAsync).mockResolvedValue({
|
||||
branch: { __typename: 'UpdateBranch', id: 'test-branch-id', name: 'test-channel-name' },
|
||||
createdBranch: true,
|
||||
});
|
||||
jest.mocked(createChannelOnAppAsync).mockResolvedValue({
|
||||
updateChannel: {
|
||||
createUpdateChannelForApp: {
|
||||
id: 'test-channel-id',
|
||||
name: 'test-channel-name',
|
||||
branchMapping:
|
||||
'{"data":[{"branchId":"test-branch-id","branchMappingLogic":"true"}],"version":0}',
|
||||
},
|
||||
},
|
||||
});
|
||||
jest.mocked(ChannelQuery.viewUpdateChannelAsync).mockImplementationOnce(
|
||||
async () =>
|
||||
mockUpdateChannel({
|
||||
channelName: 'test-channel-name',
|
||||
branchNames: ['test-branch-name'],
|
||||
}) as any
|
||||
);
|
||||
|
||||
const result = await getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
|
||||
graphqlClient,
|
||||
@@ -140,8 +97,7 @@ describe(getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync, () => {
|
||||
'test-channel-name'
|
||||
);
|
||||
|
||||
expect(result.branchId).toBe('test-branch-id');
|
||||
expect(result.branchName).toBe('test-channel-name');
|
||||
expect(result.branchName).toBe('test-branch-name');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+37
-33
@@ -9,47 +9,51 @@ export async function getBranchFromChannelNameAndCreateAndLinkIfNotExistsAsync(
|
||||
projectId: string,
|
||||
channelName: string
|
||||
): Promise<{ branchName: string; branchId: string }> {
|
||||
let channel;
|
||||
let branchInfo: { branchName: string; branchId: string };
|
||||
|
||||
try {
|
||||
channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
const channel = await ChannelQuery.viewUpdateChannelAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName,
|
||||
});
|
||||
|
||||
if (channel.updateBranches.length === 1) {
|
||||
const branch = channel.updateBranches[0];
|
||||
branchInfo = { branchId: branch.id, branchName: branch.name };
|
||||
} else if (channel.updateBranches.length === 0) {
|
||||
throw new Error(
|
||||
"Channel has no branches associated with it. Run 'eas channel:edit' to map a branch"
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Channel has multiple branches associated with it. Instead, use '--branch' instead of '--channel'`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof ChannelNotFoundError)) {
|
||||
throw error;
|
||||
}
|
||||
return await createAndLinkBranchToChannelAsync(graphqlClient, projectId, channelName);
|
||||
|
||||
const { branch } = await ensureBranchExistsAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
branchName: channelName,
|
||||
});
|
||||
const {
|
||||
updateChannel: { createUpdateChannelForApp: newChannel },
|
||||
} = await createChannelOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName,
|
||||
branchId: branch.id,
|
||||
});
|
||||
|
||||
if (!newChannel) {
|
||||
throw new Error(
|
||||
`Could not create channel with name ${channelName} on project with id ${projectId}`
|
||||
);
|
||||
}
|
||||
|
||||
branchInfo = { branchId: branch.id, branchName: channelName };
|
||||
}
|
||||
|
||||
if (channel.updateBranches.length === 1) {
|
||||
const branch = channel.updateBranches[0];
|
||||
return { branchId: branch.id, branchName: branch.name };
|
||||
}
|
||||
|
||||
if (channel.updateBranches.length === 0) {
|
||||
return await createAndLinkBranchToChannelAsync(graphqlClient, projectId, channelName);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Channel has multiple branches associated with it. Instead, use '--branch' instead of '--channel'`
|
||||
);
|
||||
}
|
||||
|
||||
async function createAndLinkBranchToChannelAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
channelName: string
|
||||
): Promise<{ branchName: string; branchId: string }> {
|
||||
const { branch } = await ensureBranchExistsAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
branchName: channelName,
|
||||
});
|
||||
await createChannelOnAppAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
channelName,
|
||||
branchId: branch.id,
|
||||
});
|
||||
|
||||
return { branchId: branch.id, branchName: channelName };
|
||||
return branchInfo;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import JsonFile from '@expo/json-file';
|
||||
import { Errors } from '@oclif/core';
|
||||
import assert from 'assert';
|
||||
import chalk from 'chalk';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
@@ -12,7 +13,7 @@ import { createGraphqlClient } from '../commandUtils/context/contextUtils/create
|
||||
import { CurrentUserQuery } from '../graphql/generated';
|
||||
import { UserQuery } from '../graphql/queries/UserQuery';
|
||||
import Log, { learnMore } from '../log';
|
||||
import { promptAsync } from '../prompts';
|
||||
import { promptAsync, selectAsync } from '../prompts';
|
||||
import { getStateJsonPath } from '../utils/paths';
|
||||
|
||||
type UserSettingsData = {
|
||||
@@ -28,6 +29,18 @@ type SessionData = {
|
||||
currentConnection: 'Username-Password-Authentication' | 'Browser-Flow-Authentication';
|
||||
};
|
||||
|
||||
export enum UserSecondFactorDeviceMethod {
|
||||
AUTHENTICATOR = 'authenticator',
|
||||
SMS = 'sms',
|
||||
}
|
||||
|
||||
type SecondFactorDevice = {
|
||||
id: string;
|
||||
method: UserSecondFactorDeviceMethod;
|
||||
sms_phone_number: string | null;
|
||||
is_primary: boolean;
|
||||
};
|
||||
|
||||
export type LoggedInAuthenticationInfo =
|
||||
| {
|
||||
accessToken: string;
|
||||
@@ -192,7 +205,11 @@ export default class SessionManager {
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof ApiV2Error && e.expoApiV2ErrorCode === 'ONE_TIME_PASSWORD_REQUIRED') {
|
||||
await this.retryUsernamePasswordAuthWithOTPAsync(username, password);
|
||||
await this.retryUsernamePasswordAuthWithOTPAsync(
|
||||
username,
|
||||
password,
|
||||
e.expoApiV2ErrorMetadata as any
|
||||
);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
@@ -228,8 +245,11 @@ export default class SessionManager {
|
||||
/**
|
||||
* Prompt for an OTP with the option to cancel the question by answering empty (pressing return key).
|
||||
*/
|
||||
private async promptForOTPAsync(): Promise<string | null> {
|
||||
const enterMessage = `press ${chalk.bold('Enter')} to cancel`;
|
||||
private async promptForOTPAsync(cancelBehavior: 'cancel' | 'menu'): Promise<string | null> {
|
||||
const enterMessage =
|
||||
cancelBehavior === 'cancel'
|
||||
? `press ${chalk.bold('Enter')} to cancel`
|
||||
: `press ${chalk.bold('Enter')} for more options`;
|
||||
const { otp } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'otp',
|
||||
@@ -243,14 +263,122 @@ export default class SessionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the special case error indicating that a second-factor is required for authentication.
|
||||
* Prompt for user to choose a backup OTP method. If selected method is SMS, a request
|
||||
* for a new OTP will be sent to that method. Then, prompt for the OTP, and retry the user login.
|
||||
*/
|
||||
private async promptForBackupOTPAsync(
|
||||
username: string,
|
||||
password: string,
|
||||
secondFactorDevices: SecondFactorDevice[]
|
||||
): Promise<string | null> {
|
||||
const nonPrimarySecondFactorDevices = secondFactorDevices.filter(device => !device.is_primary);
|
||||
|
||||
if (nonPrimarySecondFactorDevices.length === 0) {
|
||||
throw new Error(
|
||||
'No other second-factor devices set up. Ensure you have set up and certified a backup device.'
|
||||
);
|
||||
}
|
||||
|
||||
const hasAuthenticatorSecondFactorDevice = nonPrimarySecondFactorDevices.find(
|
||||
device => device.method === UserSecondFactorDeviceMethod.AUTHENTICATOR
|
||||
);
|
||||
|
||||
const smsNonPrimarySecondFactorDevices = nonPrimarySecondFactorDevices.filter(
|
||||
device => device.method === UserSecondFactorDeviceMethod.SMS
|
||||
);
|
||||
|
||||
const authenticatorChoiceSentinel = -1;
|
||||
const cancelChoiceSentinel = -2;
|
||||
|
||||
const deviceChoices = smsNonPrimarySecondFactorDevices.map((device, idx) => ({
|
||||
title: device.sms_phone_number!,
|
||||
value: idx,
|
||||
}));
|
||||
|
||||
if (hasAuthenticatorSecondFactorDevice) {
|
||||
deviceChoices.push({
|
||||
title: 'Authenticator',
|
||||
value: authenticatorChoiceSentinel,
|
||||
});
|
||||
}
|
||||
|
||||
deviceChoices.push({
|
||||
title: 'Cancel',
|
||||
value: cancelChoiceSentinel,
|
||||
});
|
||||
|
||||
const selectedValue = await selectAsync('Select a second-factor device:', deviceChoices);
|
||||
if (selectedValue === cancelChoiceSentinel) {
|
||||
return null;
|
||||
} else if (selectedValue === authenticatorChoiceSentinel) {
|
||||
return await this.promptForOTPAsync('cancel');
|
||||
}
|
||||
|
||||
const device = smsNonPrimarySecondFactorDevices[selectedValue];
|
||||
|
||||
// this is a logged-out endpoint
|
||||
const apiV2Client = new ApiV2Client({ accessToken: null, sessionSecret: null });
|
||||
await apiV2Client.postAsync('auth/send-sms-otp', {
|
||||
body: {
|
||||
username,
|
||||
password,
|
||||
secondFactorDeviceID: device.id,
|
||||
},
|
||||
});
|
||||
|
||||
return await this.promptForOTPAsync('cancel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the special case error indicating that a second-factor is required for
|
||||
* authentication.
|
||||
*
|
||||
* There are three cases we need to handle:
|
||||
* 1. User's primary second-factor device was SMS, OTP was automatically sent by the server to that
|
||||
* device already. In this case we should just prompt for the SMS OTP (or backup code), which the
|
||||
* user should be receiving shortly. We should give the user a way to cancel and the prompt and move
|
||||
* to case 3 below.
|
||||
* 2. User's primary second-factor device is authenticator. In this case we should prompt for authenticator
|
||||
* OTP (or backup code) and also give the user a way to cancel and move to case 3 below.
|
||||
* 3. User doesn't have a primary device or doesn't have access to their primary device. In this case
|
||||
* we should show a picker of the SMS devices that they can have an OTP code sent to, and when
|
||||
* the user picks one we show a prompt() for the sent OTP.
|
||||
*/
|
||||
private async retryUsernamePasswordAuthWithOTPAsync(
|
||||
username: string,
|
||||
password: string
|
||||
password: string,
|
||||
metadata: {
|
||||
secondFactorDevices?: SecondFactorDevice[];
|
||||
smsAutomaticallySent?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
Log.log('One-time password from authenticator required.');
|
||||
const otp = await this.promptForOTPAsync();
|
||||
const { secondFactorDevices, smsAutomaticallySent } = metadata;
|
||||
assert(
|
||||
secondFactorDevices !== undefined && smsAutomaticallySent !== undefined,
|
||||
`Malformed OTP error metadata: ${metadata}`
|
||||
);
|
||||
|
||||
const primaryDevice = secondFactorDevices.find(device => device.is_primary);
|
||||
let otp: string | null = null;
|
||||
|
||||
if (smsAutomaticallySent) {
|
||||
assert(primaryDevice, 'OTP should only automatically be sent when there is a primary device');
|
||||
Log.log(
|
||||
`One-time password was sent to the phone number ending in ${primaryDevice.sms_phone_number}.`
|
||||
);
|
||||
otp = await this.promptForOTPAsync('menu');
|
||||
}
|
||||
|
||||
if (primaryDevice?.method === UserSecondFactorDeviceMethod.AUTHENTICATOR) {
|
||||
Log.log('One-time password from authenticator required.');
|
||||
otp = await this.promptForOTPAsync('menu');
|
||||
}
|
||||
|
||||
// user bailed on case 1 or 2, wants to move to case 3
|
||||
if (!otp) {
|
||||
otp = await this.promptForBackupOTPAsync(username, password, secondFactorDevices);
|
||||
}
|
||||
|
||||
if (!otp) {
|
||||
throw new Error('Cancelled login');
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import { ApiV2Error } from '../../ApiV2Error';
|
||||
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
|
||||
import { ApiV2Client } from '../../api';
|
||||
import Log from '../../log';
|
||||
import { promptAsync } from '../../prompts';
|
||||
import { promptAsync, selectAsync } from '../../prompts';
|
||||
import { getStateJsonPath } from '../../utils/paths';
|
||||
import SessionManager from '../SessionManager';
|
||||
import SessionManager, { UserSecondFactorDeviceMethod } from '../SessionManager';
|
||||
import { fetchSessionSecretAndUserAsync } from '../fetchSessionSecretAndUser';
|
||||
import { fetchSessionSecretAndUserFromBrowserAuthFlowAsync } from '../fetchSessionSecretAndUserFromBrowserAuthFlow';
|
||||
|
||||
@@ -275,6 +275,17 @@ describe(SessionManager, () => {
|
||||
throw new ApiV2Error({
|
||||
message: 'An OTP is required',
|
||||
code: 'ONE_TIME_PASSWORD_REQUIRED',
|
||||
metadata: {
|
||||
secondFactorDevices: [
|
||||
{
|
||||
id: 'p0',
|
||||
is_primary: true,
|
||||
method: UserSecondFactorDeviceMethod.SMS,
|
||||
sms_phone_number: 'testphone',
|
||||
},
|
||||
],
|
||||
smsAutomaticallySent: true,
|
||||
},
|
||||
});
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
@@ -287,7 +298,18 @@ describe(SessionManager, () => {
|
||||
|
||||
expect(sessionManagerRetryUsernamePasswordAuthWithOTPAsyncSpy).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
'world'
|
||||
'world',
|
||||
{
|
||||
secondFactorDevices: [
|
||||
{
|
||||
id: 'p0',
|
||||
is_primary: true,
|
||||
method: UserSecondFactorDeviceMethod.SMS,
|
||||
sms_phone_number: 'testphone',
|
||||
},
|
||||
],
|
||||
smsAutomaticallySent: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -361,7 +383,7 @@ describe(SessionManager, () => {
|
||||
});
|
||||
|
||||
describe('retryUsernamePasswordAuthWithOTPAsync', () => {
|
||||
it('shows authenticator OTP prompt', async () => {
|
||||
it('shows SMS OTP prompt when SMS is primary and code was automatically sent', async () => {
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
.mockImplementationOnce(async () => ({ otp: 'hello' }))
|
||||
@@ -371,13 +393,213 @@ describe(SessionManager, () => {
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
const sessionManagerLoginAsyncSpy = jest.spyOn(sessionManager as any, 'loginAsync');
|
||||
|
||||
await sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah');
|
||||
await sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
|
||||
secondFactorDevices: [
|
||||
{
|
||||
id: 'p0',
|
||||
is_primary: true,
|
||||
method: UserSecondFactorDeviceMethod.SMS,
|
||||
sms_phone_number: 'testphone',
|
||||
},
|
||||
],
|
||||
smsAutomaticallySent: true,
|
||||
});
|
||||
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
'One-time password was sent to the phone number ending in testphone.'
|
||||
);
|
||||
|
||||
expect(sessionManagerLoginAsyncSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows authenticator OTP prompt when authenticator is primary', async () => {
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
.mockImplementationOnce(async () => ({ otp: 'hello' }))
|
||||
.mockImplementation(() => {
|
||||
throw new Error("shouldn't happen");
|
||||
});
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
const sessionManagerLoginAsyncSpy = jest.spyOn(sessionManager as any, 'loginAsync');
|
||||
|
||||
await sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
|
||||
secondFactorDevices: [
|
||||
{
|
||||
id: 'p0',
|
||||
is_primary: true,
|
||||
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
|
||||
sms_phone_number: null,
|
||||
},
|
||||
],
|
||||
smsAutomaticallySent: false,
|
||||
});
|
||||
|
||||
expect(Log.log).toHaveBeenCalledWith('One-time password from authenticator required.');
|
||||
expect(sessionManagerLoginAsyncSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('exits when user bails', async () => {
|
||||
it('shows menu when user bails on primary', async () => {
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
.mockImplementationOnce(async () => ({ otp: null }))
|
||||
.mockImplementationOnce(async () => ({ otp: 'hello' })) // second time it is prompted after selecting backup code
|
||||
.mockImplementation(() => {
|
||||
throw new Error("shouldn't happen");
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(selectAsync)
|
||||
.mockImplementationOnce(async () => -1)
|
||||
.mockImplementation(() => {
|
||||
throw new Error("shouldn't happen");
|
||||
});
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
|
||||
const sessionManagerLoginAsyncSpy = jest.spyOn(sessionManager as any, 'loginAsync');
|
||||
|
||||
await sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
|
||||
secondFactorDevices: [
|
||||
{
|
||||
id: 'p0',
|
||||
is_primary: true,
|
||||
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
|
||||
sms_phone_number: null,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
is_primary: false,
|
||||
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
|
||||
sms_phone_number: null,
|
||||
},
|
||||
],
|
||||
smsAutomaticallySent: false,
|
||||
});
|
||||
|
||||
expect(jest.mocked(selectAsync).mock.calls.length).toEqual(1);
|
||||
expect(sessionManagerLoginAsyncSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows a warning when when user bails on primary and does not have any secondary set up', async () => {
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
.mockImplementationOnce(async () => ({ otp: null }))
|
||||
.mockImplementation(() => {
|
||||
throw new Error("shouldn't happen");
|
||||
});
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
|
||||
await expect(
|
||||
sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
|
||||
secondFactorDevices: [
|
||||
{
|
||||
id: 'p0',
|
||||
is_primary: true,
|
||||
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
|
||||
sms_phone_number: null,
|
||||
},
|
||||
],
|
||||
smsAutomaticallySent: false,
|
||||
})
|
||||
).rejects.toThrowError(
|
||||
'No other second-factor devices set up. Ensure you have set up and certified a backup device.'
|
||||
);
|
||||
});
|
||||
|
||||
it('prompts for authenticator OTP when user selects authenticator secondary', async () => {
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
.mockImplementationOnce(async () => ({ otp: null }))
|
||||
.mockImplementationOnce(async () => ({ otp: 'hello' })) // second time it is prompted after selecting backup code
|
||||
.mockImplementation(() => {
|
||||
throw new Error("shouldn't happen");
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(selectAsync)
|
||||
.mockImplementationOnce(async () => -1)
|
||||
.mockImplementation(() => {
|
||||
throw new Error("shouldn't happen");
|
||||
});
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
|
||||
await sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
|
||||
secondFactorDevices: [
|
||||
{
|
||||
id: 'p0',
|
||||
is_primary: true,
|
||||
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
|
||||
sms_phone_number: null,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
is_primary: false,
|
||||
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
|
||||
sms_phone_number: null,
|
||||
},
|
||||
],
|
||||
smsAutomaticallySent: false,
|
||||
});
|
||||
|
||||
expect(jest.mocked(promptAsync).mock.calls.length).toBe(2); // first OTP, second OTP
|
||||
});
|
||||
|
||||
it('requests SMS OTP and prompts for SMS OTP when user selects SMS secondary', async () => {
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
.mockImplementationOnce(async () => ({ otp: null }))
|
||||
.mockImplementationOnce(async () => ({ otp: 'hello' })) // second time it is prompted after selecting backup code
|
||||
.mockImplementation(() => {
|
||||
throw new Error("shouldn't happen");
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(selectAsync)
|
||||
.mockImplementationOnce(async () => 0)
|
||||
.mockImplementation(() => {
|
||||
throw new Error("shouldn't happen");
|
||||
});
|
||||
|
||||
jest.mocked(fetchSessionSecretAndUserAsync).mockResolvedValue({
|
||||
sessionSecret: 'SESSION_SECRET',
|
||||
id: 'USER_ID',
|
||||
username: 'USERNAME',
|
||||
});
|
||||
const apiV2PostSpy = jest.spyOn(ApiV2Client.prototype, 'postAsync');
|
||||
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
await sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
|
||||
secondFactorDevices: [
|
||||
{
|
||||
id: 'p0',
|
||||
is_primary: true,
|
||||
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
|
||||
sms_phone_number: null,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
is_primary: false,
|
||||
method: UserSecondFactorDeviceMethod.SMS,
|
||||
sms_phone_number: 'wat',
|
||||
},
|
||||
],
|
||||
smsAutomaticallySent: false,
|
||||
});
|
||||
|
||||
expect(jest.mocked(promptAsync).mock.calls.length).toBe(2); // first OTP, second OTP
|
||||
|
||||
expect(apiV2PostSpy.mock.calls[0]).toEqual([
|
||||
'auth/send-sms-otp',
|
||||
{
|
||||
body: {
|
||||
username: 'blah',
|
||||
password: 'blah',
|
||||
secondFactorDeviceID: 'p2',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('exits when user bails on primary and backup', async () => {
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
.mockImplementationOnce(async () => ({ otp: null }))
|
||||
@@ -385,10 +607,32 @@ describe(SessionManager, () => {
|
||||
throw new Error("shouldn't happen");
|
||||
});
|
||||
|
||||
jest
|
||||
.mocked(selectAsync)
|
||||
.mockImplementationOnce(async () => -2)
|
||||
.mockImplementation(() => {
|
||||
throw new Error("shouldn't happen");
|
||||
});
|
||||
const sessionManager = new SessionManager(analytics);
|
||||
|
||||
await expect(
|
||||
sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah')
|
||||
sessionManager['retryUsernamePasswordAuthWithOTPAsync']('blah', 'blah', {
|
||||
secondFactorDevices: [
|
||||
{
|
||||
id: 'p0',
|
||||
is_primary: true,
|
||||
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
|
||||
sms_phone_number: null,
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
is_primary: false,
|
||||
method: UserSecondFactorDeviceMethod.AUTHENTICATOR,
|
||||
sms_phone_number: null,
|
||||
},
|
||||
],
|
||||
smsAutomaticallySent: false,
|
||||
})
|
||||
).rejects.toThrowError('Cancelled login');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli-local-build-plugin",
|
||||
"version": "20.4.0",
|
||||
"version": "20.1.0",
|
||||
"description": "Tool for running EAS compatible builds on a local machine.",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
@@ -26,7 +26,7 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/build-tools": "20.4.0",
|
||||
"@expo/build-tools": "20.1.0",
|
||||
"@expo/eas-build-job": "20.1.0",
|
||||
"@expo/logger": "20.0.0",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/worker",
|
||||
"version": "20.4.0",
|
||||
"version": "20.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "7.24.7",
|
||||
"@expo/build-tools": "20.4.0",
|
||||
"@expo/build-tools": "20.1.0",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/config-types": "55.0.5",
|
||||
"@expo/downloader": "20.0.0",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
describe('config', () => {
|
||||
const originalEnvironment = process.env.ENVIRONMENT;
|
||||
const originalWorkerRuntimeConfigBase64 = process.env.WORKER_RUNTIME_CONFIG_BASE64;
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv('ENVIRONMENT', originalEnvironment);
|
||||
restoreEnv('WORKER_RUNTIME_CONFIG_BASE64', originalWorkerRuntimeConfigBase64);
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('parses cache URL fallbacks from worker runtime config', () => {
|
||||
process.env.ENVIRONMENT = 'staging';
|
||||
process.env.WORKER_RUNTIME_CONFIG_BASE64 = Buffer.from(
|
||||
JSON.stringify({
|
||||
buildId: 'build-id',
|
||||
npmCacheUrl: 'https://npm-cache.example',
|
||||
nodeJsCacheUrl: 'https://node-cache.example',
|
||||
mavenCacheUrl: 'https://maven-cache.example',
|
||||
cocoapodsCacheUrl: 'https://cocoapods-cache.example',
|
||||
})
|
||||
).toString('base64');
|
||||
|
||||
jest.isolateModules(() => {
|
||||
const config = require('../config').default;
|
||||
|
||||
expect(config.npmCacheUrl).toBe('https://npm-cache.example');
|
||||
expect(config.nodeJsCacheUrl).toBe('https://node-cache.example');
|
||||
expect(config.mavenCacheUrl).toBe('https://maven-cache.example');
|
||||
expect(config.cocoapodsCacheUrl).toBe('https://cocoapods-cache.example');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { RuntimeSettings } from '@expo/build-tools';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
|
||||
import config from '../config';
|
||||
import { createBuildContext } from '../context';
|
||||
import { getBuildEnv } from '../env';
|
||||
import { prepareRuntimeEnvironmentConfigFiles } from '../runtimeEnvironment';
|
||||
|
||||
jest.mock('../env', () => ({
|
||||
getBuildEnv: jest.fn(() => ({
|
||||
__API_SERVER_URL: 'http://api.expo.test',
|
||||
PATH: '/usr/bin',
|
||||
})),
|
||||
}));
|
||||
jest.mock('../runtimeEnvironment', () => ({
|
||||
prepareRuntimeEnvironmentConfigFiles: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
describe(createBuildContext.name, () => {
|
||||
const childLogger = {
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
} as any;
|
||||
const buildLogger = {
|
||||
child: jest.fn(() => childLogger),
|
||||
} as any;
|
||||
const baseOptions = {
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
analytics: {} as any,
|
||||
metadata: {},
|
||||
projectId: 'project-id',
|
||||
buildId: 'build-id',
|
||||
buildLogger,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(RuntimeSettings, 'loadAsync').mockResolvedValue();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('loads runtime settings with the job environment before creating build env', async () => {
|
||||
const env = { EAS_USE_NPM_CACHE: '1' };
|
||||
|
||||
await createBuildContext({
|
||||
...baseOptions,
|
||||
job: {
|
||||
platform: Platform.IOS,
|
||||
type: Workflow.GENERIC,
|
||||
secrets: { environmentSecrets: [] },
|
||||
builderEnvironment: { env },
|
||||
environment: 'preview',
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(RuntimeSettings.loadAsync).toHaveBeenCalledWith({
|
||||
environment: config.env,
|
||||
env,
|
||||
});
|
||||
expect(prepareRuntimeEnvironmentConfigFiles).toHaveBeenCalled();
|
||||
expect(jest.mocked(RuntimeSettings.loadAsync).mock.invocationCallOrder[0]).toBeLessThan(
|
||||
jest.mocked(getBuildEnv).mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('loads runtime settings without job env when the job has no builder environment', async () => {
|
||||
await createBuildContext({
|
||||
...baseOptions,
|
||||
job: {
|
||||
platform: Platform.ANDROID,
|
||||
type: Workflow.GENERIC,
|
||||
secrets: { environmentSecrets: [] },
|
||||
} as any,
|
||||
});
|
||||
|
||||
expect(RuntimeSettings.loadAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
environment: config.env,
|
||||
env: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,6 @@ describe(getBuildEnv.name, () => {
|
||||
NVM_NODEJS_ORG_MIRROR: process.env.NVM_NODEJS_ORG_MIRROR,
|
||||
EAS_BUILD_MAVEN_CACHE_URL: process.env.EAS_BUILD_MAVEN_CACHE_URL,
|
||||
EAS_BUILD_COCOAPODS_CACHE_URL: process.env.EAS_BUILD_COCOAPODS_CACHE_URL,
|
||||
NPM_CONFIG_REGISTRY: process.env.NPM_CONFIG_REGISTRY,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -27,7 +26,6 @@ describe(getBuildEnv.name, () => {
|
||||
delete process.env.NVM_NODEJS_ORG_MIRROR;
|
||||
delete process.env.EAS_BUILD_MAVEN_CACHE_URL;
|
||||
delete process.env.EAS_BUILD_COCOAPODS_CACHE_URL;
|
||||
delete process.env.NPM_CONFIG_REGISTRY;
|
||||
jest.spyOn(RuntimeSettings, 'getNpmCacheUrl').mockReturnValue(null);
|
||||
jest.spyOn(RuntimeSettings, 'getNodeJsCacheUrl').mockReturnValue(null);
|
||||
jest.spyOn(RuntimeSettings, 'getMavenCacheUrl').mockReturnValue(null);
|
||||
@@ -46,7 +44,6 @@ describe(getBuildEnv.name, () => {
|
||||
restoreEnv('NVM_NODEJS_ORG_MIRROR', originalCacheUrls.NVM_NODEJS_ORG_MIRROR);
|
||||
restoreEnv('EAS_BUILD_MAVEN_CACHE_URL', originalCacheUrls.EAS_BUILD_MAVEN_CACHE_URL);
|
||||
restoreEnv('EAS_BUILD_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_BUILD_COCOAPODS_CACHE_URL);
|
||||
restoreEnv('NPM_CONFIG_REGISTRY', originalCacheUrls.NPM_CONFIG_REGISTRY);
|
||||
mockProcessPlatform(originalPlatform);
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
@@ -199,7 +196,6 @@ describe(getBuildEnv.name, () => {
|
||||
});
|
||||
|
||||
expect(env.NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.NPM_CONFIG_REGISTRY).toBeUndefined();
|
||||
expect(env.NVM_NODEJS_ORG_MIRROR).toBeUndefined();
|
||||
expect(env.EAS_BUILD_NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_BUILD_MAVEN_CACHE_URL).toBeUndefined();
|
||||
@@ -232,7 +228,6 @@ describe(getBuildEnv.name, () => {
|
||||
});
|
||||
|
||||
expect(env.NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.NPM_CONFIG_REGISTRY).toBeUndefined();
|
||||
expect(env.NVM_NODEJS_ORG_MIRROR).toBeUndefined();
|
||||
expect(env.EAS_BUILD_NPM_CACHE_URL).toBeUndefined();
|
||||
expect(env.EAS_BUILD_COCOAPODS_CACHE_URL).toBeUndefined();
|
||||
@@ -269,7 +264,6 @@ describe(getBuildEnv.name, () => {
|
||||
});
|
||||
|
||||
expect(env.NPM_CACHE_URL).toBe('https://npm.example');
|
||||
expect(env.NPM_CONFIG_REGISTRY).toBe('https://npm.example');
|
||||
expect(env.EAS_BUILD_NPM_CACHE_URL).toBe('https://npm.example');
|
||||
expect(env.NVM_NODEJS_ORG_MIRROR).toBe('https://node.example');
|
||||
expect(env.EAS_BUILD_MAVEN_CACHE_URL).toBe('https://maven.example');
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('prepareRuntimeEnvironment', () => {
|
||||
|
||||
await prepareRuntimeEnvironmentConfigFiles();
|
||||
|
||||
expect(spawn).not.toHaveBeenCalledWith('npm', [
|
||||
expect(spawn).toHaveBeenCalledWith('npm', [
|
||||
'config',
|
||||
'set',
|
||||
'registry',
|
||||
|
||||
@@ -26,7 +26,7 @@ jest.mock('../build', () => ({
|
||||
build: jest.fn(),
|
||||
}));
|
||||
jest.mock('../context', () => ({
|
||||
createBuildContext: jest.fn(async () => ({ job: {} })),
|
||||
createBuildContext: jest.fn(() => ({ job: {} })),
|
||||
}));
|
||||
jest.mock('../config', () => {
|
||||
const actual = jest.requireActual('../config').default;
|
||||
@@ -111,7 +111,7 @@ describe('BuildService Datadog setup', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
buildMock.mockResolvedValue({});
|
||||
createBuildContextMock.mockResolvedValue({ job: {} } as any);
|
||||
createBuildContextMock.mockReturnValue({ job: {} } as any);
|
||||
createBuildLoggerWithSecretsFilterMock.mockResolvedValue({
|
||||
logger: buildLogger,
|
||||
cleanUp: jest.fn(async () => {}),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { BuildContext, GCS } from '@expo/build-tools';
|
||||
import { Job, errors } from '@expo/eas-build-job';
|
||||
import { Job } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import { vol } from 'memfs';
|
||||
import { Response } from 'node-fetch';
|
||||
|
||||
import config from '../config';
|
||||
import {
|
||||
uploadApplicationArchiveAsync,
|
||||
uploadBuildArtifactsAsync,
|
||||
@@ -58,14 +59,7 @@ describe(uploadApplicationArchiveAsync.name, () => {
|
||||
buildId: 'buildId',
|
||||
logger: ctx.logger,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: errors.ErrorCode.SERVER_ERROR,
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_APPLICATION_ARCHIVE_FAILED',
|
||||
message: 'Failed to upload application archive.',
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining('env variables are not set'),
|
||||
}),
|
||||
});
|
||||
).rejects.toThrow('env variables are not set');
|
||||
|
||||
ctx.env.__WORKFLOW_JOB_ID = randomUUID();
|
||||
|
||||
@@ -75,14 +69,7 @@ describe(uploadApplicationArchiveAsync.name, () => {
|
||||
buildId: 'buildId',
|
||||
logger: ctx.logger,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: errors.ErrorCode.SERVER_ERROR,
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_APPLICATION_ARCHIVE_FAILED',
|
||||
message: 'Failed to upload application archive.',
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining('robot access token is not set'),
|
||||
}),
|
||||
});
|
||||
).rejects.toThrow('robot access token is not set');
|
||||
});
|
||||
|
||||
it('should upload the application archive', async () => {
|
||||
@@ -164,9 +151,6 @@ describe(uploadApplicationArchiveAsync.name, () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
expect(turtleFetchMock).toHaveBeenNthCalledWith(
|
||||
@@ -178,81 +162,84 @@ describe(uploadApplicationArchiveAsync.name, () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a system error if the application archive upload fails', async () => {
|
||||
vol.fromJSON({
|
||||
'./artifact.ipa': JSON.stringify(randomBytes(20)),
|
||||
});
|
||||
const workflowJobId = randomUUID();
|
||||
|
||||
// @ts-expect-error
|
||||
const ctx: BuildContext<Job> = {
|
||||
env: {
|
||||
__WORKFLOW_JOB_ID: workflowJobId,
|
||||
},
|
||||
job: {
|
||||
secrets: {
|
||||
robotAccessToken: 'fake-token',
|
||||
describe('with signed upload url provided via config', () => {
|
||||
beforeAll(() => {
|
||||
config.gcsSignedUploadUrlForApplicationArchive = {
|
||||
url: 'https://upload.url/from-config',
|
||||
headers: {
|
||||
authorization: 'test-signed-upload-authorization',
|
||||
},
|
||||
} as Job,
|
||||
logger: mockLogger,
|
||||
};
|
||||
|
||||
const bucketKey = `test/${randomUUID()}/artifact.ipa`;
|
||||
const uploadUrl = `https://upload.url/${randomUUID()}`;
|
||||
const testSignedUploadAuthorization = randomUUID();
|
||||
const uploadError = new Error('upload failed');
|
||||
|
||||
turtleFetchMock.mockImplementation(async () => {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
bucketKey,
|
||||
url: uploadUrl,
|
||||
headers: {
|
||||
authorization: testSignedUploadAuthorization,
|
||||
},
|
||||
storageType: 'GCS',
|
||||
},
|
||||
}),
|
||||
} as Response;
|
||||
};
|
||||
});
|
||||
afterAll(() => {
|
||||
config.gcsSignedUploadUrlForApplicationArchive = null;
|
||||
});
|
||||
jest.mocked(GCS.uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
|
||||
|
||||
let thrownError: unknown;
|
||||
try {
|
||||
it('should fall back to signed upload URL if upload session fails', async () => {
|
||||
vol.fromJSON({
|
||||
'./artifact.ipa': JSON.stringify(randomBytes(20)),
|
||||
});
|
||||
const workflowJobId = randomUUID();
|
||||
|
||||
// @ts-expect-error
|
||||
const ctx: BuildContext<Job> = {
|
||||
env: {
|
||||
__WORKFLOW_JOB_ID: workflowJobId,
|
||||
},
|
||||
job: {
|
||||
secrets: {
|
||||
robotAccessToken: 'fake-token',
|
||||
},
|
||||
} as Job,
|
||||
logger: mockLogger,
|
||||
};
|
||||
|
||||
turtleFetchMock.mockImplementation(async () => {
|
||||
return {
|
||||
json: async () => ({
|
||||
data: {
|
||||
bucketKey: 'test/bucketKey',
|
||||
url: 'https://upload.url/from-www',
|
||||
headers: {
|
||||
authorization: 'test-signed-upload-authorization',
|
||||
},
|
||||
storageType: 'GCS',
|
||||
},
|
||||
}),
|
||||
ok: true,
|
||||
} as unknown as Response;
|
||||
});
|
||||
|
||||
// GCS fails to upload to upload session from www.
|
||||
jest.mocked(GCS.uploadWithSignedUrl).mockImplementation(async ({ signedUrl }) => {
|
||||
if (signedUrl.url === 'https://upload.url/from-www') {
|
||||
throw new Error('upload failed');
|
||||
}
|
||||
return signedUrl.url;
|
||||
});
|
||||
|
||||
await uploadApplicationArchiveAsync(ctx, {
|
||||
artifactPaths: ['./artifact.ipa'],
|
||||
buildId: randomUUID(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
} catch (err) {
|
||||
thrownError = err;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(errors.SystemError);
|
||||
expect(thrownError).toMatchObject({
|
||||
errorCode: errors.ErrorCode.SERVER_ERROR,
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_APPLICATION_ARCHIVE_FAILED',
|
||||
message: 'Failed to upload application archive.',
|
||||
buildPhase: undefined,
|
||||
metadata: expect.objectContaining({
|
||||
filename: expect.stringMatching(/^application-.*\.ipa$/),
|
||||
size: expect.any(Number),
|
||||
url: uploadUrl,
|
||||
headers: {
|
||||
authorization: testSignedUploadAuthorization,
|
||||
},
|
||||
}),
|
||||
cause: uploadError,
|
||||
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signedUrl: {
|
||||
url: 'https://upload.url/from-config',
|
||||
headers: {
|
||||
authorization: 'test-signed-upload-authorization',
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -276,14 +263,7 @@ describe(uploadBuildArtifactsAsync.name, () => {
|
||||
buildId: 'buildId',
|
||||
logger: ctx.logger,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: errors.ErrorCode.SERVER_ERROR,
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_BUILD_ARTIFACTS_FAILED',
|
||||
message: 'Failed to upload build artifacts.',
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining('env variables are not set'),
|
||||
}),
|
||||
});
|
||||
).rejects.toThrow('env variables are not set');
|
||||
ctx.env.__WORKFLOW_JOB_ID = randomUUID();
|
||||
await expect(
|
||||
uploadBuildArtifactsAsync(ctx, {
|
||||
@@ -291,14 +271,7 @@ describe(uploadBuildArtifactsAsync.name, () => {
|
||||
buildId: 'buildId',
|
||||
logger: ctx.logger,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
errorCode: errors.ErrorCode.SERVER_ERROR,
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_BUILD_ARTIFACTS_FAILED',
|
||||
message: 'Failed to upload build artifacts.',
|
||||
cause: expect.objectContaining({
|
||||
message: expect.stringContaining('robot access token is not set'),
|
||||
}),
|
||||
});
|
||||
).rejects.toThrow('robot access token is not set');
|
||||
});
|
||||
|
||||
it('should upload the build artifacts', async () => {
|
||||
@@ -375,9 +348,6 @@ describe(uploadBuildArtifactsAsync.name, () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
expect(turtleFetchMock).toHaveBeenNthCalledWith(
|
||||
@@ -389,83 +359,96 @@ describe(uploadBuildArtifactsAsync.name, () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a system error if the build artifacts upload fails', async () => {
|
||||
vol.fromJSON({
|
||||
'./video.mp4': JSON.stringify(randomBytes(20)),
|
||||
});
|
||||
const workflowJobId = randomUUID();
|
||||
// @ts-expect-error
|
||||
const ctx: BuildContext<Job> = {
|
||||
env: {
|
||||
__WORKFLOW_JOB_ID: workflowJobId,
|
||||
},
|
||||
job: {
|
||||
secrets: {
|
||||
robotAccessToken: 'fake-token',
|
||||
describe('with signed upload url provided via config', () => {
|
||||
beforeAll(() => {
|
||||
config.gcsSignedUploadUrlForBuildArtifacts = {
|
||||
url: 'https://upload.url/from-config',
|
||||
headers: {
|
||||
authorization: 'test-signed-upload-authorization',
|
||||
},
|
||||
} as Job,
|
||||
logger: mockLogger,
|
||||
};
|
||||
const bucketKey = `test/${randomUUID()}/artifact.ipa`;
|
||||
const uploadUrl = `https://upload.url/${randomUUID()}`;
|
||||
const testSignedUploadAuthorization = randomUUID();
|
||||
const uploadError = new Error('upload failed');
|
||||
turtleFetchMock.mockImplementation(async () => {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
bucketKey,
|
||||
url: uploadUrl,
|
||||
headers: {
|
||||
authorization: testSignedUploadAuthorization,
|
||||
},
|
||||
storageType: 'GCS',
|
||||
},
|
||||
}),
|
||||
} as Response;
|
||||
};
|
||||
});
|
||||
afterAll(() => {
|
||||
config.gcsSignedUploadUrlForBuildArtifacts = null;
|
||||
});
|
||||
jest.mocked(GCS.uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
|
||||
|
||||
let thrownError: unknown;
|
||||
try {
|
||||
it('should fall back to signed upload URL if upload session fails', async () => {
|
||||
vol.fromJSON({
|
||||
'./video.mp4': JSON.stringify(randomBytes(20)),
|
||||
});
|
||||
const workflowJobId = randomUUID();
|
||||
|
||||
// @ts-expect-error
|
||||
const ctx: BuildContext<Job> = {
|
||||
env: {
|
||||
__WORKFLOW_JOB_ID: workflowJobId,
|
||||
},
|
||||
job: {
|
||||
secrets: {
|
||||
robotAccessToken: 'fake-token',
|
||||
},
|
||||
} as Job,
|
||||
logger: mockLogger,
|
||||
};
|
||||
|
||||
turtleFetchMock.mockImplementation(async () => {
|
||||
return {
|
||||
json: async () => ({
|
||||
data: {
|
||||
bucketKey: 'test/bucketKey',
|
||||
url: 'https://upload.url/from-www',
|
||||
headers: {
|
||||
authorization: 'test-signed-upload-authorization',
|
||||
},
|
||||
storageType: 'GCS',
|
||||
},
|
||||
}),
|
||||
ok: true,
|
||||
} as unknown as Response;
|
||||
});
|
||||
|
||||
// GCS fails to upload to upload session from www.
|
||||
jest.mocked(GCS.uploadWithSignedUrl).mockImplementation(async ({ signedUrl }) => {
|
||||
if (signedUrl.url === 'https://upload.url/from-www') {
|
||||
throw new Error('upload failed');
|
||||
}
|
||||
return signedUrl.url;
|
||||
});
|
||||
|
||||
await uploadBuildArtifactsAsync(ctx, {
|
||||
artifactPaths: ['./video.mp4'],
|
||||
buildId: randomUUID(),
|
||||
logger: ctx.logger,
|
||||
});
|
||||
} catch (err) {
|
||||
thrownError = err;
|
||||
}
|
||||
|
||||
expect(thrownError).toBeInstanceOf(errors.SystemError);
|
||||
expect(thrownError).toMatchObject({
|
||||
errorCode: errors.ErrorCode.SERVER_ERROR,
|
||||
trackingCode: 'EAS_BUILD_UPLOAD_BUILD_ARTIFACTS_FAILED',
|
||||
message: 'Failed to upload build artifacts.',
|
||||
buildPhase: undefined,
|
||||
metadata: expect.objectContaining({
|
||||
filename: expect.stringMatching(/^artifacts-.*\.mp4$/),
|
||||
size: expect.any(Number),
|
||||
url: uploadUrl,
|
||||
headers: {
|
||||
authorization: testSignedUploadAuthorization,
|
||||
},
|
||||
}),
|
||||
cause: uploadError,
|
||||
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signedUrl: {
|
||||
url: 'https://upload.url/from-config',
|
||||
headers: {
|
||||
authorization: 'test-signed-upload-authorization',
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(turtleFetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with signed upload url provided via www', () => {
|
||||
beforeAll(() => {
|
||||
config.gcsSignedUploadUrlForBuildArtifacts = null;
|
||||
});
|
||||
afterAll(() => {
|
||||
config.gcsSignedUploadUrlForBuildArtifacts = null;
|
||||
});
|
||||
|
||||
it('should use www signed upload url', async () => {
|
||||
vol.fromJSON({
|
||||
'./video.mp4': JSON.stringify(randomBytes(20)),
|
||||
@@ -550,9 +533,6 @@ describe('with signed upload url provided via www', () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
expect(turtleFetchMock).toHaveBeenNthCalledWith(
|
||||
@@ -564,9 +544,6 @@ describe('with signed upload url provided via www', () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -645,9 +622,6 @@ describe(uploadWorkflowArtifactAsync.name, () => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
|
||||
},
|
||||
retries: 2,
|
||||
retryIntervalMs: 1000,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -63,6 +63,17 @@ export default {
|
||||
defaultValue: null,
|
||||
}),
|
||||
},
|
||||
gcsSignedUploadUrlForApplicationArchive: env<GCS.SignedUrl | null>(
|
||||
'WORKER_RUNTIME_CONFIG_BASE64',
|
||||
{
|
||||
transform: createBase64EnvTransformer('gcsSignedUploadUrlForApplicationArchive'),
|
||||
defaultValue: null,
|
||||
}
|
||||
),
|
||||
gcsSignedUploadUrlForBuildArtifacts: env<GCS.SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('gcsSignedUploadUrlForBuildArtifacts'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
workingdir: env('WORKINGDIR', { defaultValue: path.join(__dirname, '../workingdir') }),
|
||||
sentry: {
|
||||
dsn: env('SENTRY_DSN', { defaultValue: '' }),
|
||||
@@ -71,6 +82,22 @@ export default {
|
||||
dataPlaneURL: env<string | null>('RUDDERSTACK_DATA_PLANE_URL', { defaultValue: null }),
|
||||
writeKey: env<string | null>('RUDDERSTACK_WRITE_KEY', { defaultValue: null }),
|
||||
},
|
||||
npmCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('npmCacheUrl'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
nodeJsCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('nodeJsCacheUrl'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
mavenCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('mavenCacheUrl'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
cocoapodsCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('cocoapodsCacheUrl'),
|
||||
defaultValue: null,
|
||||
}),
|
||||
runMetricsServer: env<boolean | null>('WORKER_RUNTIME_CONFIG_BASE64', {
|
||||
transform: createBase64EnvTransformer('runMetricsServer'),
|
||||
defaultValue: null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BuildContext, BuildContextOptions, LogBuffer, RuntimeSettings } from '@expo/build-tools';
|
||||
import { BuildContext, BuildContextOptions, LogBuffer } from '@expo/build-tools';
|
||||
import { Job, ManagedArtifactType, Metadata, Platform } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import assert from 'assert';
|
||||
@@ -8,7 +8,6 @@ import config from './config';
|
||||
import { getBuildEnv } from './env';
|
||||
import { Analytics } from './external/analytics';
|
||||
import { uploadXcodeBuildLogs } from './ios/xcodeLogs';
|
||||
import { prepareRuntimeEnvironmentConfigFiles } from './runtimeEnvironment';
|
||||
import sentry from './sentry';
|
||||
import {
|
||||
uploadApplicationArchiveAsync,
|
||||
@@ -17,7 +16,7 @@ import {
|
||||
uploadWorkflowArtifactAsync,
|
||||
} from './upload';
|
||||
|
||||
export async function createBuildContext<TJob extends Job>({
|
||||
export function createBuildContext<TJob extends Job>({
|
||||
job,
|
||||
logBuffer,
|
||||
analytics,
|
||||
@@ -33,14 +32,9 @@ export async function createBuildContext<TJob extends Job>({
|
||||
projectId: string;
|
||||
buildId: string;
|
||||
buildLogger: bunyan;
|
||||
}): Promise<BuildContext<TJob>> {
|
||||
const childLogger = buildLogger.child({ buildId });
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: config.env,
|
||||
env: job.builderEnvironment?.env,
|
||||
});
|
||||
await prepareRuntimeEnvironmentConfigFiles();
|
||||
}): BuildContext<TJob> {
|
||||
const env = getBuildEnv({ job, projectId, metadata, buildId });
|
||||
const childLogger = buildLogger.child({ buildId });
|
||||
|
||||
const uploadArtifact: BuildContextOptions['uploadArtifact'] = async ({ artifact, logger }) => {
|
||||
const { paths, type } = artifact;
|
||||
@@ -74,7 +68,6 @@ export async function createBuildContext<TJob extends Job>({
|
||||
artifactPaths: paths,
|
||||
logger,
|
||||
name: artifact.name,
|
||||
metadata: artifact.metadata,
|
||||
}),
|
||||
analytics
|
||||
);
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user