mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd47185f29 | ||
|
|
88c51c3ccc | ||
|
|
9247f3cf78 | ||
|
|
bde331da63 | ||
|
|
fd055f08b3 | ||
|
|
785d690e65 | ||
|
|
47335f02f0 | ||
|
|
75d9be813f | ||
|
|
cdaa011ce3 | ||
|
|
ee6e197587 | ||
|
|
63baff4daf | ||
|
|
85a4054b17 | ||
|
|
15cd0c5ae2 | ||
|
|
efc78f43d0 | ||
|
|
a905a50f65 | ||
|
|
b36b5b36d7 | ||
|
|
f867b7b9f2 | ||
|
|
99fba8c814 | ||
|
|
49101c06c1 | ||
|
|
4071f4834c | ||
|
|
55fe28cf63 | ||
|
|
88a404e45f | ||
|
|
cb2daddbfc | ||
|
|
b2388fdfa0 | ||
|
|
0986682159 | ||
|
|
24354688b6 | ||
|
|
490457976c | ||
|
|
787fe4d3a7 | ||
|
|
2dae2cdd0e | ||
|
|
967dff73df | ||
|
|
3a3995817e | ||
|
|
8a392544f2 | ||
|
|
84015ebe32 | ||
|
|
7a38191692 | ||
|
|
3e393650dc | ||
|
|
5e2ecb5997 | ||
|
|
51aceb1db0 | ||
|
|
90f78ab2ae | ||
|
|
203e9997ee | ||
|
|
792af28a5d | ||
|
|
e62bb1a840 | ||
|
|
f8c2b2daf4 | ||
|
|
22fc4da732 | ||
|
|
1c7c79d445 | ||
|
|
b30dd9054d | ||
|
|
7af74c605e | ||
|
|
a5fccedf4e | ||
|
|
f1aa26d9f3 | ||
|
|
68a0847058 | ||
|
|
1431a20b86 |
No files matched your search
@@ -12,9 +12,9 @@ jobs:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
# Pinned to a commit SHA (not the v1.4.0 tag) because this runs under
|
||||
# pull_request_target with a write-scoped token — a re-pointed tag from
|
||||
# a compromised upstream account would give the attacker repo write access.
|
||||
- uses: tobyhs/codemention@bb6bfb2c3ff1e6fee7ee37006bbee6d114057225 # v1.4.0
|
||||
# Pinned to a commit SHA (not a tag or branch) because this runs under
|
||||
# pull_request_target with a write-scoped token — a re-pointed tag or
|
||||
# moving branch would allow unreviewed upstream code to run here.
|
||||
- uses: tobyhs/codemention@ebac5877393ab8693fd73b42708c8552043e9a3b # post-v1.5.2
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
Vendored
+2
-1
@@ -5,5 +5,6 @@
|
||||
"**/*.code-search": true,
|
||||
"**/src/**/build/**": false,
|
||||
"**/src/build/**": false,
|
||||
}
|
||||
},
|
||||
"js/ts.tsdk.path": "node_modules/typescript/lib"
|
||||
}
|
||||
+52
-1
@@ -10,10 +10,61 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🐛 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 Free plan users being warned they had reached their build limit while still well below it. Overage warnings are now shown only for paid plans with an actual billable overage cost. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
|
||||
- [eas-cli] Select correct tvOS build target for non-interactive Apple builds when EXPO_TV env variable is set. ([#3907](https://github.com/expo/eas-cli/pull/3907) by [@douglowder](https://github.com/douglowder))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Bump `@expo/apple-utils` to `2.1.22`. ([#3913](https://github.com/expo/eas-cli/pull/3913) by [@sjchmiela](https://github.com/sjchmiela))
|
||||
|
||||
## [20.5.0](https://github.com/expo/eas-cli/releases/tag/v20.5.0) - 2026-06-29
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] `eas integrations:posthog:connect` now supports existing PostHog user connection. ([#3895](https://github.com/expo/eas-cli/pull/3895) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [20.4.0](https://github.com/expo/eas-cli/releases/tag/v20.4.0) - 2026-06-25
|
||||
|
||||
### 🎉 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))
|
||||
- [eas-cli] Add tiered build-credit usage warnings (approaching/at/over) before each build, lowering the warning threshold to 80% to match email notifications. ([#3882](https://github.com/expo/eas-cli/pull/3882) by [@sarahlane8](https://github.com/sarahlane8))
|
||||
|
||||
### 🐛 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))
|
||||
|
||||
## [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))
|
||||
|
||||
## [20.1.0](https://github.com/expo/eas-cli/releases/tag/v20.1.0) - 2026-06-05
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "20.1.0",
|
||||
"version": "20.5.1",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "20.1.0",
|
||||
"version": "20.5.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -38,14 +38,14 @@
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "20.0.0",
|
||||
"@expo/eas-build-job": "20.1.0",
|
||||
"@expo/eas-build-job": "20.5.1",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "20.0.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.2.0",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "20.1.0",
|
||||
"@expo/steps": "20.5.1",
|
||||
"@expo/template-file": "20.0.0",
|
||||
"@expo/turtle-spawn": "20.0.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
|
||||
@@ -14,10 +14,6 @@ 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,
|
||||
@@ -30,8 +26,6 @@ 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);
|
||||
@@ -41,24 +35,16 @@ describe('runtimeSettings', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({}));
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
jest.mocked(fetch).mockReset();
|
||||
logger.info.mockReset();
|
||||
logger.warn.mockReset();
|
||||
});
|
||||
|
||||
it('fetches runtime settings from the staging and production buckets', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({}));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
},
|
||||
});
|
||||
await RuntimeSettings.loadAsync({ environment: 'production', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'test', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'production' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'test' });
|
||||
|
||||
expect(fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
@@ -81,12 +67,11 @@ describe('runtimeSettings', () => {
|
||||
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
|
||||
jest.mocked(fetch).mockResolvedValue(response({}, 404));
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
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,
|
||||
@@ -103,13 +88,12 @@ describe('runtimeSettings', () => {
|
||||
.mockResolvedValueOnce(response({ caches: { linux: { npm: true } } }))
|
||||
.mockResolvedValueOnce(response({ iosPrecompiledModules: 'enabled' }));
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
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),
|
||||
@@ -138,7 +122,7 @@ describe('runtimeSettings', () => {
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
@@ -163,7 +147,7 @@ describe('runtimeSettings', () => {
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
|
||||
@@ -172,50 +156,13 @@ describe('runtimeSettings', () => {
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://pods.example');
|
||||
});
|
||||
|
||||
it('uses cache URL fallbacks when enabled cache environment variables are not set', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { npm: true, nodejs: true, maven: true },
|
||||
darwin: { cocoapods: true },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
nodejs: 'https://fallback-node.example',
|
||||
maven: 'https://fallback-maven.example',
|
||||
cocoapods: 'https://fallback-pods.example',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://fallback-npm.example');
|
||||
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://fallback-node.example');
|
||||
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://fallback-maven.example');
|
||||
mockProcessPlatform('darwin');
|
||||
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://fallback-pods.example');
|
||||
});
|
||||
|
||||
it('prefers cache URL environment variables over fallbacks', async () => {
|
||||
it('uses job environment flags to enable cache URLs from worker environment variables', 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',
|
||||
environment: 'test',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -223,20 +170,13 @@ describe('runtimeSettings', () => {
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
|
||||
});
|
||||
|
||||
it('does not use cache URL fallbacks when runtime settings disable caches', async () => {
|
||||
jest.mocked(fetch).mockResolvedValue(
|
||||
response({
|
||||
caches: {
|
||||
linux: { npm: false },
|
||||
},
|
||||
})
|
||||
);
|
||||
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: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
environment: 'test',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -244,17 +184,89 @@ describe('runtimeSettings', () => {
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('clears cache URL fallbacks when loading without them', async () => {
|
||||
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 } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
env: {
|
||||
EAS_USE_NPM_CACHE: '1',
|
||||
},
|
||||
});
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
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';
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
|
||||
|
||||
await RuntimeSettings.loadAsync({
|
||||
environment: 'staging',
|
||||
logger: logger as any,
|
||||
cacheUrlFallbacks: {
|
||||
npm: 'https://fallback-npm.example',
|
||||
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',
|
||||
},
|
||||
});
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
|
||||
mockProcessPlatform('linux');
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
@@ -275,7 +287,7 @@ describe('runtimeSettings', () => {
|
||||
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
|
||||
mockProcessPlatform('darwin');
|
||||
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
|
||||
await RuntimeSettings.loadAsync({ environment: 'staging' });
|
||||
|
||||
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
|
||||
@@ -17,6 +18,7 @@ const findAndUploadXcodeBuildLogsAsyncMock = jest.mocked(findAndUploadXcodeBuild
|
||||
|
||||
describe(runCustomBuildAsync, () => {
|
||||
let ctx: BuildContext<BuildJob>;
|
||||
const datadogLogSpy = jest.spyOn(Datadog, 'log');
|
||||
|
||||
beforeEach(() => {
|
||||
const job = createTestIosJob();
|
||||
@@ -27,10 +29,24 @@ 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'
|
||||
);
|
||||
@@ -77,6 +93,21 @@ 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,5 +1,11 @@
|
||||
import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job';
|
||||
import { BuildConfigParser, BuildStepGlobalContext, StepsConfigParser, errors } from '@expo/steps';
|
||||
import {
|
||||
BuildConfigParser,
|
||||
BuildStepGlobalContext,
|
||||
BuildWorkflow,
|
||||
StepsConfigParser,
|
||||
errors,
|
||||
} from '@expo/steps';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs/promises';
|
||||
import nullthrows from 'nullthrows';
|
||||
@@ -9,6 +15,7 @@ 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';
|
||||
@@ -81,6 +88,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
|
||||
throw parseError;
|
||||
}
|
||||
});
|
||||
logUserProvidedCustomFunctions(workflow);
|
||||
try {
|
||||
try {
|
||||
await workflow.executeAsync();
|
||||
@@ -103,3 +111,26 @@ 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(','),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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,8 +1,9 @@
|
||||
import { Job } from '@expo/eas-build-job';
|
||||
import { Job, SystemError } 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';
|
||||
|
||||
@@ -11,6 +12,7 @@ export async function installDependenciesAsync({
|
||||
env,
|
||||
logger,
|
||||
infoCallbackFn,
|
||||
lineTransformer,
|
||||
cwd,
|
||||
useFrozenLockfile,
|
||||
}: {
|
||||
@@ -19,6 +21,7 @@ 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[];
|
||||
@@ -69,11 +72,92 @@ 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()) {
|
||||
@@ -87,3 +171,54 @@ 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,7 +2,10 @@ import { BuildJob } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { SpawnOptions } from '@expo/turtle-spawn';
|
||||
|
||||
import { installDependenciesAsync, resolvePackagerDir } from './installDependencies';
|
||||
import {
|
||||
installDependenciesWithNpmCacheFallbackAsync,
|
||||
resolvePackagerDir,
|
||||
} from './installDependencies';
|
||||
import { BuildContext } from '../context';
|
||||
import { runExpoCliCommand } from '../utils/project';
|
||||
|
||||
@@ -30,17 +33,14 @@ export async function prebuildAsync<TJob extends BuildJob>(
|
||||
options: spawnOptions,
|
||||
packageManager: ctx.packageManager,
|
||||
});
|
||||
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;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
function getPrebuildCommandArgs<TJob extends BuildJob>(ctx: BuildContext<TJob>): string[] {
|
||||
|
||||
@@ -233,7 +233,7 @@ async function uploadProjectMetadataAsync(
|
||||
}
|
||||
`),
|
||||
{
|
||||
buildId: ctx.env.EAS_BUILD_ID,
|
||||
buildId: nullthrows(ctx.env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set'),
|
||||
projectMetadataFile: {
|
||||
type: 'GCS',
|
||||
bucketKey: uploadSession.bucketKey,
|
||||
|
||||
@@ -45,6 +45,7 @@ export type ArtifactToUpload =
|
||||
type: GenericArtifactType;
|
||||
name: string;
|
||||
paths: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface BuildContextOptions {
|
||||
@@ -62,7 +63,7 @@ export interface BuildContextOptions {
|
||||
reportError?: (
|
||||
msg: string,
|
||||
err?: Error,
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
|
||||
) => void;
|
||||
skipNativeBuild?: boolean;
|
||||
metadata?: Metadata;
|
||||
@@ -79,7 +80,7 @@ export class BuildContext<TJob extends Job = Job> {
|
||||
public readonly reportError?: (
|
||||
msg: string,
|
||||
err?: Error,
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
|
||||
options?: { tags?: Record<string, string>; extras?: Record<string, string | undefined> }
|
||||
) => void;
|
||||
public readonly skipNativeBuild?: boolean;
|
||||
public readonly expoApiV2BaseUrl?: string;
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function runFastlane(
|
||||
cwd,
|
||||
}: {
|
||||
logger?: bunyan;
|
||||
env?: Record<string, string>;
|
||||
env?: Env;
|
||||
cwd?: string;
|
||||
} = {}
|
||||
): Promise<SpawnResult> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { Env } from '@expo/eas-build-job';
|
||||
import fetch from 'node-fetch';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -26,27 +26,18 @@ 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 cacheUrlFallbacks: CacheUrlFallbacks = {};
|
||||
let runtimeEnvironment: Env = {};
|
||||
|
||||
export namespace RuntimeSettings {
|
||||
export async function loadAsync({
|
||||
environment,
|
||||
logger,
|
||||
cacheUrlFallbacks: nextCacheUrlFallbacks = {},
|
||||
env: nextRuntimeEnvironment = {},
|
||||
}: {
|
||||
environment: string;
|
||||
logger: bunyan;
|
||||
cacheUrlFallbacks?: CacheUrlFallbacks;
|
||||
env?: Env;
|
||||
}): Promise<void> {
|
||||
cacheUrlFallbacks = nextCacheUrlFallbacks;
|
||||
runtimeEnvironment = nextRuntimeEnvironment;
|
||||
const url = ENVIRONMENT_TO_RUNTIME_SETTINGS_URL[environment];
|
||||
if (!url) {
|
||||
return;
|
||||
@@ -58,10 +49,6 @@ 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',
|
||||
@@ -84,9 +71,7 @@ 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)),
|
||||
@@ -103,26 +88,51 @@ export namespace RuntimeSettings {
|
||||
}
|
||||
|
||||
export function getNpmCacheUrl(): string | null {
|
||||
return runtimeSettings.caches?.[process.platform]?.npm
|
||||
? process.env.EAS_NPM_CACHE_URL || cacheUrlFallbacks.npm || null
|
||||
: 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;
|
||||
}
|
||||
|
||||
export function getNodeJsCacheUrl(): string | null {
|
||||
return runtimeSettings.caches?.[process.platform]?.nodejs
|
||||
? process.env.EAS_NODEJS_CACHE_URL || cacheUrlFallbacks.nodejs || null
|
||||
: 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;
|
||||
}
|
||||
|
||||
export function getMavenCacheUrl(): string | null {
|
||||
return runtimeSettings.caches?.[process.platform]?.maven
|
||||
? process.env.EAS_MAVEN_CACHE_URL || cacheUrlFallbacks.maven || null
|
||||
: 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;
|
||||
}
|
||||
|
||||
export function getCocoapodsCacheUrl(): string | null {
|
||||
return runtimeSettings.caches?.[process.platform]?.cocoapods
|
||||
? process.env.EAS_COCOAPODS_CACHE_URL || cacheUrlFallbacks.cocoapods || null
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createInstallNodeModulesBuildFunction(),
|
||||
createPrebuildBuildFunction(),
|
||||
createReadIpaInfoBuildFunction(),
|
||||
createDownloadBuildFunction(),
|
||||
createDownloadBuildFunction(ctx),
|
||||
createEasExportBuildFunction(),
|
||||
createEasDeployBuildFunction(),
|
||||
createRepackBuildFunction(),
|
||||
@@ -98,7 +98,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createUploadToAscBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
createMaestroTestsBuildFunction(),
|
||||
createMaestroTestsBuildFunction(ctx),
|
||||
];
|
||||
|
||||
if (ctx.hasBuildJob()) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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';
|
||||
@@ -17,22 +19,63 @@ 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('should handle an archive', async () => {
|
||||
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
|
||||
jest.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
body: Readable.from(APP_TAR_GZ_BUFFER),
|
||||
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
expoApiServerURL: 'http://api.expo.test',
|
||||
buildId,
|
||||
graphqlClient,
|
||||
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'
|
||||
@@ -40,16 +83,21 @@ 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: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.txt?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
|
||||
url: applicationArchiveUrl,
|
||||
} as unknown as Response);
|
||||
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
expoApiServerURL: 'http://api.expo.test',
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['app'],
|
||||
});
|
||||
@@ -58,18 +106,114 @@ 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: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
|
||||
url: APPLICATION_ARCHIVE_URL,
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(
|
||||
downloadBuildAsync({
|
||||
logger: createLogger({ name: 'test' }),
|
||||
buildId: randomUUID(),
|
||||
expoApiServerURL: 'http://api.expo.test',
|
||||
graphqlClient,
|
||||
robotAccessToken: null,
|
||||
extensions: ['apk'],
|
||||
})
|
||||
@@ -79,7 +223,11 @@ describe('downloadBuild', () => {
|
||||
|
||||
describe('createDownloadBuildFunction', () => {
|
||||
it('should download a build', async () => {
|
||||
const downloadBuild = createDownloadBuildFunction();
|
||||
const buildId = randomUUID();
|
||||
const graphqlClient = createMockGraphqlClient({
|
||||
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
|
||||
});
|
||||
const downloadBuild = createDownloadBuildFunction({ graphqlClient } as any);
|
||||
const logger = createMockLogger();
|
||||
|
||||
const buildStep = downloadBuild.createBuildStepFromFunctionCall(
|
||||
@@ -92,7 +240,7 @@ describe('createDownloadBuildFunction', () => {
|
||||
}),
|
||||
{
|
||||
callInputs: {
|
||||
build_id: randomUUID(),
|
||||
build_id: buildId,
|
||||
extensions: ['app'],
|
||||
},
|
||||
}
|
||||
@@ -105,5 +253,9 @@ 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,10 +4,14 @@ import { vol } from 'memfs';
|
||||
|
||||
import {
|
||||
copyLatestAttemptXml,
|
||||
isFileAttrRun,
|
||||
junitFileHasFileAttrs,
|
||||
mergeJUnitReports,
|
||||
parseFailedFlowsFromFileAttrs,
|
||||
parseFailedFlowsFromJUnit,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
} from '../maestroResultParser';
|
||||
|
||||
describe(parseMaestroResults, () => {
|
||||
@@ -298,7 +302,169 @@ 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': [
|
||||
@@ -582,6 +748,100 @@ 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({
|
||||
@@ -765,6 +1025,28 @@ 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
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
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,10 +1,15 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { GenericArtifactType, 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', () => ({
|
||||
@@ -17,7 +22,29 @@ 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,
|
||||
@@ -41,7 +68,10 @@ function createStep(
|
||||
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
|
||||
> {
|
||||
const logger = createMockLogger();
|
||||
const fn = createMaestroTestsBuildFunction();
|
||||
const ctx = {
|
||||
runtimeApi: { uploadArtifact: mockUploadArtifact },
|
||||
} as unknown as CustomBuildContext;
|
||||
const fn = createMaestroTestsBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock({ logger });
|
||||
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
|
||||
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
|
||||
@@ -54,10 +84,18 @@ 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()).toBeDefined();
|
||||
expect(
|
||||
createMaestroTestsBuildFunction({
|
||||
runtimeApi: { uploadArtifact: mockUploadArtifact },
|
||||
} as unknown as CustomBuildContext)
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('sets all outputs before running any flows', async () => {
|
||||
@@ -273,6 +311,33 @@ 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);
|
||||
@@ -293,6 +358,44 @@ 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']);
|
||||
@@ -639,4 +742,149 @@ 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']);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ describe(createRepackBuildFunction, () => {
|
||||
vol.reset();
|
||||
});
|
||||
|
||||
it('should set the output path for successful repack', async () => {
|
||||
it('should set generated output path for successful repack', async () => {
|
||||
const repack = createRepackBuildFunction();
|
||||
const repackStep = repack.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
@@ -38,14 +38,33 @@ describe(createRepackBuildFunction, () => {
|
||||
{
|
||||
callInputs: {
|
||||
platform: 'ios',
|
||||
source_app_path: '/path/to/source_app',
|
||||
output_path: '/path/to/output_app',
|
||||
source_app_path: '/path/to/source_app.ipa',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await repackStep.executeAsync();
|
||||
expect(repackStep.outputById['output_path'].value).toBe('/path/to/output_app');
|
||||
expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.ipa$/);
|
||||
});
|
||||
|
||||
it('should rename generated aab output path to apk', async () => {
|
||||
const repack = createRepackBuildFunction();
|
||||
const repackStep = repack.createBuildStepFromFunctionCall(
|
||||
createGlobalContextMock({
|
||||
staticContextContent: {
|
||||
job: createTestAndroidJob(),
|
||||
},
|
||||
}),
|
||||
{
|
||||
callInputs: {
|
||||
platform: 'android',
|
||||
source_app_path: '/path/to/source_app.aab',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await repackStep.executeAsync();
|
||||
expect(repackStep.outputById['output_path'].value).toMatch(/repacked-.*\.apk$/);
|
||||
});
|
||||
|
||||
it('should throw for unsupported platforms', async () => {
|
||||
|
||||
+80
-3
@@ -3,6 +3,7 @@ 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 = [
|
||||
@@ -43,15 +44,24 @@ 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: createMockLogger(),
|
||||
logger: overrides?.logger ?? createMockLogger(),
|
||||
projectTargetDirectory: '/root/project',
|
||||
staticContextContent: {
|
||||
expoApiServerURL: 'https://api.expo.test',
|
||||
@@ -105,6 +115,54 @@ 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': [
|
||||
@@ -221,7 +279,7 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
await createStep().executeAsync();
|
||||
});
|
||||
|
||||
it('skips report when duplicate testcase names found', async () => {
|
||||
it('skips report when duplicate testcase names found (legacy), advising unique names', async () => {
|
||||
vol.fromJSON({
|
||||
'/junit/report.xml': [
|
||||
'<?xml version="1.0"?>',
|
||||
@@ -238,8 +296,27 @@ describe(createReportMaestroTestResultsFunction, () => {
|
||||
}),
|
||||
});
|
||||
|
||||
await createStep().executeAsync();
|
||||
const logger = createSelfChildedLogger();
|
||||
await createStep({ logger }).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 () => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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.')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
getAvailableDevicesAsync: jest.fn(),
|
||||
getDeviceAsync: jest.fn(),
|
||||
cloneAsync: jest.fn(),
|
||||
enableAccessibilitySettingsAsync: jest.fn(),
|
||||
startAsync: jest.fn(),
|
||||
waitForReadyAsync: jest.fn(),
|
||||
disableApsdAsync: jest.fn(),
|
||||
@@ -34,15 +35,59 @@ function createStep(callInputs?: Record<string, unknown>) {
|
||||
|
||||
describe(createStartIosSimulatorBuildFunction, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
mockedUtils.getAvailableDevicesAsync.mockResolvedValue([]);
|
||||
mockedUtils.getDeviceAsync.mockResolvedValue(null);
|
||||
mockedUtils.cloneAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.enableAccessibilitySettingsAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.startAsync.mockResolvedValue({ udid: 'test-udid' as any });
|
||||
mockedUtils.waitForReadyAsync.mockResolvedValue(undefined);
|
||||
mockedUtils.disableApsdAsync.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('does not enable accessibility settings by default', async () => {
|
||||
await createStep({ device_identifier: 'iPhone 15' }).executeAsync();
|
||||
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).not.toHaveBeenCalled();
|
||||
expect(mockedUtils.startAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 15',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('enables accessibility settings before starting the main device and every clone when requested', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-1' as any })
|
||||
.mockResolvedValueOnce({ udid: 'clone-2' as any });
|
||||
|
||||
await createStep({
|
||||
device_identifier: 'iPhone 15',
|
||||
count: 2,
|
||||
enable_accessibility_settings: true,
|
||||
}).executeAsync();
|
||||
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'iPhone 15',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'eas-simulator-1',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
expect(mockedUtils.enableAccessibilitySettingsAsync).toHaveBeenCalledWith({
|
||||
deviceIdentifier: 'eas-simulator-2',
|
||||
env: expect.any(Object),
|
||||
});
|
||||
for (const [callIndex] of mockedUtils.startAsync.mock.calls.entries()) {
|
||||
const enableCallOrder =
|
||||
mockedUtils.enableAccessibilitySettingsAsync.mock.invocationCallOrder[callIndex];
|
||||
const startCallOrder = mockedUtils.startAsync.mock.invocationCallOrder[callIndex];
|
||||
expect(enableCallOrder).toBeLessThan(startCallOrder);
|
||||
}
|
||||
});
|
||||
|
||||
it('disables apsd on the main device and every clone', async () => {
|
||||
mockedUtils.startAsync
|
||||
.mockResolvedValueOnce({ udid: 'base' as any })
|
||||
|
||||
@@ -120,4 +120,44 @@ 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,6 +1,7 @@
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
|
||||
import { Sentry } from '../../sentry';
|
||||
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
|
||||
|
||||
export function createSubmissionEntityFunction(): BuildFunction {
|
||||
@@ -54,18 +55,42 @@ export function createSubmissionEntityFunction(): BuildFunction {
|
||||
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
|
||||
if (!robotAccessToken) {
|
||||
stepsCtx.logger.error('Failed to create submission entity: no robot access token found');
|
||||
Sentry.capture('Failed to create submission entity: missing robot access token');
|
||||
return;
|
||||
}
|
||||
|
||||
const buildId = inputs.build_id.value;
|
||||
if (!buildId) {
|
||||
stepsCtx.logger.error('Failed to create submission entity: no build ID provided');
|
||||
Sentry.capture('Failed to create submission entity: missing build ID', {
|
||||
extras: {
|
||||
buildId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const workflowJobId = stepsCtx.global.env.__WORKFLOW_JOB_ID;
|
||||
if (!workflowJobId) {
|
||||
stepsCtx.logger.error('Failed to create submission entity: no workflow job ID found');
|
||||
Sentry.capture('Failed to create submission entity: missing workflow job ID', {
|
||||
extras: {
|
||||
buildId,
|
||||
workflowJobId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL;
|
||||
if (!expoApiServerURL) {
|
||||
stepsCtx.logger.error('Failed to create submission entity: no Expo API server URL found');
|
||||
Sentry.capture('Failed to create submission entity: missing Expo API server URL', {
|
||||
extras: {
|
||||
buildId,
|
||||
workflowJobId,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,7 +108,7 @@ export function createSubmissionEntityFunction(): BuildFunction {
|
||||
|
||||
try {
|
||||
const response = await retryOnDNSFailure(fetch)(
|
||||
new URL('/v2/app-store-submissions/', stepsCtx.global.staticContext.expoApiServerURL),
|
||||
new URL('/v2/app-store-submissions/', expoApiServerURL),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -56,18 +56,23 @@ export function createDownloadArtifactFunction(): BuildFunction {
|
||||
const interpolationContext = stepsCtx.global.getInterpolationContext();
|
||||
|
||||
if (!('workflow' in interpolationContext)) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
|
||||
'No workflow found in the interpolation context.'
|
||||
);
|
||||
throw new SystemError('No workflow found in the interpolation context.', {
|
||||
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_WORKFLOW',
|
||||
});
|
||||
}
|
||||
|
||||
const robotAccessToken = stepsCtx.global.staticContext.job.secrets?.robotAccessToken;
|
||||
if (!robotAccessToken) {
|
||||
throw new UserError(
|
||||
'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
|
||||
'No robot access token found in the job secrets.'
|
||||
);
|
||||
throw new SystemError('No robot access token found in the job secrets.', {
|
||||
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_ROBOT_ACCESS_TOKEN',
|
||||
});
|
||||
}
|
||||
|
||||
const expoApiServerURL = stepsCtx.global.staticContext.expoApiServerURL;
|
||||
if (!expoApiServerURL) {
|
||||
throw new SystemError('Missing Expo API server URL.', {
|
||||
trackingCode: 'EAS_DOWNLOAD_ARTIFACT_NO_EXPO_API_SERVER_URL',
|
||||
});
|
||||
}
|
||||
|
||||
const workflowRunId = interpolationContext.workflow.id;
|
||||
@@ -82,7 +87,7 @@ export function createDownloadArtifactFunction(): BuildFunction {
|
||||
const { artifactPath } = await downloadArtifactAsync({
|
||||
logger,
|
||||
workflowRunId,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
params,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -7,7 +7,9 @@ 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';
|
||||
@@ -16,6 +18,7 @@ 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';
|
||||
@@ -23,7 +26,21 @@ import { pluralize } from '../../utils/strings';
|
||||
|
||||
const streamPipeline = promisify(stream.pipeline);
|
||||
|
||||
export function createDownloadBuildFunction(): BuildFunction {
|
||||
const BUILD_BY_ID_QUERY = graphql(`
|
||||
query DownloadBuildByIdQuery($buildId: ID!) {
|
||||
builds {
|
||||
byId(buildId: $buildId) {
|
||||
id
|
||||
platform
|
||||
artifacts {
|
||||
applicationArchiveUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'download_build',
|
||||
@@ -59,7 +76,7 @@ export function createDownloadBuildFunction(): BuildFunction {
|
||||
const { artifactPath } = await downloadBuildAsync({
|
||||
logger,
|
||||
buildId,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
graphqlClient: ctx.graphqlClient,
|
||||
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
|
||||
extensions,
|
||||
});
|
||||
@@ -69,16 +86,45 @@ export function createDownloadBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
expoApiServerURL,
|
||||
graphqlClient,
|
||||
robotAccessToken,
|
||||
extensions,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
buildId: string;
|
||||
expoApiServerURL: string;
|
||||
graphqlClient: Client;
|
||||
robotAccessToken: string | null;
|
||||
extensions: string[];
|
||||
}): Promise<{ artifactPath: string }> {
|
||||
@@ -86,12 +132,11 @@ export async function downloadBuildAsync({
|
||||
path.join(os.tmpdir(), 'download_build-downloaded-')
|
||||
);
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(
|
||||
new URL(`/v2/artifacts/eas/${buildId}`, expoApiServerURL),
|
||||
{
|
||||
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
|
||||
}
|
||||
);
|
||||
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
|
||||
|
||||
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
|
||||
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(`Failed to upload application archives.`, err);
|
||||
stepCtx.logger.error({ err }, `Failed to upload application archives.`);
|
||||
firstError ||= err;
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadBuildArtifacts({ ctx, stepCtx });
|
||||
} catch (err: unknown) {
|
||||
stepCtx.logger.error(`Failed to upload build artifacts.`, err);
|
||||
stepCtx.logger.error({ err }, `Failed to upload build artifacts.`);
|
||||
firstError ||= err;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
@@ -18,6 +19,7 @@ const ATTEMPT_PATTERN = /attempt-(\d+)/;
|
||||
|
||||
export interface JUnitTestCaseResult {
|
||||
name: string;
|
||||
file: string | undefined;
|
||||
status: 'passed' | 'failed';
|
||||
duration: number; // milliseconds
|
||||
errorMessage: string | null;
|
||||
@@ -32,6 +34,12 @@ 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 {
|
||||
@@ -54,6 +62,8 @@ 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;
|
||||
@@ -94,7 +104,7 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
|
||||
: [];
|
||||
delete properties['tags'];
|
||||
|
||||
results.push({ name, status, duration, errorMessage, tags, properties });
|
||||
results.push({ name, file, status, duration, errorMessage, tags, properties });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -131,6 +141,142 @@ 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
|
||||
@@ -313,6 +459,13 @@ 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;
|
||||
@@ -324,9 +477,10 @@ export async function mergeJUnitReports(args: {
|
||||
if (typeof name !== 'string') {
|
||||
continue;
|
||||
}
|
||||
const group = testcasesByName.get(name) ?? [];
|
||||
const key = useFileKeys ? fileAttrOf(tc)! : name;
|
||||
const group = testcasesByName.get(key) ?? [];
|
||||
group.push(tc);
|
||||
testcasesByName.set(name, group);
|
||||
testcasesByName.set(key, group);
|
||||
}
|
||||
}
|
||||
if (testcasesByName.size === 0) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
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,4 +1,5 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -8,15 +9,26 @@ 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);
|
||||
@@ -79,7 +91,7 @@ function buildMaestroArgs(args: {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildFunction {
|
||||
return new BuildFunction({
|
||||
namespace: 'eas',
|
||||
id: 'maestro_tests',
|
||||
@@ -200,13 +212,10 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
throw new SystemError('Failed to create JUnit report directory', { cause: err });
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
// 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;
|
||||
|
||||
// Retry loop. spawn-async error shapes:
|
||||
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
|
||||
@@ -214,10 +223,11 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
// 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. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
|
||||
// flows. The failed-flow parsers return 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++) {
|
||||
@@ -240,6 +250,8 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
`Running maestro (attempt ${attempt + 1}/${totalAttempts}): maestro ${maestroArgs.join(' ')}`
|
||||
);
|
||||
|
||||
const attemptStartedAtMs = Date.now();
|
||||
|
||||
try {
|
||||
await spawn('maestro', maestroArgs, {
|
||||
cwd: stepCtx.workingDirectory,
|
||||
@@ -259,15 +271,44 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 && nameToPath) {
|
||||
const failed = await parseFailedFlowsFromJUnit({
|
||||
junitFile: outputPath,
|
||||
nameToPath,
|
||||
});
|
||||
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 (failed !== null && failed.length > 0) {
|
||||
flowsToRun = failed;
|
||||
logger.info(
|
||||
@@ -316,6 +357,12 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -328,3 +375,82 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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.');
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -42,11 +42,6 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'output_path',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
required: false,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'embed_bundle_assets',
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
@@ -109,9 +104,10 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
stepsCtx.logger.info(`Created temporary working directory: ${workingDirectory}`);
|
||||
|
||||
const sourceAppPath = inputs.source_app_path.value as string;
|
||||
const outputPath =
|
||||
(inputs.output_path.value as string) ??
|
||||
path.join(tmpDir, `repacked-${randomUUID()}${path.extname(sourceAppPath)}`);
|
||||
const outputPath = createOutputPath({
|
||||
sourceAppPath,
|
||||
tmpDir,
|
||||
});
|
||||
const exportEmbedOptions = inputs.embed_bundle_assets.value
|
||||
? {
|
||||
sourcemapOutput: undefined,
|
||||
@@ -202,6 +198,21 @@ export function createRepackBuildFunction(): BuildFunction {
|
||||
});
|
||||
}
|
||||
|
||||
function createOutputPath({
|
||||
sourceAppPath,
|
||||
tmpDir,
|
||||
}: {
|
||||
sourceAppPath: string;
|
||||
tmpDir: string;
|
||||
}): string {
|
||||
const outputPath = path.join(tmpDir, `repacked-${randomUUID()}${path.extname(sourceAppPath)}`);
|
||||
const extension = path.extname(outputPath);
|
||||
if (extension.toLowerCase() !== '.aab') {
|
||||
return outputPath;
|
||||
}
|
||||
return `${outputPath.slice(0, -extension.length)}.apk`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install `@expo/repack-app` in a sandbox directory and import it.
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,13 @@ import { graphql } from 'gql.tada';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
|
||||
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
|
||||
import {
|
||||
MaestroFlowResult,
|
||||
isFileAttrRun,
|
||||
parseJUnitTestCases,
|
||||
parseMaestroResults,
|
||||
parseMaestroResultsFromFileAttrs,
|
||||
} from './maestroResultParser';
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
|
||||
const FlowPathSchema = z.array(z.string().min(1)).min(1);
|
||||
@@ -61,47 +67,64 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
|
||||
return;
|
||||
}
|
||||
|
||||
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).'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const flowResults = await parseMaestroResults(junitDirectory, nameToPath);
|
||||
// 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);
|
||||
} 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);
|
||||
}
|
||||
|
||||
if (flowResults.length === 0) {
|
||||
logger.info('No maestro test results found, skipping report');
|
||||
return;
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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).
|
||||
const seen = new Set<string>();
|
||||
const conflicting = new Set<string>();
|
||||
for (const r of flowResults) {
|
||||
const key = `${r.name}:${r.retryCount}`;
|
||||
const key = `${r.path}:${r.retryCount}`;
|
||||
if (seen.has(key)) {
|
||||
conflicting.add(r.name);
|
||||
conflicting.add(r.path);
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
if (conflicting.size > 0) {
|
||||
const conflictList = [...conflicting].join(', ');
|
||||
logger.error(
|
||||
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
|
||||
', '
|
||||
)}. Skipping report. Ensure each Maestro flow has a unique name.`
|
||||
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).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -206,16 +206,16 @@ export async function restoreGradleCacheAsync({
|
||||
}
|
||||
|
||||
try {
|
||||
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`
|
||||
);
|
||||
// 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');
|
||||
|
||||
// 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');
|
||||
// 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');
|
||||
await fs.promises.mkdir(initScriptDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
|
||||
|
||||
@@ -69,6 +69,10 @@ export function createRestoreCacheFunction(): BuildFunction {
|
||||
.filter(key => key !== '');
|
||||
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const expoApiServerURL = nullthrows(
|
||||
stepsCtx.global.staticContext.expoApiServerURL,
|
||||
'expoApiServerURL is not set'
|
||||
);
|
||||
const robotAccessToken = nullthrows(
|
||||
stepsCtx.global.staticContext.job.secrets?.robotAccessToken,
|
||||
'robotAccessToken is not set'
|
||||
@@ -77,7 +81,7 @@ export function createRestoreCacheFunction(): BuildFunction {
|
||||
const { archivePath, matchedKey } = await downloadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
paths,
|
||||
key,
|
||||
|
||||
@@ -43,6 +43,10 @@ export function createSaveCacheFunction(): BuildFunction {
|
||||
.filter(path => path.length > 0);
|
||||
const key = z.string().parse(inputs.key.value);
|
||||
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
|
||||
const expoApiServerURL = nullthrows(
|
||||
stepsCtx.global.staticContext.expoApiServerURL,
|
||||
'expoApiServerURL is not set'
|
||||
);
|
||||
const robotAccessToken = nullthrows(
|
||||
stepsCtx.global.staticContext.job.secrets?.robotAccessToken,
|
||||
'robotAccessToken is not set'
|
||||
@@ -61,7 +65,7 @@ export function createSaveCacheFunction(): BuildFunction {
|
||||
await uploadPublicCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key,
|
||||
@@ -73,7 +77,7 @@ export function createSaveCacheFunction(): BuildFunction {
|
||||
await uploadCacheAsync({
|
||||
logger,
|
||||
jobId,
|
||||
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
|
||||
expoApiServerURL,
|
||||
robotAccessToken,
|
||||
archivePath,
|
||||
key,
|
||||
@@ -141,10 +145,6 @@ 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}`);
|
||||
}
|
||||
|
||||
@@ -235,19 +235,6 @@ 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,6 +19,7 @@ import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startNgrokTunnelAsync,
|
||||
startServeSimWithTunnelAsync,
|
||||
@@ -30,7 +31,6 @@ 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,8 +64,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
}
|
||||
|
||||
logger.info('Launching agent-device daemon.');
|
||||
@@ -90,7 +89,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({
|
||||
const { previewUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildRuntimePlatform,
|
||||
@@ -9,13 +10,16 @@ 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,
|
||||
@@ -24,11 +28,15 @@ 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() });
|
||||
const ArgentToolServerStateSchema = z.object({
|
||||
port: z.number(),
|
||||
token: z.string().optional(),
|
||||
});
|
||||
|
||||
export function createStartArgentRemoteSessionBuildFunction(
|
||||
ctx: CustomBuildContext
|
||||
@@ -55,6 +63,7 @@ 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(
|
||||
@@ -62,46 +71,76 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
);
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
await selectXcodeDeveloperDirectoryAsync({ 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} 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({
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bunx.`);
|
||||
const argentServer = spawnDetached({
|
||||
command: 'bunx',
|
||||
args: [`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'mcp'],
|
||||
env: { ...env, ARGENT_IDLE_TIMEOUT_MINUTES: '0' },
|
||||
args: [
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--detach',
|
||||
],
|
||||
env,
|
||||
});
|
||||
|
||||
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
|
||||
const { port: toolServerPort } = await waitForFileAsync({
|
||||
filePath: ARGENT_STATE_FILE,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
description: 'argent tool-server state',
|
||||
parse: parseArgentToolServerState,
|
||||
});
|
||||
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 toolsUrl = await startNgrokTunnelAsync({
|
||||
const publicToolsUrl = await startNgrokTunnelAsync({
|
||||
port: toolServerPort,
|
||||
subdomainPrefix: 'argent',
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
authtoken: ngrokAuthtoken,
|
||||
rewriteHostHeader: true,
|
||||
logger,
|
||||
});
|
||||
logger.info(`Tunnel is ready at ${toolsUrl}.`);
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
// serve-sim is iOS-only — Android sessions go without a preview URL.
|
||||
let webPreviewUrl: string | undefined;
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
const serveSim = await startServeSimWithTunnelAsync({
|
||||
const serveSim = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
@@ -115,7 +154,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
toolsUrl,
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
...(webPreviewUrl ? { webPreviewUrl } : {}),
|
||||
},
|
||||
logger,
|
||||
@@ -129,7 +169,37 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgentToolServerState(raw: string): { port: number } {
|
||||
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 } {
|
||||
const result = ArgentToolServerStateSchema.safeParse(JSON.parse(raw));
|
||||
if (!result.success) {
|
||||
throw new SystemError(
|
||||
|
||||
@@ -31,6 +31,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
defaultValue: 1,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'enable_accessibility_settings',
|
||||
required: false,
|
||||
defaultValue: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
],
|
||||
fn: async ({ logger }, { inputs, env }) => {
|
||||
try {
|
||||
@@ -55,11 +61,18 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
| undefined;
|
||||
const originalDeviceIdentifier =
|
||||
deviceIdentifierInput ?? (await findMostGenericIphoneUuidAsync({ env }));
|
||||
const enableAccessibilitySettings = Boolean(inputs.enable_accessibility_settings.value);
|
||||
|
||||
if (!originalDeviceIdentifier) {
|
||||
throw new Error('Could not find an iPhone among available simulator devices.');
|
||||
}
|
||||
|
||||
if (enableAccessibilitySettings) {
|
||||
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
env,
|
||||
});
|
||||
}
|
||||
const { udid } = await IosSimulatorUtils.startAsync({
|
||||
deviceIdentifier: originalDeviceIdentifier,
|
||||
env,
|
||||
@@ -97,6 +110,12 @@ export function createStartIosSimulatorBuildFunction(): BuildFunction {
|
||||
env,
|
||||
});
|
||||
|
||||
if (enableAccessibilitySettings) {
|
||||
await IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: cloneDeviceName,
|
||||
env,
|
||||
});
|
||||
}
|
||||
const { udid: cloneUdid } = await IosSimulatorUtils.startAsync({
|
||||
deviceIdentifier: cloneDeviceName,
|
||||
env,
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
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(
|
||||
@@ -27,10 +26,9 @@ export function createStartServeSimRemoteSessionBuildFunction(
|
||||
|
||||
logger.info('Starting serve-sim remote session.');
|
||||
|
||||
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
|
||||
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
|
||||
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
|
||||
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { GenericArtifactType, Job, ManagedArtifactType } from '@expo/eas-build-job';
|
||||
import {
|
||||
GenericArtifactType,
|
||||
Job,
|
||||
ManagedArtifactType,
|
||||
isGenericArtifact,
|
||||
} from '@expo/eas-build-job';
|
||||
import {
|
||||
BuildFunction,
|
||||
BuildStepInput,
|
||||
@@ -65,6 +70,11 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'metadata',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
],
|
||||
outputProviders: [
|
||||
BuildStepOutput.createProvider({
|
||||
@@ -108,7 +118,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
|
||||
throw result.reason;
|
||||
});
|
||||
|
||||
const artifact = {
|
||||
const artifactSpec = {
|
||||
type: parseArtifactTypeInput({
|
||||
platform: ctx.job.platform,
|
||||
inputValue: `${inputs.type.value ?? ''}`,
|
||||
@@ -116,19 +126,22 @@ 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(`Failed to upload ${artifact.type}. Ignoring error.`, error);
|
||||
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
|
||||
// Ignoring error.
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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(),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
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,14 +1,20 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildStepEnv } from '@expo/steps';
|
||||
import { BuildStepEnv, spawnAsync } 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!) {
|
||||
@@ -60,6 +66,125 @@ 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,
|
||||
@@ -120,18 +245,22 @@ export function spawnDetached({
|
||||
return { getOutput: () => output };
|
||||
}
|
||||
|
||||
export async function startServeSimWithTunnelAsync({
|
||||
baseDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
}): Promise<{ previewUrl: string; streamUrl: string }> {
|
||||
export async function startServeSimWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
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: [
|
||||
@@ -147,6 +276,7 @@ export async function startServeSimWithTunnelAsync({
|
||||
'0.55',
|
||||
'--codec',
|
||||
'webrtc',
|
||||
...turnArgs,
|
||||
],
|
||||
env,
|
||||
});
|
||||
@@ -188,19 +318,26 @@ 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 });
|
||||
const listener = await ngrok.forward({
|
||||
addr: port,
|
||||
authtoken,
|
||||
domain,
|
||||
...(rewriteHostHeader ? { request_header_add: [`Host:localhost:${port}`] } : {}),
|
||||
});
|
||||
const url = listener.url();
|
||||
if (!url) {
|
||||
throw new SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ExpoError, SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn, { SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
@@ -92,6 +93,67 @@ export namespace IosSimulatorUtils {
|
||||
});
|
||||
}
|
||||
|
||||
export async function enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
}: {
|
||||
deviceIdentifier: IosSimulatorUuid | IosSimulatorName;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const devices = await getAvailableDevicesAsync({ env, filter: 'available' });
|
||||
const device = devices.find(
|
||||
device =>
|
||||
device.isAvailable &&
|
||||
(device.udid === deviceIdentifier || device.name === deviceIdentifier)
|
||||
);
|
||||
if (!device) {
|
||||
throw new UserError(
|
||||
'EAS_IOS_SIMULATOR_NOT_FOUND',
|
||||
`Failed to find available iOS Simulator "${deviceIdentifier}" to update accessibility settings.`
|
||||
);
|
||||
}
|
||||
if (device.state !== 'Shutdown') {
|
||||
throw new UserError(
|
||||
'EAS_IOS_SIMULATOR_NOT_SHUTDOWN',
|
||||
`Expected iOS Simulator "${deviceIdentifier}" to be shutdown before updating accessibility settings, but it is ${device.state}.`
|
||||
);
|
||||
}
|
||||
|
||||
const plistPath = path.join(
|
||||
device.dataPath,
|
||||
'Library',
|
||||
'Preferences',
|
||||
'com.apple.Accessibility.plist'
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(plistPath), { recursive: true });
|
||||
|
||||
const plistExists = await fs.promises
|
||||
.access(plistPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!plistExists) {
|
||||
await spawn('plutil', ['-create', 'binary1', plistPath], { env });
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
'AutomationEnabled',
|
||||
'IgnoreAXServerEntitlements',
|
||||
'AccessibilityEnabled',
|
||||
'ApplicationAccessibilityEnabled',
|
||||
]) {
|
||||
await spawn('plutil', ['-replace', key, '-bool', 'true', plistPath], { env });
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ExpoError) {
|
||||
throw err;
|
||||
}
|
||||
throw new SystemError('Failed to update iOS Simulator accessibility settings.', {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function startAsync({
|
||||
deviceIdentifier,
|
||||
env,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -18,9 +19,143 @@ const mockedSpawn = jest.mocked(spawn);
|
||||
|
||||
describe('IosSimulatorUtils', () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.startAsync, () => {
|
||||
it('waits for boot completion without writing accessibility prefs', async () => {
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'xcrun' && args.join(' ') === 'simctl bootstatus test-udid -b') {
|
||||
return {
|
||||
stdout: 'Monitoring boot status for iPhone 15 (test-udid).\n',
|
||||
stderr: '',
|
||||
} as any;
|
||||
}
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}) as any);
|
||||
|
||||
await IosSimulatorUtils.startAsync({
|
||||
deviceIdentifier: 'test-udid' as any,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith(
|
||||
'xcrun',
|
||||
['simctl', 'list', 'devices', '--json', '--no-escape-slashes', 'test-udid'],
|
||||
expect.anything()
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith('plutil', expect.anything(), expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.enableAccessibilitySettingsAsync, () => {
|
||||
function device(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
dataPath: '/tmp/eas-test-simulator-data',
|
||||
dataPathSize: 1,
|
||||
logPath: '/tmp/eas-test-simulator-logs',
|
||||
udid: 'test-udid',
|
||||
isAvailable: true,
|
||||
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-15',
|
||||
state: 'Shutdown',
|
||||
name: 'iPhone 15',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockAvailableDevices(devices: Record<string, unknown>[]) {
|
||||
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
|
||||
if (
|
||||
command === 'xcrun' &&
|
||||
args.join(' ') === 'simctl list devices --json --no-escape-slashes available'
|
||||
) {
|
||||
return {
|
||||
stdout: JSON.stringify({
|
||||
devices: {
|
||||
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': devices,
|
||||
},
|
||||
}),
|
||||
stderr: '',
|
||||
} as any;
|
||||
}
|
||||
return { stdout: '', stderr: '' } as any;
|
||||
}) as any);
|
||||
}
|
||||
|
||||
it('writes accessibility prefs for an exact shutdown simulator match', async () => {
|
||||
mockAvailableDevices([
|
||||
device({
|
||||
udid: 'test-udid-pro',
|
||||
name: 'iPhone 17 Pro',
|
||||
dataPath: '/tmp/eas-test-simulator-data-pro',
|
||||
}),
|
||||
device({ name: 'iPhone 17' }),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: 'iPhone 17' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
for (const key of [
|
||||
'AutomationEnabled',
|
||||
'IgnoreAXServerEntitlements',
|
||||
'AccessibilityEnabled',
|
||||
'ApplicationAccessibilityEnabled',
|
||||
]) {
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
'plutil',
|
||||
[
|
||||
'-replace',
|
||||
key,
|
||||
'-bool',
|
||||
'true',
|
||||
'/tmp/eas-test-simulator-data/Library/Preferences/com.apple.Accessibility.plist',
|
||||
],
|
||||
{ env: process.env }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws UserError when no available simulator exactly matches the identifier', async () => {
|
||||
mockAvailableDevices([device({ name: 'iPhone 17 Pro' })]);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: 'iPhone 17' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow(UserError);
|
||||
|
||||
expect(mockedSpawn).not.toHaveBeenCalledWith('plutil', expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
it('throws UserError when the matched simulator is already booted', async () => {
|
||||
mockAvailableDevices([device({ state: 'Booted' })]);
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: 'test-udid' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow(UserError);
|
||||
});
|
||||
|
||||
it('throws SystemError when pre-boot accessibility setup fails', async () => {
|
||||
mockedSpawn.mockRejectedValue(new Error('Failed to list simulators'));
|
||||
|
||||
await expect(
|
||||
IosSimulatorUtils.enableAccessibilitySettingsAsync({
|
||||
deviceIdentifier: 'test-udid' as any,
|
||||
env: process.env,
|
||||
})
|
||||
).rejects.toThrow(SystemError);
|
||||
});
|
||||
});
|
||||
|
||||
describe(IosSimulatorUtils.waitForReadyAsync, () => {
|
||||
it('takes the readiness screenshot into a writable temp file, not /dev/null', async () => {
|
||||
await IosSimulatorUtils.waitForReadyAsync({
|
||||
|
||||
@@ -104,4 +104,33 @@ describe(expoUpdates.configureExpoUpdatesIfInstalledAsync, () => {
|
||||
expect(iosSetChannelNativelyAsync).toBeCalledTimes(1);
|
||||
expect(getExpoUpdatesPackageVersionIfInstalledAsync).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fail on missing EAS_BUILD_ID when logging fingerprint diffs', async () => {
|
||||
jest.mocked(getExpoUpdatesPackageVersionIfInstalledAsync).mockResolvedValue('0.18.0');
|
||||
|
||||
const warn = jest.fn();
|
||||
const managedCtx: BuildContext<BuildJob> = {
|
||||
appConfig: {},
|
||||
env: {},
|
||||
job: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
logger: { warn },
|
||||
metadata: {
|
||||
runtimeVersion: 'runtime-version-from-local-machine',
|
||||
},
|
||||
getReactNativeProjectDirectory: () => '/app',
|
||||
} as any;
|
||||
|
||||
await expect(
|
||||
expoUpdates.configureExpoUpdatesIfInstalledAsync(managedCtx, {
|
||||
resolvedRuntimeVersion: 'runtime-version-from-eas-build',
|
||||
resolvedFingerprintSources: [],
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Runtime version calculated on local machine not equal to runtime version calculated during build.'
|
||||
);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith('Skipping fingerprint diff because EAS_BUILD_ID is not set');
|
||||
});
|
||||
});
|
||||
@@ -243,6 +243,12 @@ async function logDiffFingerprints({
|
||||
ctx: BuildContext<BuildJob>;
|
||||
}): Promise<void> {
|
||||
const { resolvedRuntimeVersion, resolvedFingerprintSources } = resolvedRuntime;
|
||||
const buildId = ctx.env.EAS_BUILD_ID;
|
||||
|
||||
if (!buildId) {
|
||||
ctx.logger.warn('Skipping fingerprint diff because EAS_BUILD_ID is not set');
|
||||
return;
|
||||
}
|
||||
|
||||
const fingerprintInfo = await ctx.graphqlClient
|
||||
.query(
|
||||
@@ -257,7 +263,7 @@ async function logDiffFingerprints({
|
||||
}
|
||||
}
|
||||
`),
|
||||
{ id: ctx.env.EAS_BUILD_ID }
|
||||
{ id: buildId }
|
||||
)
|
||||
.toPromise();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "20.1.0",
|
||||
"version": "20.5.1",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,7 +35,7 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "20.1.0"
|
||||
"@expo/steps": "20.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "20.1.0",
|
||||
"version": "20.5.1",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
import { StaticWorkflowInterpolationContextZ } from '../common';
|
||||
import { EnvSchema, StaticWorkflowInterpolationContextZ } from '../common';
|
||||
|
||||
describe('EnvSchema', () => {
|
||||
it('accepts explicit undefined values', () => {
|
||||
const env = {
|
||||
DEFINED_ENV: 'value',
|
||||
UNDEFINED_ENV: undefined,
|
||||
};
|
||||
|
||||
const { value, error } = EnvSchema.validate(env);
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(value).toEqual(env);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StaticWorkflowInterpolationContextZ', () => {
|
||||
it('accepts app and account context', () => {
|
||||
|
||||
@@ -115,8 +115,8 @@ export const ArchiveSourceSchemaZ = z.discriminatedUnion('type', [
|
||||
}),
|
||||
]);
|
||||
|
||||
export type Env = Record<string, string>;
|
||||
export const EnvSchema = Joi.object().pattern(Joi.string(), Joi.string());
|
||||
export type Env = Record<string, string | undefined>;
|
||||
export const EnvSchema = Joi.object().pattern(Joi.string(), Joi.string().optional());
|
||||
|
||||
export type EnvironmentSecret = {
|
||||
name: string;
|
||||
|
||||
@@ -11,7 +11,7 @@ type StaticJobOnlyInterpolationContext = {
|
||||
outputs: Record<string, string | undefined>;
|
||||
}
|
||||
>;
|
||||
expoApiServerURL: string;
|
||||
expoApiServerURL: string | undefined;
|
||||
};
|
||||
|
||||
export type StaticJobInterpolationContext =
|
||||
|
||||
+246
-120
File diff suppressed because it is too large.
Load diff
Generated
+4181
-17
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "20.1.0",
|
||||
"version": "20.5.1",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -27,7 +27,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"postpack": "rimraf oclif.manifest.json",
|
||||
"prepack": "yarn rebuild && node ./scripts/prepack.js",
|
||||
"prepack": "yarn sync-agent-templates && 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,6 +36,7 @@
|
||||
"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",
|
||||
@@ -43,12 +44,12 @@
|
||||
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/apple-utils": "2.1.19",
|
||||
"@expo/apple-utils": "2.1.22",
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "20.1.0",
|
||||
"@expo/eas-json": "20.1.0",
|
||||
"@expo/eas-build-job": "20.5.1",
|
||||
"@expo/eas-json": "20.5.1",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "20.0.0",
|
||||
@@ -63,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "20.1.0",
|
||||
"@expo/steps": "20.5.1",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -224,6 +225,9 @@
|
||||
"update": {
|
||||
"description": "manage individual updates"
|
||||
},
|
||||
"update:embedded": {
|
||||
"description": "manage embedded updates registered with EAS Update"
|
||||
},
|
||||
"webhook": {
|
||||
"description": "manage webhooks"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
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,5 +1,5 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { Env, Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { BuildProfile, EasJson } from '@expo/eas-json';
|
||||
import { LoggerLevel } from '@expo/logger';
|
||||
import { NodePackageManager } from '@expo/package-manager';
|
||||
@@ -65,5 +65,5 @@ export interface BuildContext<T extends Platform> {
|
||||
loggerLevel?: LoggerLevel;
|
||||
isVerboseLoggingEnabled: boolean;
|
||||
whatToTest?: string;
|
||||
env: Record<string, string>;
|
||||
env: Env;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { Env, Platform } from '@expo/eas-build-job';
|
||||
import { BuildProfile, EasJson, ResourceClass } from '@expo/eas-json';
|
||||
import JsonFile from '@expo/json-file';
|
||||
import { LoggerLevel } from '@expo/logger';
|
||||
@@ -69,7 +69,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
refreshAdHocProvisioningProfile?: boolean;
|
||||
isVerboseLoggingEnabled: boolean;
|
||||
whatToTest?: string;
|
||||
env: Record<string, string>;
|
||||
env: Env;
|
||||
}): Promise<BuildContext<T>> {
|
||||
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
|
||||
env,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Job, Metadata, version } from '@expo/eas-build-job';
|
||||
import { Env, Job, Metadata, version } from '@expo/eas-build-job';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { ChildProcess } from 'child_process';
|
||||
import semver from 'semver';
|
||||
@@ -42,7 +42,7 @@ export async function runLocalBuildAsync(
|
||||
job: Job,
|
||||
metadata: Metadata,
|
||||
options: LocalBuildOptions,
|
||||
env: Record<string, string>
|
||||
env: Env
|
||||
): Promise<void> {
|
||||
const { command, args } = await getCommandAndArgsAsync(job, metadata);
|
||||
let spinner;
|
||||
|
||||
@@ -64,6 +64,19 @@ export function getWorkflowRunUrl(
|
||||
).toString();
|
||||
}
|
||||
|
||||
export function getDeviceRunSessionUrl(
|
||||
accountName: string,
|
||||
projectName: string,
|
||||
deviceRunSessionId: string
|
||||
): string {
|
||||
return new URL(
|
||||
`/accounts/${encodeURIComponent(accountName)}/projects/${encodeURIComponent(
|
||||
projectName
|
||||
)}/simulator-sessions/${encodeURIComponent(deviceRunSessionId)}`,
|
||||
getExpoWebsiteBaseUrl()
|
||||
).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Links to the raw job-run page; prefer a higher-level URL (e.g. the workflow run
|
||||
* or the feature-specific dashboard) that gives users more context. Use this only for internal
|
||||
|
||||
@@ -1,162 +1,41 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility.
|
||||
|
||||
## Documentation Resources
|
||||
## Expo has changed — do not trust your training data
|
||||
|
||||
When working on this project, **always consult the official Expo documentation** available at:
|
||||
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:
|
||||
|
||||
- **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
|
||||
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.
|
||||
|
||||
These documentation files are specifically formatted for AI agents and should be your **primary reference** for:
|
||||
## Commands
|
||||
|
||||
- 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
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Development
|
||||
Use `bunx` instead of `npx` if the project uses bun (`bun.lock` present).
|
||||
|
||||
```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
|
||||
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
|
||||
```
|
||||
|
||||
### Building & Testing
|
||||
Run lint and typecheck before declaring any task done.
|
||||
|
||||
```bash
|
||||
npx expo doctor # Check project health and dependencies
|
||||
npx expo lint # Run ESLint
|
||||
npm run draft # Publish preview update and website (workflow)
|
||||
```
|
||||
## Navigation & Routing
|
||||
|
||||
### Production
|
||||
- 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 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)
|
||||
```
|
||||
## Building with EAS
|
||||
|
||||
## Development Guidelines
|
||||
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
|
||||
|
||||
### Code Style & Standards
|
||||
## Rules
|
||||
|
||||
- **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
|
||||
- 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
|
||||
@@ -1,9 +1 @@
|
||||
# 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.
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,22 @@
|
||||
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.`);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import openBrowserAsync from 'better-opn';
|
||||
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,
|
||||
StartPostHogConnectionResult,
|
||||
} 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('better-opn');
|
||||
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(),
|
||||
stop: 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 mockConnectionResult: StartPostHogConnectionResult = {
|
||||
__typename: 'PostHogOrganizationConnection',
|
||||
...mockConnection,
|
||||
};
|
||||
|
||||
const mockPendingResult: StartPostHogConnectionResult = {
|
||||
__typename: 'PostHogPendingConnection',
|
||||
url: 'https://us.posthog.com/login?next=/oauth/authorize',
|
||||
};
|
||||
|
||||
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.startPostHogConnectionAsync)
|
||||
.mockResolvedValue(mockConnectionResult);
|
||||
jest.mocked(PostHogMutation.setupPostHogProjectAsync).mockResolvedValue(mockProject);
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(true as never);
|
||||
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.startPostHogConnectionAsync).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.startPostHogConnectionAsync).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.startPostHogConnectionAsync).not.toHaveBeenCalled();
|
||||
expect(PostHogMutation.setupPostHogProjectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('completes an existing-account connection via the browser, then provisions the project', async () => {
|
||||
// No connection at the first check; the start mutation hands off to the browser, and
|
||||
// polling then finds the connection the website callback created.
|
||||
jest
|
||||
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue(mockConnection);
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
jest.mocked(PostHogMutation.startPostHogConnectionAsync).mockResolvedValue(mockPendingResult);
|
||||
mockFeatureSelection(['analytics']);
|
||||
|
||||
await createCommand(['--region', 'US']).runAsync();
|
||||
|
||||
expect(openBrowserAsync).toHaveBeenCalledWith(mockPendingResult.url);
|
||||
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Opened'));
|
||||
expect(PostHogMutation.setupPostHogProjectAsync).toHaveBeenCalledWith(graphqlClient, {
|
||||
appId: testProjectId,
|
||||
posthogOrganizationConnectionId: mockConnection.id,
|
||||
});
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still prints the approval URL when a browser cannot be opened', async () => {
|
||||
jest
|
||||
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue(mockConnection);
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
jest.mocked(PostHogMutation.startPostHogConnectionAsync).mockResolvedValue(mockPendingResult);
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(false as never);
|
||||
mockFeatureSelection(['analytics']);
|
||||
|
||||
await createCommand(['--region', 'US']).runAsync();
|
||||
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Open this URL to approve the connection')
|
||||
);
|
||||
expect(PostHogMutation.setupPostHogProjectAsync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries polling after a transient failure, then completes', async () => {
|
||||
jest.useFakeTimers();
|
||||
const debugSpy = jest.spyOn(Log, 'debug').mockImplementation(() => {});
|
||||
try {
|
||||
// No connection initially; the first poll throws (transient), the next finds it.
|
||||
jest
|
||||
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockRejectedValueOnce(new Error('network blip'))
|
||||
.mockResolvedValue(mockConnection);
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
jest.mocked(PostHogMutation.startPostHogConnectionAsync).mockResolvedValue(mockPendingResult);
|
||||
mockFeatureSelection(['analytics']);
|
||||
|
||||
const promise = createCommand(['--region', 'US']).runAsync();
|
||||
// Flush the poll interval so the retry runs after the transient failure.
|
||||
await jest.advanceTimersByTimeAsync(3_000);
|
||||
await promise;
|
||||
|
||||
expect(debugSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Polling for the PostHog connection failed')
|
||||
);
|
||||
expect(PostHogMutation.setupPostHogProjectAsync).toHaveBeenCalled();
|
||||
} finally {
|
||||
debugSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses the browser handoff non-interactively and provisions nothing', async () => {
|
||||
jest
|
||||
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
|
||||
.mockResolvedValue(null);
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
jest.mocked(PostHogMutation.startPostHogConnectionAsync).mockResolvedValue(mockPendingResult);
|
||||
|
||||
await expect(createCommand(['--non-interactive', '--region', 'US']).runAsync()).rejects.toThrow(
|
||||
/already has a PostHog account.*interactively/s
|
||||
);
|
||||
|
||||
expect(openBrowserAsync).not.toHaveBeenCalled();
|
||||
expect(PostHogMutation.setupPostHogProjectAsync).not.toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('times out (and provisions nothing) if the connection is never approved', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
// Connection never appears: null at the first check and on every poll.
|
||||
jest
|
||||
.mocked(PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync)
|
||||
.mockResolvedValue(null);
|
||||
jest.mocked(PostHogQuery.getPostHogProjectByAppIdAsync).mockResolvedValue(null);
|
||||
jest.mocked(PostHogMutation.startPostHogConnectionAsync).mockResolvedValue(mockPendingResult);
|
||||
mockFeatureSelection(['analytics']);
|
||||
|
||||
const promise = createCommand(['--region', 'US']).runAsync();
|
||||
const assertion = expect(promise).rejects.toThrow(/Timed out waiting for the PostHog/);
|
||||
// Advance past the 15-minute poll window, flushing each poll iteration's awaits.
|
||||
await jest.advanceTimersByTimeAsync(16 * 60 * 1_000);
|
||||
await assertion;
|
||||
|
||||
expect(PostHogMutation.setupPostHogProjectAsync).not.toHaveBeenCalled();
|
||||
expect(EnvironmentVariableMutation.createForAppAsync).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
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.startPostHogConnectionAsync).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.startPostHogConnectionAsync)
|
||||
.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.startPostHogConnectionAsync).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')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,657 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { Flags } from '@oclif/core';
|
||||
import openBrowserAsync from 'better-opn';
|
||||
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 {
|
||||
PostHogOrganizationConnectionData,
|
||||
PostHogProjectData,
|
||||
StartPostHogConnectionResult,
|
||||
} 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';
|
||||
|
||||
// Match the server's 15-minute pending-row TTL — timing out sooner would strand an approval
|
||||
// the user completes within the window.
|
||||
const CONNECTION_POLL_INTERVAL_MS = 2_000;
|
||||
const CONNECTION_POLL_TIMEOUT_MS = 15 * 60 * 1_000;
|
||||
|
||||
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);
|
||||
connection = await this.startConnectionAsync(graphqlClient, account, region, nonInteractive);
|
||||
}
|
||||
|
||||
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 startConnectionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
account: { id: string; name: string },
|
||||
region: PostHogRegion,
|
||||
nonInteractive: boolean
|
||||
): Promise<PostHogOrganizationConnectionData> {
|
||||
const spinner = ora('Creating PostHog organization').start();
|
||||
let result: StartPostHogConnectionResult;
|
||||
try {
|
||||
result = await PostHogMutation.startPostHogConnectionAsync(graphqlClient, {
|
||||
accountId: account.id,
|
||||
region,
|
||||
});
|
||||
} catch (error) {
|
||||
spinner.fail('Failed to create PostHog organization');
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result.__typename === 'PostHogOrganizationConnection') {
|
||||
spinner.succeed(`Created PostHog organization ${chalk.bold(result.posthogOrganizationName)}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
spinner.stop();
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
`The email on the ${chalk.bold(account.name)} account already has a PostHog account, which must be connected by approving it in a browser. Re-run \`eas integrations:posthog:connect\` interactively to finish connecting.`
|
||||
);
|
||||
}
|
||||
return await this.completePendingConnectionViaBrowserAsync(graphqlClient, account, result);
|
||||
}
|
||||
|
||||
private async completePendingConnectionViaBrowserAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
account: { id: string; name: string },
|
||||
pending: { url: string }
|
||||
): Promise<PostHogOrganizationConnectionData> {
|
||||
Log.addNewLineIfNone();
|
||||
Log.log(
|
||||
`The email on the ${chalk.bold(account.name)} account already has a PostHog account. Approve connecting it to Expo in your browser.`
|
||||
);
|
||||
const opened = await openBrowserAsync(pending.url).catch(() => false);
|
||||
Log.log(
|
||||
opened
|
||||
? `Opened ${link(pending.url)}`
|
||||
: `Open this URL to approve the connection: ${link(pending.url)}`
|
||||
);
|
||||
|
||||
const spinner = ora(
|
||||
'Waiting for you to approve the connection in PostHog (up to 15 minutes; press Ctrl-C to cancel)'
|
||||
).start();
|
||||
try {
|
||||
const connection = await this.pollForConnectionAsync(graphqlClient, account.id);
|
||||
spinner.succeed(
|
||||
`Connected PostHog organization ${chalk.bold(connection.posthogOrganizationName)}`
|
||||
);
|
||||
return connection;
|
||||
} catch (error) {
|
||||
spinner.fail("Couldn't confirm the PostHog connection");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async pollForConnectionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string
|
||||
): Promise<PostHogOrganizationConnectionData> {
|
||||
const deadline = Date.now() + CONNECTION_POLL_TIMEOUT_MS;
|
||||
while (true) {
|
||||
let connection: PostHogOrganizationConnectionData | null = null;
|
||||
try {
|
||||
connection = await PostHogQuery.getPostHogOrganizationConnectionByAccountIdAsync(
|
||||
graphqlClient,
|
||||
accountId,
|
||||
{ useCache: false }
|
||||
);
|
||||
} catch (error) {
|
||||
Log.debug(`Polling for the PostHog connection failed, will retry: ${error}`);
|
||||
}
|
||||
if (connection) {
|
||||
return connection;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(
|
||||
'Timed out waiting for the PostHog connection. If you approved it in your browser, re-run `eas integrations:posthog:connect` — it will pick up the connection. If not, approve it and try again.'
|
||||
);
|
||||
}
|
||||
await new Promise<void>(resolve => setTimeout(resolve, CONNECTION_POLL_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,8 @@ describe(SimulatorGet, () => {
|
||||
id: 'session-123',
|
||||
type: 'agent-device',
|
||||
status: DeviceRunSessionStatus.InProgress,
|
||||
jobRunUrl: 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123',
|
||||
deviceRunSessionUrl:
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/simulator-sessions/session-123',
|
||||
remoteConfig: session.remoteConfig,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,7 +135,8 @@ describe(SimulatorList, () => {
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
startedAt: '2025-01-01T00:00:05.000Z',
|
||||
finishedAt: undefined,
|
||||
jobRunUrl: 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123',
|
||||
deviceRunSessionUrl:
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/simulator-sessions/session-123',
|
||||
},
|
||||
],
|
||||
pageInfo: {
|
||||
|
||||
@@ -61,7 +61,8 @@ type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const projectDir = '/test/project';
|
||||
const simulatorDotenvPath = `${projectDir}/.env.eas-simulator`;
|
||||
const jobRunUrl = 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123';
|
||||
const deviceRunSessionUrl =
|
||||
'https://expo.dev/accounts/testuser/projects/testapp/simulator-sessions/session-123';
|
||||
|
||||
const mockCreateDeviceRunSessionAsync = jest.mocked(
|
||||
DeviceRunSessionMutation.createDeviceRunSessionAsync
|
||||
@@ -185,7 +186,7 @@ describe(SimulatorStart, () => {
|
||||
});
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
expect(mockOra.mock.results[0]?.value.succeed).toHaveBeenCalledWith(
|
||||
`Device run session created (id: session-123) ${jobRunUrl}`
|
||||
`Simulator session created (id: session-123) ${deviceRunSessionUrl}`
|
||||
);
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("export AGENT_DEVICE_DAEMON_BASE_URL='https://agent.example.com'")
|
||||
@@ -214,7 +215,7 @@ describe(SimulatorStart, () => {
|
||||
mockByIdAsync.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(mockOra.mock.results[0]?.value.succeed).toHaveBeenCalledWith(
|
||||
`Device run session created (id: session-123, saved to ${SIMULATOR_DOTENV_FILE_NAME}) ${jobRunUrl}`
|
||||
`Simulator session created (id: session-123, saved to ${SIMULATOR_DOTENV_FILE_NAME}) ${deviceRunSessionUrl}`
|
||||
);
|
||||
expect(Log.withTick).not.toHaveBeenCalled();
|
||||
expect(Log.log).toHaveBeenCalledWith(
|
||||
@@ -298,7 +299,7 @@ describe(SimulatorStart, () => {
|
||||
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive', '--no-force']);
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'Existing simulator session in environment. Use --force to create a new device session.'
|
||||
'Existing simulator session in environment. Use --force to create a new simulator session.'
|
||||
);
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
|
||||
import { getBareJobRunUrl } from '../../build/utils/url';
|
||||
import { getDeviceRunSessionUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
@@ -24,11 +24,11 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
export default class SimulatorGet extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'[EXPERIMENTAL] get info about a remote simulator session on EAS by its device run session ID';
|
||||
'[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID';
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: `Device run session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -61,26 +61,28 @@ export default class SimulatorGet extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
const fetchSpinner = ora(`Fetching device run session ${flagId}`).start();
|
||||
const fetchSpinner = ora(`Fetching simulator session ${flagId}`).start();
|
||||
let session;
|
||||
try {
|
||||
session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, flagId);
|
||||
fetchSpinner.succeed(`Fetched device run session ${session.id}`);
|
||||
fetchSpinner.succeed(`Fetched simulator session ${session.id}`);
|
||||
} catch (err) {
|
||||
fetchSpinner.fail(`Failed to fetch device run session ${flagId}`);
|
||||
fetchSpinner.fail(`Failed to fetch simulator session ${flagId}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const jobRunUrl = session.turtleJobRun
|
||||
? getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, session.turtleJobRun.id)
|
||||
: '';
|
||||
const deviceRunSessionUrl = getDeviceRunSessionUrl(
|
||||
session.app.ownerAccount.name,
|
||||
session.app.slug,
|
||||
session.id
|
||||
);
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
id: session.id,
|
||||
type: deviceRunSessionTypeToFlagValue(session.type),
|
||||
status: session.status,
|
||||
jobRunUrl: jobRunUrl || undefined,
|
||||
deviceRunSessionUrl,
|
||||
remoteConfig: session.remoteConfig,
|
||||
});
|
||||
return;
|
||||
@@ -90,7 +92,7 @@ export default class SimulatorGet extends EasCommand {
|
||||
Log.log(`ID: ${session.id}`);
|
||||
Log.log(`Type: ${session.type}`);
|
||||
Log.log(`Status: ${session.status}`);
|
||||
Log.log(`URL: ${jobRunUrl ? link(jobRunUrl) : ''}`);
|
||||
Log.log(`URL: ${link(deviceRunSessionUrl)}`);
|
||||
|
||||
if (session.status === DeviceRunSessionStatus.InProgress) {
|
||||
Log.newLine();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { getBareJobRunUrl } from '../../build/utils/url';
|
||||
import { getDeviceRunSessionUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
@@ -110,7 +110,7 @@ export default class SimulatorList extends EasCommand {
|
||||
|
||||
const limit = flags.limit ?? DEFAULT_LIMIT;
|
||||
|
||||
const fetchSpinner = jsonFlag ? null : ora('Fetching device run sessions').start();
|
||||
const fetchSpinner = jsonFlag ? null : ora('Fetching simulator sessions').start();
|
||||
let connection;
|
||||
try {
|
||||
connection = await DeviceRunSessionQuery.listByAppIdAsync(graphqlClient, {
|
||||
@@ -119,9 +119,9 @@ export default class SimulatorList extends EasCommand {
|
||||
after: flags.after,
|
||||
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
||||
});
|
||||
fetchSpinner?.succeed(`Fetched ${connection.edges.length} device run session(s)`);
|
||||
fetchSpinner?.succeed(`Fetched ${connection.edges.length} simulator session(s)`);
|
||||
} catch (err) {
|
||||
fetchSpinner?.fail('Failed to fetch device run sessions');
|
||||
fetchSpinner?.fail('Failed to fetch simulator sessions');
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -137,13 +137,11 @@ export default class SimulatorList extends EasCommand {
|
||||
createdAt: session.createdAt,
|
||||
startedAt: session.startedAt ?? undefined,
|
||||
finishedAt: session.finishedAt ?? undefined,
|
||||
jobRunUrl: session.turtleJobRun
|
||||
? getBareJobRunUrl(
|
||||
session.app.ownerAccount.name,
|
||||
session.app.slug,
|
||||
session.turtleJobRun.id
|
||||
)
|
||||
: undefined,
|
||||
deviceRunSessionUrl: getDeviceRunSessionUrl(
|
||||
session.app.ownerAccount.name,
|
||||
session.app.slug,
|
||||
session.id
|
||||
),
|
||||
})),
|
||||
pageInfo: connection.pageInfo,
|
||||
});
|
||||
@@ -152,25 +150,25 @@ export default class SimulatorList extends EasCommand {
|
||||
|
||||
if (sessions.length === 0) {
|
||||
Log.newLine();
|
||||
Log.log('No device run sessions found.');
|
||||
Log.log('No simulator sessions found.');
|
||||
return;
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
const formattedEntries = sessions.map(session => {
|
||||
const jobRunUrl = session.turtleJobRun
|
||||
? getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, session.turtleJobRun.id)
|
||||
: null;
|
||||
const deviceRunSessionUrl = getDeviceRunSessionUrl(
|
||||
session.app.ownerAccount.name,
|
||||
session.app.slug,
|
||||
session.id
|
||||
);
|
||||
const lines = [
|
||||
`ID: ${session.id}`,
|
||||
`Type: ${session.type}`,
|
||||
`Status: ${session.status}`,
|
||||
`Platform: ${session.platform}`,
|
||||
`Created: ${fromNow(new Date(session.createdAt))} ago`,
|
||||
`URL: ${link(deviceRunSessionUrl)}`,
|
||||
];
|
||||
if (jobRunUrl) {
|
||||
lines.push(`URL: ${link(jobRunUrl)}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
});
|
||||
Log.log(formattedEntries.join(`\n\n${chalk.dim('———')}\n\n`));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { getBareJobRunUrl } from '../../build/utils/url';
|
||||
import { getDeviceRunSessionUrl } from '../../build/utils/url';
|
||||
import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import {
|
||||
@@ -54,17 +54,22 @@ export default class SimulatorStart extends EasCommand {
|
||||
required: true,
|
||||
})(),
|
||||
type: Flags.option({
|
||||
description: 'Type of device run session to create',
|
||||
description: 'Type of simulator session to create',
|
||||
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
|
||||
default: DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[DeviceRunSessionType.AgentDevice],
|
||||
})(),
|
||||
'package-version': Flags.string({
|
||||
description:
|
||||
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
|
||||
'Version of the package backing the simulator session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
|
||||
}),
|
||||
'max-duration-minutes': Flags.integer({
|
||||
description:
|
||||
'Maximum duration of the simulator 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.',
|
||||
'[default: true] Create a new simulator session even when an existing simulator session is present in the environment.',
|
||||
default: true,
|
||||
allowNo: true,
|
||||
}),
|
||||
@@ -102,7 +107,7 @@ export default class SimulatorStart extends EasCommand {
|
||||
const existingDeviceRunSessionId = process.env[EAS_SIMULATOR_SESSION_ID];
|
||||
if (existingDeviceRunSessionId && !flags.force) {
|
||||
throw new Error(
|
||||
`Existing simulator session in environment. Use --force to create a new device session.`
|
||||
`Existing simulator session in environment. Use --force to create a new simulator session.`
|
||||
);
|
||||
}
|
||||
if (existingDeviceRunSessionId) {
|
||||
@@ -116,19 +121,24 @@ export default class SimulatorStart extends EasCommand {
|
||||
|
||||
const platform = flags.platform === 'android' ? AppPlatform.Android : AppPlatform.Ios;
|
||||
|
||||
const createSpinner = ora('🚀 Creating device run session').start();
|
||||
const createSpinner = ora('🚀 Creating simulator session').start();
|
||||
let deviceRunSessionId: string;
|
||||
let jobRunUrl: string;
|
||||
let deviceRunSessionUrl: string;
|
||||
try {
|
||||
const session = await DeviceRunSessionMutation.createDeviceRunSessionAsync(graphqlClient, {
|
||||
appId: projectId,
|
||||
platform,
|
||||
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
|
||||
packageVersion: flags['package-version'],
|
||||
maxRunTimeMinutes: flags['max-duration-minutes'],
|
||||
});
|
||||
deviceRunSessionId = session.id;
|
||||
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
|
||||
jobRunUrl = getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, jobRunId);
|
||||
nullthrows(session.turtleJobRun?.id, 'Expected simulator session to start');
|
||||
deviceRunSessionUrl = getDeviceRunSessionUrl(
|
||||
session.app.ownerAccount.name,
|
||||
session.app.slug,
|
||||
deviceRunSessionId
|
||||
);
|
||||
const simulatorEnvWritten =
|
||||
!jsonFlag && flags['out-config-type'] === OUT_CONFIG_TYPE_VALUES.Dotenv
|
||||
? await writeSimulatorEnvSafelyAsync(projectDir, {
|
||||
@@ -136,12 +146,12 @@ export default class SimulatorStart extends EasCommand {
|
||||
})
|
||||
: false;
|
||||
createSpinner.succeed(
|
||||
`Device run session created (id: ${deviceRunSessionId}${
|
||||
`Simulator session created (id: ${deviceRunSessionId}${
|
||||
simulatorEnvWritten ? `, saved to ${SIMULATOR_DOTENV_FILE_NAME}` : ''
|
||||
}) ${link(jobRunUrl)}`
|
||||
}) ${link(deviceRunSessionUrl)}`
|
||||
);
|
||||
} catch (err) {
|
||||
createSpinner.fail('Failed to create device run session');
|
||||
createSpinner.fail('Failed to create simulator session');
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -158,7 +168,7 @@ export default class SimulatorStart extends EasCommand {
|
||||
session.status === DeviceRunSessionStatus.Stopped
|
||||
) {
|
||||
throw new Error(
|
||||
`Device run session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(jobRunUrl)}`
|
||||
`Simulator session ${deviceRunSessionId} ${session.status.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,7 +179,7 @@ export default class SimulatorStart extends EasCommand {
|
||||
jobRunStatus === JobRunStatus.Finished
|
||||
) {
|
||||
throw new Error(
|
||||
`Turtle job run for device run session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(jobRunUrl)}`
|
||||
`Turtle job run for simulator session ${deviceRunSessionId} ${jobRunStatus.toLowerCase()} before the ${flags.type} session was ready. ${link(deviceRunSessionUrl)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,7 +201,7 @@ export default class SimulatorStart extends EasCommand {
|
||||
pollSpinner.fail(`Timed out waiting for ${flags.type} session to be ready`);
|
||||
await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId);
|
||||
throw new Error(
|
||||
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to be ready. ${link(jobRunUrl)}`
|
||||
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to be ready. ${link(deviceRunSessionUrl)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,7 +216,7 @@ export default class SimulatorStart extends EasCommand {
|
||||
printJsonOnlyOutput({
|
||||
id: deviceRunSessionId,
|
||||
type: flags.type,
|
||||
jobRunUrl,
|
||||
deviceRunSessionUrl,
|
||||
remoteConfig,
|
||||
});
|
||||
return;
|
||||
@@ -226,7 +236,7 @@ export default class SimulatorStart extends EasCommand {
|
||||
await waitForSessionEndOrInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
jobRunUrl,
|
||||
deviceRunSessionUrl,
|
||||
projectDir,
|
||||
});
|
||||
}
|
||||
@@ -252,16 +262,16 @@ async function writeSimulatorEnvSafelyAsync(
|
||||
async function waitForSessionEndOrInterruptAsync({
|
||||
graphqlClient,
|
||||
deviceRunSessionId,
|
||||
jobRunUrl,
|
||||
deviceRunSessionUrl,
|
||||
projectDir,
|
||||
}: {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
deviceRunSessionId: string;
|
||||
jobRunUrl: string;
|
||||
deviceRunSessionUrl: string;
|
||||
projectDir: string;
|
||||
}): Promise<void> {
|
||||
const spinner = ora(
|
||||
`Device run session active — press Ctrl+C to stop, or run \`eas simulator:stop --id ${deviceRunSessionId}\` from another shell`
|
||||
`Simulator session active — press Ctrl+C to stop, or run \`eas simulator:stop --id ${deviceRunSessionId}\` from another shell`
|
||||
).start();
|
||||
|
||||
const abortController = new AbortController();
|
||||
@@ -280,7 +290,7 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
// Force exit on a second Ctrl+C in case cleanup is hanging. The session may still be
|
||||
// running on EAS, so tell the user how to make sure it gets terminated.
|
||||
spinner.fail(
|
||||
`Aborted before the device run session could be stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
|
||||
`Aborted before the simulator session could be stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
|
||||
);
|
||||
process.exit(130);
|
||||
}
|
||||
@@ -295,7 +305,7 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
session = await DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId);
|
||||
} catch (err) {
|
||||
Log.debug(
|
||||
`Failed to poll device run session: ${err instanceof Error ? err.message : String(err)}`
|
||||
`Failed to poll simulator session: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
await Promise.race([sleepAsync(POLL_INTERVAL_MS), abortPromise]);
|
||||
continue;
|
||||
@@ -306,15 +316,15 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
session.status === DeviceRunSessionStatus.Errored ||
|
||||
jobRunStatus === JobRunStatus.Errored
|
||||
) {
|
||||
spinner.fail(`Device run session errored. ${link(jobRunUrl)}`);
|
||||
throw new Error(`Device run session ${deviceRunSessionId} errored.`);
|
||||
spinner.fail(`Simulator session errored. ${link(deviceRunSessionUrl)}`);
|
||||
throw new Error(`Simulator session ${deviceRunSessionId} errored.`);
|
||||
}
|
||||
if (
|
||||
session.status === DeviceRunSessionStatus.Stopped ||
|
||||
jobRunStatus === JobRunStatus.Canceled ||
|
||||
jobRunStatus === JobRunStatus.Finished
|
||||
) {
|
||||
spinner.succeed(`Device run session ended. ${link(jobRunUrl)}`);
|
||||
spinner.succeed(`Simulator session ended. ${link(deviceRunSessionUrl)}`);
|
||||
await resetSimulatorEnvVerboseAsync(projectDir);
|
||||
return;
|
||||
}
|
||||
@@ -322,17 +332,17 @@ async function waitForSessionEndOrInterruptAsync({
|
||||
await Promise.race([sleepAsync(POLL_INTERVAL_MS), abortPromise]);
|
||||
}
|
||||
|
||||
spinner.text = 'Stopping device run session...';
|
||||
spinner.text = 'Stopping simulator session...';
|
||||
const stopped = await ensureDeviceRunSessionStoppedSafelyAsync(
|
||||
graphqlClient,
|
||||
deviceRunSessionId
|
||||
);
|
||||
if (stopped) {
|
||||
spinner.succeed('Device run session stopped');
|
||||
spinner.succeed('Simulator session stopped');
|
||||
await resetSimulatorEnvVerboseAsync(projectDir);
|
||||
} else {
|
||||
spinner.fail(
|
||||
`Could not confirm the device run session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
|
||||
`Could not confirm the simulator session was stopped. Run \`eas simulator:stop --id ${deviceRunSessionId}\` to terminate it and avoid unexpected charges.`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -362,7 +372,7 @@ async function ensureDeviceRunSessionStoppedSafelyAsync(
|
||||
} catch (err) {
|
||||
// Cleanup is best-effort; surface the failure but don't mask the original error.
|
||||
Log.warn(
|
||||
`Failed to stop device run session ${deviceRunSessionId}: ${
|
||||
`Failed to stop simulator session ${deviceRunSessionId}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`
|
||||
);
|
||||
|
||||
@@ -17,11 +17,11 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
export default class SimulatorStop extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override description =
|
||||
'[EXPERIMENTAL] stop a remote simulator session on EAS by its device run session ID';
|
||||
'[EXPERIMENTAL] stop a remote simulator session on EAS by its simulator session ID';
|
||||
|
||||
static override flags = {
|
||||
id: Flags.string({
|
||||
description: `Device run session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
description: `Simulator session ID. Defaults to ${SIMULATOR_DOTENV_FILE_NAME}.`,
|
||||
}),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
@@ -54,16 +54,16 @@ export default class SimulatorStop extends EasCommand {
|
||||
);
|
||||
}
|
||||
|
||||
const stopSpinner = ora(`🛑 Stopping device run session ${flagId}`).start();
|
||||
const stopSpinner = ora(`🛑 Stopping simulator session ${flagId}`).start();
|
||||
let session;
|
||||
try {
|
||||
session = await DeviceRunSessionMutation.ensureDeviceRunSessionStoppedAsync(
|
||||
graphqlClient,
|
||||
flagId
|
||||
);
|
||||
stopSpinner.succeed(`🎉 Device run session ${session.id} is ${session.status.toLowerCase()}`);
|
||||
stopSpinner.succeed(`🎉 Simulator session ${session.id} is ${session.status.toLowerCase()}`);
|
||||
} catch (err) {
|
||||
stopSpinner.fail(`Failed to stop device run session ${flagId}`);
|
||||
stopSpinner.fail(`Failed to stop simulator session ${flagId}`);
|
||||
throw err;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
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() },
|
||||
UpdateQuery: { viewUpdateGroupAsync: jest.fn(), viewByUpdateAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../../graphql/queries/UpdateInsightsQuery', () => ({
|
||||
UpdateInsightsQuery: { viewUpdateGroupInsightsAsync: jest.fn() },
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
);
|
||||
@@ -155,4 +156,42 @@ 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,41 +1,188 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import { Args, 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';
|
||||
|
||||
export default class UpdateRollback extends EasCommand {
|
||||
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".';
|
||||
const defaultRollbackPlatforms = ['android', 'ios'] as const;
|
||||
|
||||
static override flags = {
|
||||
'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/`,
|
||||
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 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 { 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 { args, flags } = await this.parse(UpdateRollback);
|
||||
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
const groupId = args.groupId;
|
||||
const platform = flags.platform;
|
||||
const messageArg = flags.message;
|
||||
const privateKeyPathArg = flags['private-key-path']
|
||||
? ['--private-key-path', flags['private-key-path']]
|
||||
: [];
|
||||
if (choice === 'published') {
|
||||
await UpdateRepublish.run(privateKeyPathArg);
|
||||
|
||||
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,
|
||||
]);
|
||||
} else {
|
||||
await UpdateRollBackToEmbedded.run(privateKeyPathArg);
|
||||
const message = messageArg ?? 'Roll back to embedded';
|
||||
await UpdateRollBackToEmbedded.run([
|
||||
'--branch',
|
||||
sourceGroup.branchName,
|
||||
'--runtime-version',
|
||||
sourceGroup.runtimeVersion,
|
||||
'--message',
|
||||
message,
|
||||
...commonArgs,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +3,8 @@ 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';
|
||||
@@ -26,7 +28,7 @@ export default class UpdateView extends EasCommand {
|
||||
static override args = {
|
||||
groupId: Args.string({
|
||||
required: true,
|
||||
description: 'The ID of an update group.',
|
||||
description: 'The ID of an update group, or the ID of a platform-specific update.',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -58,7 +60,7 @@ export default class UpdateView extends EasCommand {
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const {
|
||||
args: { groupId },
|
||||
args: { groupId: idArg },
|
||||
flags: { json: jsonFlag, insights: insightsFlag, days, start, end },
|
||||
} = await this.parse(UpdateView);
|
||||
|
||||
@@ -74,7 +76,10 @@ export default class UpdateView extends EasCommand {
|
||||
enableJsonOutput();
|
||||
}
|
||||
|
||||
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, { groupId });
|
||||
const { groupId, updatesByGroup } = await UpdateView.resolveUpdateGroupAsync(
|
||||
graphqlClient,
|
||||
idArg
|
||||
);
|
||||
|
||||
let insightsSummary: UpdateInsightsSummary | null = null;
|
||||
if (insightsFlag) {
|
||||
@@ -112,4 +117,34 @@ 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,7 +5,9 @@ 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';
|
||||
@@ -14,7 +16,11 @@ 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,
|
||||
@@ -277,3 +283,78 @@ 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, AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { AuthCtx } from '../appstore/authenticateTypes';
|
||||
import { Target } from '../types';
|
||||
|
||||
export class ConfigureProvisioningProfile {
|
||||
@@ -34,12 +34,9 @@ export class ConfigureProvisioningProfile {
|
||||
throw new ForbidCredentialModificationError(
|
||||
'Remove the --freeze-credentials flag to configure a Provisioning Profile.'
|
||||
);
|
||||
} else if (
|
||||
ctx.nonInteractive &&
|
||||
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
|
||||
) {
|
||||
} else if (ctx.nonInteractive && !ctx.appStore.authCtx) {
|
||||
throw new InsufficientAuthenticationNonInteractiveError(
|
||||
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
|
||||
`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(
|
||||
'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, AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { AuthCtx } from '../appstore/authenticateTypes';
|
||||
import { provisioningProfileSchema } from '../credentials';
|
||||
import { Target } from '../types';
|
||||
|
||||
@@ -30,12 +30,9 @@ 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.defaultAuthenticationMode !== AuthenticationMode.API_KEY
|
||||
) {
|
||||
} else if (ctx.nonInteractive && !ctx.appStore.authCtx) {
|
||||
throw new InsufficientAuthenticationNonInteractiveError(
|
||||
`In order to generate a new Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
|
||||
`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(
|
||||
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
|
||||
)}`
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
filterDevicesForApplePlatform,
|
||||
formatDeviceLabel,
|
||||
} from './DeviceUtils';
|
||||
import { resolveAscApiKeyForAppCredentialsAsync } from './AscApiKeyUtils';
|
||||
import { SetUpDistributionCertificate } from './SetUpDistributionCertificate';
|
||||
import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action';
|
||||
import {
|
||||
@@ -22,8 +23,6 @@ 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 {
|
||||
@@ -35,13 +34,11 @@ 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';
|
||||
|
||||
@@ -424,6 +421,7 @@ 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,
|
||||
@@ -436,35 +434,6 @@ 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,5 +1,6 @@
|
||||
import nullthrows from 'nullthrows';
|
||||
|
||||
import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from './AscApiKeyUtils';
|
||||
import {
|
||||
assignBuildCredentialsAsync,
|
||||
getBuildCredentialsAsync,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
IosAppBuildCredentialsFragment,
|
||||
IosDistributionType,
|
||||
} from '../../../graphql/generated';
|
||||
import { learnMore } from '../../../log';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
import { getApplePlatformFromTarget } from '../../../project/ios/target';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import { CredentialsContext } from '../../context';
|
||||
@@ -25,7 +26,8 @@ import {
|
||||
} from '../../errors';
|
||||
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
|
||||
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
|
||||
import { AuthenticationMode } from '../appstore/authenticateTypes';
|
||||
import { resolveAppleTeamTypeFromEnvironment } from '../appstore/resolveCredentials';
|
||||
import { AppleTeamType } from '../appstore/authenticateTypes';
|
||||
import { Target } from '../types';
|
||||
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
|
||||
|
||||
@@ -102,8 +104,28 @@ export class SetUpProvisioningProfile {
|
||||
this.app,
|
||||
this.distributionType
|
||||
).runAsync(ctx);
|
||||
if (ctx.nonInteractive && !ctx.appStore.authCtx) {
|
||||
await tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
ctx,
|
||||
this.app,
|
||||
this.resolveTeamTypeForAuthentication()
|
||||
);
|
||||
}
|
||||
|
||||
const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (areBuildCredentialsSetup) {
|
||||
return nullthrows(await getBuildCredentialsAsync(ctx, this.app, this.distributionType));
|
||||
}
|
||||
@@ -111,12 +133,11 @@ export class SetUpProvisioningProfile {
|
||||
throw new ForbidCredentialModificationError(
|
||||
'Provisioning profile is not configured correctly. Remove the --freeze-credentials flag to configure it.'
|
||||
);
|
||||
} else if (
|
||||
ctx.nonInteractive &&
|
||||
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
|
||||
) {
|
||||
}
|
||||
|
||||
if (ctx.nonInteractive && !ctx.appStore.authCtx) {
|
||||
throw new InsufficientAuthenticationNonInteractiveError(
|
||||
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
|
||||
`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(
|
||||
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
|
||||
)}`
|
||||
);
|
||||
@@ -167,6 +188,24 @@ 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
|
||||
|
||||
+272
-1
@@ -19,8 +19,9 @@ import {
|
||||
ForbidCredentialModificationError,
|
||||
InsufficientAuthenticationNonInteractiveError,
|
||||
} from '../../../errors';
|
||||
import { AuthenticationMode } from '../../appstore/authenticateTypes';
|
||||
import { AppleTeamType, AuthenticationMode } from '../../appstore/authenticateTypes';
|
||||
import { validateProvisioningProfileAsync } from '../../validators/validateProvisioningProfile';
|
||||
import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from '../AscApiKeyUtils';
|
||||
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
|
||||
import { SetUpProvisioningProfile } from '../SetUpProvisioningProfile';
|
||||
|
||||
@@ -30,11 +31,15 @@ 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'];
|
||||
@@ -262,4 +267,270 @@ 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,6 +6,7 @@ import { promptAsync } from '../../../../prompts';
|
||||
import { AppleTeamType } from '../authenticateTypes';
|
||||
import * as Keychain from '../keychain';
|
||||
import {
|
||||
resolveAppleTeamTypeFromEnvironment,
|
||||
resolveAppleTeamAsync,
|
||||
resolveAscApiKeyAsync,
|
||||
resolveUserCredentialsAsync,
|
||||
@@ -73,6 +74,17 @@ 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;
|
||||
}
|
||||
|
||||
function resolveAppleTeamTypeFromEnvironment(): AppleTeamType | undefined {
|
||||
export function resolveAppleTeamTypeFromEnvironment(): AppleTeamType | undefined {
|
||||
if (!process.env.EXPO_APPLE_TEAM_TYPE) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Platform, Workflow } from '@expo/eas-build-job';
|
||||
import { Env, Platform, Workflow } from '@expo/eas-build-job';
|
||||
|
||||
import { createFingerprintAsync } from './cli';
|
||||
import { Fingerprint } from './types';
|
||||
@@ -16,7 +16,7 @@ export async function getFingerprintInfoFromLocalProjectForPlatformsAsync(
|
||||
projectId: string,
|
||||
vcsClient: Client,
|
||||
platforms: AppPlatform[],
|
||||
{ env }: { env?: Record<string, string> } = {}
|
||||
{ env }: { env?: Env } = {}
|
||||
): Promise<Fingerprint> {
|
||||
const workflows = await resolveWorkflowPerPlatformAsync(projectDir, vcsClient);
|
||||
const optionsFromWorkflow = getFingerprintOptionsFromWorkflow(platforms, workflows);
|
||||
|
||||
+689
-6
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,121 @@
|
||||
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 {
|
||||
PostHogOrganizationConnectionFragmentNode,
|
||||
PostHogProjectData,
|
||||
PostHogProjectFragmentNode,
|
||||
StartPostHogConnectionResult,
|
||||
} from '../types/PostHogConnection';
|
||||
|
||||
type StartPostHogConnectionMutation = {
|
||||
posthogOrganizationConnection: {
|
||||
startPostHogConnection: StartPostHogConnectionResult;
|
||||
};
|
||||
};
|
||||
|
||||
type StartPostHogConnectionMutationVariables = {
|
||||
input: CreatePostHogAccountRequestInput;
|
||||
};
|
||||
|
||||
type SetupPostHogProjectMutation = {
|
||||
posthogProject: {
|
||||
setupPostHogProject: PostHogProjectData;
|
||||
};
|
||||
};
|
||||
|
||||
type SetupPostHogProjectMutationVariables = {
|
||||
input: SetupPostHogProjectInput;
|
||||
};
|
||||
|
||||
type DeletePostHogProjectMutation = {
|
||||
posthogProject: {
|
||||
deletePostHogProject: string;
|
||||
};
|
||||
};
|
||||
|
||||
type DeletePostHogProjectMutationVariables = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const PostHogMutation = {
|
||||
async startPostHogConnectionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
input: CreatePostHogAccountRequestInput
|
||||
): Promise<StartPostHogConnectionResult> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.mutation<StartPostHogConnectionMutation, StartPostHogConnectionMutationVariables>(
|
||||
gql`
|
||||
mutation StartPostHogConnection($input: CreatePostHogAccountRequestInput!) {
|
||||
posthogOrganizationConnection {
|
||||
startPostHogConnection(input: $input) {
|
||||
__typename
|
||||
... on PostHogOrganizationConnection {
|
||||
id
|
||||
...PostHogOrganizationConnectionFragment
|
||||
}
|
||||
... on PostHogPendingConnection {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(PostHogOrganizationConnectionFragmentNode)}
|
||||
`,
|
||||
{ input },
|
||||
{ additionalTypenames: ['PostHogOrganizationConnection'] }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
return data.posthogOrganizationConnection.startPostHogConnection;
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -198,6 +198,11 @@ export const AccountQuery = {
|
||||
value
|
||||
limit
|
||||
}
|
||||
overageMetrics {
|
||||
id
|
||||
value
|
||||
}
|
||||
totalCost
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user