Compare commits

..
Author SHA1 Message Date
Mustafa AbbanandStanisław Chmiela 1222104094 Update packages/build-tools/src/buildErrors/__tests__/detectError.test.ts
Co-authored-by: Stanisław Chmiela <[email protected]>
2026-06-23 08:30:18 -04:00
AbbanMustafa 0f1fe92a46 remove catchalls 2026-05-05 01:17:30 -04:00
AbbanMustafa 245942348d fmt 2026-05-04 21:46:07 -04:00
AbbanMustafa ae6b38b925 [build-tools] - Add additional known error categories to build error handlers 2026-05-04 18:10:15 -04:00
Juwan Wheatley ddd8bb7fdd [eas-cli] Add Convex E2E runner (#3654)
# Why
The Convex integration needs a repeatable manual end-to-end check against the deployed API without putting flaky real-resource setup in CI.

# How
Adds `packages/eas-cli/scripts/convex-e2e.ts`, which bootstraps one fresh Expo app, runs the Convex connect/display/invite/dashboard/delete flow with the repo-local CLI, verifies env output and package install, and cleans up EAS-side links when safe. Usage and cleanup caveats live in the script header.

# Test Plan
Run locally from the top of the stack:
```sh
cd packages/eas-cli

yarn build

yarn ts-node scripts/convex-e2e.ts
```

Verification from the successful run:
```text
✔ Created @fiberjw/convex-e2e-molzwy56
✔ Installed the convex npm package
✔ Created Convex team fiberjw's team (Expo) / fiberjw-c7b29
✔ Created Convex project convex-e2e-molzwy56 with deployment zany-trout-356
✔ Sent Convex team invitation
✔ Wrote Convex config to .env.local
Verified .env.local contains Convex environment variables
Verified package.json includes convex@^1.37.0
Convex teams linked to @fiberjw
Team: fiberjw's team (Expo) / fiberjw-c7b29
Convex project linked to convex-e2e-molzwy56
✔ Opened https://dashboard.convex.dev/t/fiberjw-c7b29/convex-e2e-molzwy56
✔ Removed Convex project convex-e2e-molzwy56 from EAS servers
✔ Removed Convex team fiberjw's team (Expo) / fiberjw-c7b29 from EAS servers
Convex-side resources are not destroyed by this cleanup.
```
2026-05-01 18:45:20 +09:00
Juwan Wheatley ed251cb0d1 [eas-cli] Add Convex dashboard command (#3653)
# Why
Users need a quick way to open the Convex dashboard for the linked app project.

# How
Adds `eas integrations:convex:dashboard` to open the linked project dashboard, with empty-state and browser-open failure handling.

# Test Plan
Local E2E rerun after the formatting change. Linked project setup is currently blocked by the API project setup throttle, so this rerun covers the no-project dashboard path from a fresh Expo app.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
cd /tmp/eas-convex-e2e-teamfmt
$REPO/packages/eas-cli/bin/run integrations:convex:dashboard
```

Output/verification:
```text
No Convex project is linked to Expo app eas-convex-e2e-teamfmt on EAS.
```
2026-05-01 18:40:54 +09:00
Juwan Wheatley 5176495e9e [eas-cli] Add Convex project delete command (#3652)
# Why
Users need a way to remove the EAS-side Convex project link for an Expo app.

# How
Adds `eas integrations:convex:project:delete`, with `--yes`, non-interactive handling, and prompts that include the Convex dashboard URL and state Convex-side resources are not destroyed.

# Test Plan
Local E2E rerun after the formatting change. Linked project setup is currently blocked by the API project setup throttle, so this rerun covers the no-project delete path from a fresh Expo app.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
cd /tmp/eas-convex-e2e-teamfmt
$REPO/packages/eas-cli/bin/run integrations:convex:project:delete --non-interactive --yes
```

Output/verification:
```text
No Convex project is linked to Expo app eas-convex-e2e-teamfmt on EAS.
```
2026-05-01 18:35:42 +09:00
Juwan Wheatley 32211614eb [eas-cli] Add Convex project command (#3651)
# Why
Users need to inspect the Convex project linked to the current Expo app.

# How
Adds `eas integrations:convex:project` to display the app-linked Convex project with IDs, slugs, identifiers, team, and dashboard URL. The team line now uses the human-readable team name/slug first, then the Convex ID.

# Test Plan
Local E2E rerun after the formatting change. Linked project setup is currently blocked by the API project setup throttle, so this rerun covers the command's no-project state from a fresh Expo app.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
cd /tmp/eas-convex-e2e-teamfmt
$REPO/packages/eas-cli/bin/run integrations:convex:project
```

Output/verification:
```text
No Convex project is linked to Expo app eas-convex-e2e-teamfmt on EAS.
```
2026-05-01 18:30:43 +09:00
Juwan Wheatley ce5b48244c [eas-cli] Add Convex team delete command (#3650)
# Why
Users need a way to remove stale EAS-side Convex team links.

# How
Adds `eas integrations:convex:team:delete`, with selection by ID/prompt, `--yes`, non-interactive handling, and prompts that state Convex-side resources are not destroyed.

# Test Plan
Local E2E rerun against the team link created in a fresh Expo app before project setup was API-throttled.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
cd /tmp/eas-convex-e2e-teamfmt
$REPO/packages/eas-cli/bin/run integrations:convex:team:delete 019dda27-c450-7647-8ce4-97124c421f91 --non-interactive --yes
$REPO/packages/eas-cli/bin/run integrations:convex:team
```

Output/verification:
```text
Team: fiberjw's team (Expo) / fiberjw-ef4a2 (Convex ID: 478548)
EAS connection ID: 019dda27-c450-7647-8ce4-97124c421f91
Dashboard: https://dashboard.convex.dev/t/fiberjw-ef4a2
Invited email: [email protected]
Removing the Convex team link from EAS servers. This does not destroy resources on Convex: https://dashboard.convex.dev/t/fiberjw-ef4a2
✔ Removed Convex team fiberjw's team (Expo) / fiberjw-ef4a2 (Convex ID: 478548) from EAS servers
No Convex team is linked to account fiberjw on EAS.
```
2026-05-01 18:26:10 +09:00
Juwan Wheatley ece1d00ca7 [eas-cli] Add Convex team invite command (#3649)
# Why
Users may need to resend a Convex team invite if they missed the original email.

# How
Adds `eas integrations:convex:team:invite`, including previous invite metadata, recent-resend confirmation in interactive mode, and non-interactive warning behavior.

# Test Plan
Local E2E rerun against the team link created in a fresh Expo app before project setup was API-throttled.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
cd /tmp/eas-convex-e2e-teamfmt
$REPO/packages/eas-cli/bin/run integrations:convex:team:invite --non-interactive
```

Output/verification:
```text
Convex team fiberjw's team (Expo) / fiberjw-ef4a2 (Convex ID: 478548)
EAS connection ID: 019dda27-c450-7647-8ce4-97124c421f91
Dashboard: https://dashboard.convex.dev/t/fiberjw-ef4a2
- Sending Convex team invitation to [email protected]
✔ Sent Convex team invitation to [email protected] for fiberjw's team (Expo) / fiberjw-ef4a2 (Convex ID: 478548)
Convex dashboard: https://dashboard.convex.dev/t/fiberjw-ef4a2
```
2026-05-01 18:21:22 +09:00
Juwan Wheatley 82dd218a2b [eas-cli] Add Convex team command (#3648)
# Why
Users need to inspect Convex team links stored for the current Expo app owner account.

# How
Adds `eas integrations:convex:team` to list linked Convex teams with the human-readable team name/slug first, then the Convex ID, plus the EAS connection ID, dashboard URL, and invite metadata.

# Test Plan
Local E2E rerun against a fresh Expo app. Project setup is currently API-throttled, but team creation succeeded before setup was rejected, allowing the display command to be verified.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
npx create-expo-app@latest /tmp/eas-convex-e2e-teamfmt --yes
cd /tmp/eas-convex-e2e-teamfmt
$REPO/packages/eas-cli/bin/run init --non-interactive --force
$REPO/packages/eas-cli/bin/run integrations:convex:connect --non-interactive --project-name e2eteamfmt || true
$REPO/packages/eas-cli/bin/run integrations:convex:team
```

Output/verification:
```text
✔ Created @fiberjw/eas-convex-e2e-teamfmt
✔ Project successfully linked (ID: 194b1351-323e-4b8d-a0ce-af93fee1ac74)
✔ Created Convex team fiberjw's team (Expo) / fiberjw-ef4a2 (Convex ID: 478548)
Convex teams linked to @fiberjw
Team: fiberjw's team (Expo) / fiberjw-ef4a2 (Convex ID: 478548)
EAS connection ID: 019dda27-c450-7647-8ce4-97124c421f91
Dashboard: https://dashboard.convex.dev/t/fiberjw-ef4a2
```
2026-05-01 18:12:36 +09:00
Juwan Wheatley 9f763c6957 [eas-cli] Add Convex connect command (#3575)
# Why
Expo is integrating with Convex so users can provision an EAS-linked Convex backend from the EAS CLI.

# How
Adds `eas integrations:convex:connect`, which installs `convex`, creates or reuses an EAS-side Convex team link, sets up the app project, sends a verified-email invite with recent-resend confirmation, writes `CONVEX_DEPLOY_KEY` and `EXPO_PUBLIC_CONVEX_URL` to `.env.local`, and points users at `npx convex dev`.

This PR also includes the generated GraphQL types, Convex GraphQL helpers, shared Convex formatting/prompt utilities, and the changelog entry for the stack.

# Test Plan
Local E2E rerun after updating Convex team output formatting.

Commands run (with `REPO=/Users/fiberjw/Developer/expo/eas-cli`):
```sh
cd packages/eas-cli && yarn build
npx create-expo-app@latest /tmp/eas-convex-e2e-3575-fmt --yes
cd /tmp/eas-convex-e2e-3575-fmt
$REPO/packages/eas-cli/bin/run init --non-interactive --force
$REPO/packages/eas-cli/bin/run integrations:convex:connect --non-interactive --project-name e2e3575fmt
```

Output/verification:
```text
yarn build exited 0
✅ Your project is ready!
✔ Created @fiberjw/eas-convex-e2e-3575-fmt
✔ Project successfully linked (ID: 56a35c21-e0a0-4423-a3bd-070cd5440370)
✔ Installed the convex npm package
✔ Created Convex team fiberjw's team (Expo) / fiberjw-1d252 (Convex ID: 478547)
- Setting up Convex project
Too many Convex project setup attempts. Try again later.
Request ID: 89cc010c-e894-49df-9214-85f60d7bb334
```

The project setup API is temporarily throttled for about an hour, so the full successful connect flow could not be rerun after the formatting change. The EAS-side team link created by this blocked run was removed afterward.
2026-05-01 18:07:44 +09:00
Juwan Wheatley e74c93d80c [eas-cli] Resolve GraphQL codegen URL from API config (#3636)
# Why

Addresses the review request from https://github.com/expo/eas-cli/pull/3575#discussion_r3041624004 to have GraphQL codegen use the same Expo API URL logic as the CLI instead of hardcoding or duplicating the staging GraphQL endpoint.

# How

Replaced `graphql-codegen.yml` with a typed `graphql-codegen.ts` config.

The config imports `getExpoApiBaseUrl()` from `src/api.ts` and computes the schema URL directly in the codegen config:

- `GRAPHQL_SCHEMA_URL=... yarn generate-graphql-code` still uses the explicit override
- `EXPO_LOCAL=1 yarn generate-graphql-code` defaults to `http://127.0.0.1:3000/graphql`
- `EXPO_STAGING=1 yarn generate-graphql-code` defaults to `https://staging-api.expo.dev/graphql`
- default codegen uses `https://api.expo.dev/graphql`

This removes the package-script shell substitution and the one-off resolver script.

# Test Plan

```bash
cd packages/eas-cli

yarn ts-node -e "import config from './graphql-codegen'; console.log(config.schema)"
# https://api.expo.dev/graphql

EXPO_STAGING=1 yarn ts-node -e "import config from './graphql-codegen'; console.log(config.schema)"
# https://staging-api.expo.dev/graphql

EXPO_LOCAL=1 yarn ts-node -e "import config from './graphql-codegen'; console.log(config.schema)"
# http://127.0.0.1:3000/graphql

GRAPHQL_SCHEMA_URL=http://example.test/graphql yarn ts-node -e "import config from './graphql-codegen'; console.log(config.schema)"
# http://example.test/graphql

EXPO_LOCAL=1 yarn generate-graphql-code
yarn build
```
I tested this locally with the local WWW server both alive and dead to make sure it was calling into the API:

<img width="1864" height="1878" alt="Screenshot 2026-04-28 at 01 05 33" src="https://github.com/user-attachments/assets/18e5d017-2c3b-4c45-8b78-b471bcef93e2" />
2026-05-01 05:51:01 +09:00
MalcolmandClaude Sonnet 4.6 3d691d602c [eas-cli] Fix invalid json for eas update with --json flag (#3659)
* [eas-cli] Pass silent:true to @expo/fingerprint in createFingerprintWithoutLoggingAsync

@expo/fingerprint spawns subprocesses concurrently (autolinking, react-native
config, ExpoConfigLoader) that can write to stdout, corrupting the parent
process's output when --json is in use. The silent option exists specifically
to suppress this, but was never set. The function is already named
WithoutLogging so this is the correct default regardless of --json.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* [eas-cli] Add changelog entry for fingerprint silent fix

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
2026-04-30 11:46:25 -07:00
Stanisław Chmiela 7fde621858 [build-tools] Fix worker integration test mocks (#3662)
## Summary
- Mock `@expo/turtle-spawn` to surface the missing `expo/package.json` error path in the worker integration test
- Mock `turtleFetch` so the test can proceed without external fetch behavior
2026-04-30 18:39:45 +02:00
Ash Wu 91c9331e43 [eas-cli] remove image override in eas build:resign (#3661)
* [eas-cli] remove image override in eas build:resign

Signed-off-by: Ash Wu <[email protected]>

* [eas-cli] add changelog

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2026-04-30 22:57:39 +08:00
Stanisław Chmiela 02ba5880c4 [build-tools] Add expo package version to Datadog error logs (#3658)
## Summary
- Resolve the installed Expo package version in the worker when sending Turtle error logs.
- Tag Datadog error logs with `expo_package_version` while leaving `sdk_version` unchanged.
- Add unit coverage for exact-version resolution, missing package resolution, invalid semver, and the error-log payload.

## Testing
- `yarn workspace @expo/worker test:unit -- service.test.ts`
- `yarn workspace @expo/worker typecheck`
- `yarn exec oxfmt .`
2026-04-30 16:09:03 +02:00
Ash Wu 8229dc8265 [build-tools] Port Maestro Tests bash step to TypeScript (#3641)
* [build-tools] Port Maestro Tests bash step to TypeScript

Replace the bash shellscript step (defined in www/WorkflowMaestroTestJob)
with a typed BuildFunction. Behaviorally identical to the bash version:
runs maestro with the full flow_paths set, retries the whole set on
failure, copies the latest attempt's JUnit XML to final_report_path.

Smart retry (only re-running failed flows) is added in a follow-up PR.

Signed-off-by: Ash Wu <[email protected]>

* address comments

Signed-off-by: Ash Wu <[email protected]>

* address comments 2

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2026-04-30 17:47:35 +08:00
Stanisław Chmiela 15a456970b [build-tools] Scope precompiled modules env to pod install (#3646)
## Summary

- Stops adding precompiled modules environment variables to the whole worker build environment.
- Adds `EXPO_USE_PRECOMPILED_MODULES=1` only to the iOS `pod install` environment when requested and when the installed Expo package is at least `55.0.18`.
- Logs the detected Expo package version, the reason precompiled modules are not enabled, or the exact pod-install environment values that are added.
- Leaves the precompiled modules base URL helper commented out for the later third-party module rollout.

## Validation

- `yarn oxfmt packages/build-tools/src/ios/pod.ts packages/build-tools/src/ios/__tests__/pod.test.ts packages/worker/src/env.ts packages/worker/src/__unit__/env.test.ts`
- `yarn workspace @expo/build-tools jest-unit pod.test.ts`
- `yarn workspace @expo/worker test:unit -- env.test.ts`
- `yarn workspace @expo/build-tools typecheck`
- `yarn workspace @expo/worker typecheck`
2026-04-30 11:31:52 +02:00
Expo CI e74751c26c update CHANGELOG.md 2026-04-30 00:35:41 +00:00
43 changed files with 4963 additions and 80 deletions

No files matched your search

+7
View File
@@ -8,10 +8,17 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add `eas integrations:convex` commands to manage Convex integrations for EAS projects. ([#3575](https://github.com/expo/eas-cli/pull/3575) by [@fiberjw](https://github.com/fiberjw))
### 🐛 Bug fixes
- [eas-cli] Remove hardcoded `builderEnvironment.image` override in `eas build:resign`. ([#3661](https://github.com/expo/eas-cli/pull/3661) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Fix `eas update --json` intermittently failing with JSON parse errors during "Computing project fingerprints" by passing `silent: true` to `@expo/fingerprint` to suppress subprocess stdout pollution. ([#3659](https://github.com/expo/eas-cli/pull/3659) by [@Mookiies](https://github.com/Mookiies))
### 🧹 Chores
## [18.9.1](https://github.com/expo/eas-cli/releases/tag/v18.9.1) - 2026-04-30
## [18.9.0](https://github.com/expo/eas-cli/releases/tag/v18.9.0) - 2026-04-30
### 🎉 New features
@@ -309,4 +309,217 @@ Refer to "Xcode Logs" below for additional, more detailed logs.`);
`Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information.`
);
});
it('detects NPM_ERESOLVE tracking code', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
[
'npm ERR! code ERESOLVE',
'npm ERR! ERESOLVE could not resolve',
'npm ERR! While resolving: [email protected]',
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.trackingCode).toBe('NPM_ERESOLVE');
});
it('detects METRO_UNABLE_TO_RESOLVE tracking code', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
[
'error: Unable to resolve module ./src/missing from /home/expo/workingdir/build/index.js: ',
'None of these files exist:',
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.PREBUILD,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.trackingCode).toBe('METRO_UNABLE_TO_RESOLVE');
});
it('detects METRO_UNABLE_TO_RESOLVE without phase restriction', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
[
'error: Unable to resolve module @react-native/assets from /home/expo/workingdir/build/App.js',
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.EAGER_BUNDLE,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.trackingCode).toBe('METRO_UNABLE_TO_RESOLVE');
});
it('detects PNPM_ERROR tracking code', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
[
' ERR_PNPM_PEER_DEP_ISSUES Unmet peer dependencies',
'hint: If you want peer dependencies to be automatically installed, add "auto-install-peers=true" to an .npmrc file.',
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.trackingCode).toBe('PNPM_ERROR');
});
it('does not detect PNPM_ERROR outside INSTALL_DEPENDENCIES phase', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
[' ERR_PNPM_PEER_DEP_ISSUES Unmet peer dependencies'],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.PREBUILD,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.trackingCode).toBeUndefined();
});
it('detects PREBUILD_DANGEROUS_MOD_ENOENT tracking code', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
[
"Error: [android.dangerous]: withAndroidDangerousBaseMod: ENOENT: no such file or directory, open './assets/splash.png'",
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.PREBUILD,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.trackingCode).toBe('PREBUILD_DANGEROUS_MOD_ENOENT');
});
it('detects PREBUILD_DANGEROUS_MOD_ENOENT for iOS', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
[
"Error: [ios.dangerous]: withIosDangerousBaseMod: ENOENT: no such file or directory, open './assets/fonts/CustomFont.ttf'",
],
{
job: { platform: Platform.IOS } as Job,
phase: BuildPhase.PREBUILD,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.trackingCode).toBe('PREBUILD_DANGEROUS_MOD_ENOENT');
});
it('detects SYNTAX_ERROR tracking code', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
['SyntaxError: Unexpected token } in JSON at position 1234'],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.PREBUILD,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.trackingCode).toBe('SYNTAX_ERROR');
});
it('detects MONOREPO_PACKAGE_JSON_NOT_FOUND tracking code', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
['Error: package.json does not exist at /home/expo/workingdir/build/packages/app'],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.trackingCode).toBe('MONOREPO_PACKAGE_JSON_NOT_FOUND');
});
it('detects EXPO_CONFIG_ERROR tracking code', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
['ConfigError: Property "expo.ios.bundleIdentifier" in app.json is invalid.'],
{
job: { platform: Platform.IOS } as Job,
phase: BuildPhase.PREBUILD,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.trackingCode).toBe('EXPO_CONFIG_ERROR');
});
it('detects RUNTIME_VERSION_MISMATCH tracking code', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
['Error: runtimeVersion in Expo.plist policies must be set to a valid value.'],
{
job: { platform: Platform.IOS } as Job,
phase: BuildPhase.CONFIGURE_EXPO_UPDATES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.trackingCode).toBe('RUNTIME_VERSION_MISMATCH');
});
it('does not detect RUNTIME_VERSION_MISMATCH outside CONFIGURE_EXPO_UPDATES phase', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
['Error: runtimeVersion in Expo.plist policies must be set to a valid value.'],
{
job: { platform: Platform.IOS } as Job,
phase: BuildPhase.PREBUILD,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.trackingCode).toBeUndefined();
});
it('detects CONFIG_PLUGIN_RESOLVE_ERROR tracking code', async () => {
const err = await resolveBuildPhaseErrorAsync(
new Error(),
[
'Error: Failed to resolve plugin for module "expo-camera" relative to "/home/expo/workingdir/build"',
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.PREBUILD,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
expect(err.trackingCode).toBe('CONFIG_PLUGIN_RESOLVE_ERROR');
});
});
@@ -361,4 +361,69 @@ export const buildErrorHandlers: ErrorHandler<TrackedBuildError>[] = [
'fastlane: Bundle React Native code and images failed.'
),
},
// --- Specific patterns for reducing unknown_error bucket ---
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// npm ERR! ERESOLVE could not resolve
regexp: /npm ERR!.*ERESOLVE|ERESOLVE could not resolve/,
createError: () => new TrackedBuildError('NPM_ERESOLVE', 'npm: ERESOLVE could not resolve.'),
},
{
phase: BuildPhase.INSTALL_DEPENDENCIES,
// ERR_PNPM_PEER_DEP_ISSUES or other pnpm errors
regexp: /ERR_PNPM_/,
createError: () => new TrackedBuildError('PNPM_ERROR', 'pnpm: error during install.'),
},
{
// Can occur in PREBUILD, EAGER_BUNDLE, or other phases
// Unable to resolve module `./src/missing` from `index.js`
regexp: /Unable to resolve module/,
createError: () =>
new TrackedBuildError('METRO_UNABLE_TO_RESOLVE', 'metro: Unable to resolve module.'),
},
{
phase: BuildPhase.PREBUILD,
// Error: [android.dangerous]: withAndroidDangerousBaseMod: ENOENT: no such file or directory, open './assets/splash.png'
// Must come AFTER EXPO_CLI_MISSING_ICON which matches the icon-specific subset
regexp: /with(?:Android|Ios)DangerousBaseMod:.*ENOENT/,
createError: () =>
new TrackedBuildError(
'PREBUILD_DANGEROUS_MOD_ENOENT',
'prebuild: ENOENT in dangerous base mod.'
),
},
{
// SyntaxError: Unexpected token ...
regexp: /SyntaxError:/,
createError: () => new TrackedBuildError('SYNTAX_ERROR', 'SyntaxError encountered.'),
},
{
// package.json does not exist (common in monorepos with wrong working directory)
regexp: /package\.json does not exist/,
createError: () =>
new TrackedBuildError('MONOREPO_PACKAGE_JSON_NOT_FOUND', 'package.json does not exist.'),
},
{
// ConfigError: Property ... in app.json is invalid
regexp: /ConfigError:/,
createError: () => new TrackedBuildError('EXPO_CONFIG_ERROR', 'expo: ConfigError encountered.'),
},
{
phase: BuildPhase.CONFIGURE_EXPO_UPDATES,
// runtimeVersion policies must be set ...
// runtime version is not equal ...
regexp: /runtimeVersion.*policies.*must.*set|runtime version.*not.*equal/i,
createError: () =>
new TrackedBuildError('RUNTIME_VERSION_MISMATCH', 'expo-updates: runtime version mismatch.'),
},
{
phase: BuildPhase.PREBUILD,
// Failed to resolve plugin for module "expo-camera" ...
regexp: /Failed to resolve plugin/,
createError: () =>
new TrackedBuildError(
'CONFIG_PLUGIN_RESOLVE_ERROR',
'prebuild: Failed to resolve config plugin.'
),
},
];
@@ -0,0 +1,149 @@
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import path from 'path';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { installPods } from '../pod';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const WORKING_DIR = '/workingdir';
const PROJECT_DIR = path.join(WORKING_DIR, 'build');
const IOS_DIR = path.join(PROJECT_DIR, 'ios');
function makeIosBuildContext({
expoVersion,
usePrecompiledModules = true,
}: {
expoVersion?: string;
usePrecompiledModules?: boolean;
}): BuildContext<Ios.Job> {
vol.fromJSON({
[path.join(IOS_DIR, '.keep')]: '',
...(expoVersion
? {
[path.join(PROJECT_DIR, 'node_modules/expo/package.json')]: JSON.stringify({
version: expoVersion,
}),
}
: {}),
});
const job: Ios.Job = {
...createTestIosJob(),
builderEnvironment: {
env: {
...(usePrecompiledModules ? { EAS_USE_PRECOMPILED_MODULES: '1' } : {}),
},
},
};
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
}
describe(installPods, () => {
beforeEach(() => {
vol.reset();
(spawn as jest.Mock).mockImplementation(command => {
if (command === 'node') {
return Promise.resolve({
stdout: Buffer.from(path.join(PROJECT_DIR, 'node_modules/expo/package.json')),
});
}
return Promise.resolve(undefined);
});
});
it('adds precompiled modules env var for Expo versions that support them', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.18' });
await installPods(ctx, {});
expect(spawn).toHaveBeenCalledWith(
'node',
['--print', "require.resolve('expo/package.json')"],
expect.objectContaining({
cwd: PROJECT_DIR,
env: expect.objectContaining({
__API_SERVER_URL: 'http://api.expo.test',
}),
stdio: 'pipe',
})
);
expect(spawn).toHaveBeenCalledWith(
'pod',
['install'],
expect.objectContaining({
cwd: IOS_DIR,
env: expect.objectContaining({
EXPO_USE_PRECOMPILED_MODULES: '1',
}),
})
);
expect(
(spawn as jest.Mock).mock.calls[1][2].env.EXPO_PRECOMPILED_MODULES_BASE_URL
).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=55.0.18; enabling precompiled modules use. Installing pods with additional environment variables.\nEXPO_USE_PRECOMPILED_MODULES=1\nPrecompiled modules pod install environment is configured.'
);
});
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=55.0.17; not enabling precompiled modules use because precompiled modules require expo>=55.0.18.'
);
});
it('does not add precompiled modules env vars when Expo version is invalid', async () => {
const ctx = makeIosBuildContext({ expoVersion: 'invalid-version' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
'Detected expo=invalid-version; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.'
);
});
it('does not add precompiled modules env vars when Expo version detection fails', async () => {
const ctx = makeIosBuildContext({});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[1][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.objectContaining({ code: 'ENOENT' }) }),
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
);
});
it('does not add precompiled modules env vars without the builder flag', async () => {
const ctx = makeIosBuildContext({
expoVersion: '55.0.18',
usePrecompiledModules: false,
});
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).not.toHaveBeenCalled();
});
});
+82 -1
View File
@@ -1,9 +1,14 @@
import { Ios } from '@expo/eas-build-job';
import { Env, Ios } from '@expo/eas-build-job';
import spawn, { SpawnOptions, SpawnPromise, SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import { BuildContext } from '../context';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.18';
// const PRECOMPILED_MODULES_BASE_URL = 'https://storage.googleapis.com/eas-build-precompiled-modules/';
export async function installPods<TJob extends Ios.Job>(
ctx: BuildContext<TJob>,
{ infoCallbackFn }: SpawnOptions
@@ -12,6 +17,7 @@ export async function installPods<TJob extends Ios.Job>(
const verboseFlag = ctx.env['EAS_VERBOSE'] === '1' ? ['--verbose'] : [];
const cocoapodsDeploymentFlag = ctx.env['POD_INSTALL_DEPLOYMENT'] === '1' ? ['--deployment'] : [];
const precompiledModulesEnv = await resolvePrecompiledModulesPodInstallEnvAsync(ctx);
return {
spawnPromise: spawn('pod', ['install', ...verboseFlag, ...cocoapodsDeploymentFlag], {
@@ -20,6 +26,7 @@ export async function installPods<TJob extends Ios.Job>(
env: {
...ctx.env,
LANG: 'en_US.UTF-8',
...precompiledModulesEnv,
},
lineTransformer: (line?: string) => {
if (
@@ -35,3 +42,77 @@ export async function installPods<TJob extends Ios.Job>(
}),
};
}
async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<Env> {
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
return {};
}
let expoPackageVersion: string;
try {
expoPackageVersion = await getInstalledExpoPackageVersionAsync(ctx);
} catch (err) {
ctx.logger.info(
{ err },
'Failed to detect installed Expo package version; not enabling precompiled modules use.'
);
return {};
}
const validExpoPackageVersion = semver.valid(expoPackageVersion);
if (!validExpoPackageVersion) {
ctx.logger.info(
`Detected expo=${expoPackageVersion}; not enabling precompiled modules use because the installed Expo package version is not a valid semver version.`
);
return {};
}
if (semver.lt(validExpoPackageVersion, MIN_PRECOMPILED_MODULES_EXPO_VERSION)) {
ctx.logger.info(
`Detected expo=${validExpoPackageVersion}; not enabling precompiled modules use because precompiled modules require expo>=${MIN_PRECOMPILED_MODULES_EXPO_VERSION}.`
);
return {};
}
// Start rollout with Expo precompiled modules only. Add third-party modules after this is stable.
const env: Env = {
EXPO_USE_PRECOMPILED_MODULES: '1',
// EXPO_PRECOMPILED_MODULES_BASE_URL: getPrecompiledModulesBaseUrl(),
};
ctx.logger.info(
`Detected expo=${validExpoPackageVersion}; enabling precompiled modules use. Installing pods with additional environment variables.\n${Object.entries(
env
)
.map(([key, value]) => `${key}=${value}`)
.join('\n')}\nPrecompiled modules pod install environment is configured.`
);
return env;
}
async function getInstalledExpoPackageVersionAsync<TJob extends Ios.Job>(
ctx: BuildContext<TJob>
): Promise<string> {
const { stdout } = await spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
stdio: 'pipe',
});
const expoPackageJsonPath = stdout.toString().trim();
return (await fs.readJson(expoPackageJsonPath)).version;
}
// function getPrecompiledModulesBaseUrl<TJob extends Ios.Job>(ctx: BuildContext<TJob>): string {
// if (!ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL) {
// return PRECOMPILED_MODULES_BASE_URL;
// }
//
// const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
// return PRECOMPILED_MODULES_BASE_URL.replace(
// `${parsedUrl.protocol}//${parsedUrl.host}`,
// `${ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL.replace(/\/$/, '')}/${parsedUrl.host}`
// );
// }
@@ -35,6 +35,7 @@ import { createRestoreCacheFunction } from './functions/restoreCache';
import { parseXcactivitylogFunction } from './functions/parseXcactivitylog';
import { runFastlaneFunction } from './functions/runFastlane';
import { runGradleFunction } from './functions/runGradle';
import { createMaestroTestsBuildFunction } from './functions/maestroTests';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
@@ -93,6 +94,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createUploadToAscBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(),
];
if (ctx.hasBuildJob()) {
@@ -1,6 +1,8 @@
import fs from 'fs/promises';
import { vol } from 'memfs';
import {
copyLatestAttemptXml,
parseFlowMetadata,
parseJUnitTestCases,
parseMaestroResults,
@@ -803,3 +805,22 @@ describe(parseJUnitTestCases, () => {
expect(results[0].properties).toEqual({});
});
});
describe('copyLatestAttemptXml', () => {
it('picks the highest attempt number', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': '<a/>',
'/tmp/r/android-maestro-junit-attempt-1.xml': '<b/>',
'/tmp/r/android-maestro-junit-attempt-2.xml': '<c/>',
});
await copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' });
expect(await fs.readFile('/tmp/out.xml', 'utf-8')).toBe('<c/>');
});
it('throws when source has no xml files', async () => {
vol.fromJSON({ '/tmp/r/.keep': '' });
await expect(
copyLatestAttemptXml({ sourceDir: '/tmp/r', outputPath: '/tmp/out.xml' })
).rejects.toThrow();
});
});
@@ -0,0 +1,473 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as parser from '../maestroResultParser';
import { createMaestroTestsBuildFunction } from '../maestroTests';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../utils/retry', () => ({
...jest.requireActual('../../../utils/retry'),
sleepAsync: jest.fn().mockResolvedValue(undefined),
}));
const mockedSpawn = jest.mocked(spawn);
const SPAWN_SUCCESS = {
status: 0,
pid: 1,
output: [''],
stdout: '',
stderr: '',
signal: null,
} as any;
const rejectExit1 = (): Error => {
const e: any = new Error('exit 1');
e.status = 1;
return e;
};
function createStep(
callInputs?: Record<string, unknown>,
options: { env?: Record<string, string | undefined> } = {}
): ReturnType<
ReturnType<typeof createMaestroTestsBuildFunction>['createBuildStepFromFunctionCall']
> {
const logger = createMockLogger();
const fn = createMaestroTestsBuildFunction();
const globalCtx = createGlobalContextMock({ logger });
globalCtx.updateEnv(options.env ?? { HOME: '/home/expo' });
return fn.createBuildStepFromFunctionCall(globalCtx, { callInputs });
}
describe('createMaestroTestsBuildFunction', () => {
beforeEach(() => {
mockedSpawn.mockReset();
// Default-stub copyLatestAttemptXml so tests that don't care about it
// don't hit the real disk. Individual tests override this.
jest.restoreAllMocks();
jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
});
it('exports a factory that returns a BuildFunction instance', () => {
expect(createMaestroTestsBuildFunction()).toBeDefined();
});
it('sets all outputs before running any flows', async () => {
// Make spawn reject so the step still throws; we're locking in that outputs
// are set BEFORE the spawn call (downstream `upload_artifact` fails if
// `final_report_path` is empty).
mockedSpawn.mockRejectedValue(new Error('stop'));
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow();
expect(step.getOutputValueByName('junit_report_directory')).toMatch(
/\.maestro\/tests\/junit-reports$/
);
expect(step.getOutputValueByName('tests_directory')).toMatch(/\.maestro\/tests$/);
expect(step.getOutputValueByName('final_report_path')).toMatch(
/\.maestro\/tests\/android-maestro-junit\.xml$/
);
});
it('succeeds when maestro exits 0 on first attempt', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(1);
const [cmd, args] = mockedSpawn.mock.calls[0];
expect(cmd).toBe('maestro');
expect(args).toEqual(expect.arrayContaining(['test', 'flows/a.yaml', 'flows/b.yaml']));
expect(args).toEqual(expect.arrayContaining(['--format=JUNIT']));
expect(args!.join(' ')).toMatch(/--output=.*android-maestro-junit-attempt-0\.xml/);
});
it('throws SystemError when spawn fails with ENOENT (binary missing)', async () => {
mockedSpawn.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws SystemError for unknown-shape spawn rejections', async () => {
mockedSpawn.mockRejectedValue(new Error('mystery'));
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws SystemError on signal-only spawn rejections (OOM / SIGTERM)', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('killed'), {
status: null,
signal: 'SIGKILL',
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('treats reject-with-status as a retryable test failure', async () => {
const rejectWithExit = (status: number): Error =>
Object.assign(new Error(`maestro exited with non-zero code: ${status}`), {
status,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
});
mockedSpawn.mockRejectedValueOnce(rejectWithExit(1)).mockResolvedValueOnce(SPAWN_SUCCESS);
// Default `retries` is 0, so even though the first attempt rejects with
// `status: 1`, no retry occurs and the post-loop check throws UserError.
// Spawn ran once.
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
expect(mockedSpawn).toHaveBeenCalledTimes(1);
});
it('retries all flows on failure', async () => {
const spawnMock = mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'],
retries: 1,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(spawnMock.mock.calls[0][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
expect(spawnMock.mock.calls[1][1]).toEqual(
expect.arrayContaining(['flows/a.yaml', 'flows/b.yaml', 'flows/c.yaml'])
);
});
it('writes final report via copyLatestAttemptXml on success', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
expect(copySpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('swallows copyLatestAttemptXml failure when maestro succeeded', async () => {
// Throwing on copy failure would mask the real reason for early-failing
// maestro runs (bad YAML writes no *.xml) and would also wrap a user-side
// failure as SystemError, which cancels the build's billing.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
jest
.spyOn(parser, 'copyLatestAttemptXml')
.mockRejectedValue(Object.assign(new Error('no space'), { code: 'ENOSPC' }));
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).resolves.toBeUndefined();
});
it('surfaces ERR_MAESTRO_TESTS_FAILED (not copy error) when maestro fails and copy fails', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('exit 1'), {
status: 1,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
jest.spyOn(parser, 'copyLatestAttemptXml').mockRejectedValue(new Error('No *.xml files found'));
const step = createStep({
flow_path: ['a.yaml'],
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries are exhausted with a non-zero exit', async () => {
mockedSpawn.mockRejectedValue(
Object.assign(new Error('exit 1'), {
status: 1,
signal: null,
stdout: '',
stderr: '',
output: ['', ''],
pid: 1,
})
);
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 2,
output_format: 'junit',
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError early on empty flow_path', async () => {
const step = createStep({ flow_path: [], platform: 'android' });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('uses $HOME/.maestro/tests output path for non-junit formats (e.g. html)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'html',
platform: 'android',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
// Non-JUnit uses a fixed path inside $HOME/.maestro/tests so the
// whole-directory upload picks it up.
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/android-maestro-html\.html$/);
expect(outputArg).not.toMatch(/junit-reports/);
});
it('uses lowercase extension for non-junit formats regardless of input casing', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'HTML',
platform: 'ios',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/\.maestro\/tests\/ios-maestro-html\.html$/);
});
it('does not include --output when output_format is undefined', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: '',
platform: 'android',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
expect(args.some(a => a.startsWith('--output'))).toBe(false);
expect(args).not.toContain('--output');
expect(args).not.toContain('--output=undefined');
expect(args).not.toContain('--output=null');
expect(args).not.toContain('--output=');
});
it('accepts output_format casing variations (e.g. JUNIT) case-insensitively', async () => {
// output_format.toLowerCase() must propagate end-to-end: final_report_path
// populated, per-attempt XMLs written into junit-reports/, copy invoked.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const copySpy = jest.spyOn(parser, 'copyLatestAttemptXml').mockResolvedValue();
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'JUNIT',
platform: 'android',
});
await step.executeAsync();
expect(step.getOutputValueByName('final_report_path')).toMatch(
/\.maestro\/tests\/android-maestro-junit\.xml$/
);
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(/junit-reports\/android-maestro-junit-attempt-0\.xml$/);
expect(copySpy).toHaveBeenCalledWith(
expect.objectContaining({
sourceDir: expect.stringContaining('junit-reports'),
outputPath: expect.stringContaining('android-maestro-junit.xml'),
})
);
});
it('passes include_tags, exclude_tags, and shards through to maestro argv', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
shards: 3,
include_tags: 'smoke,critical',
exclude_tags: 'slow',
});
await step.executeAsync();
const args = mockedSpawn.mock.calls[0][1] as string[];
expect(args).toContain('--shard-split=3');
expect(args).toContain('--include-tags=smoke,critical');
expect(args).toContain('--exclude-tags=slow');
});
it('passes cwd and env to spawn so relative flow paths resolve correctly', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
cwd?: string;
env?: Record<string, string | undefined>;
};
expect(spawnOptions).toBeDefined();
expect(typeof spawnOptions.cwd).toBe('string');
expect(spawnOptions.cwd!.length).toBeGreaterThan(0);
expect(spawnOptions.env).toBeDefined();
expect(spawnOptions.env!.HOME).toBe('/home/expo');
});
it('exports MAESTRO_TESTS_DIR to maestro so flows can reference ${MAESTRO_TESTS_DIR}', async () => {
// Public docs (EAS workflows pre-packaged-jobs) instruct users to write
// screenshots/recordings into ${MAESTRO_TESTS_DIR}. The step must export
// it in the spawn env.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
const spawnOptions = mockedSpawn.mock.calls[0][2] as {
env?: Record<string, string | undefined>;
};
expect(spawnOptions.env!.MAESTRO_TESTS_DIR).toBe('/home/expo/.maestro/tests');
});
it('uses env.HOME (not os.homedir()) for maestro tests output paths', async () => {
// Even when os.homedir() differs, the step should derive paths from env.HOME
// so maestro (which receives env) and the step (which inspects those paths)
// agree on where reports land.
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
},
{ env: { HOME: '/custom/home' } }
);
await step.executeAsync();
expect(step.getOutputValueByName('tests_directory')).toBe('/custom/home/.maestro/tests');
expect(step.getOutputValueByName('junit_report_directory')).toBe(
'/custom/home/.maestro/tests/junit-reports'
);
expect(step.getOutputValueByName('final_report_path')).toBe(
'/custom/home/.maestro/tests/android-maestro-junit.xml'
);
const args = mockedSpawn.mock.calls[0][1] as string[];
const outputArg = args.find(a => a.startsWith('--output='));
expect(outputArg).toMatch(
/^--output=\/custom\/home\/\.maestro\/tests\/junit-reports\/android-maestro-junit-attempt-0\.xml$/
);
});
it('throws SystemError when env.HOME is unset', async () => {
const step = createStep(
{
flow_path: ['flows/a.yaml'],
output_format: 'junit',
platform: 'android',
},
{ env: {} }
);
await expect(step.executeAsync()).rejects.toThrow(SystemError);
});
it('throws UserError on non-string entries in flow_path', async () => {
const step = createStep({
flow_path: ['flows/a.yaml', 2 as unknown as string],
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries is negative', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: -1,
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when retries is a non-integer number', async () => {
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 1.5,
platform: 'android',
});
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is zero', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 0 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is negative', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: -1 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('throws UserError when shards is a non-integer', async () => {
const step = createStep({ flow_path: ['a.yaml'], platform: 'android', shards: 1.5 });
await expect(step.executeAsync()).rejects.toThrow(UserError);
});
it('accepts shards: undefined (optional input)', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const step = createStep({ flow_path: ['a.yaml'], platform: 'android' }); // no shards
await step.executeAsync(); // should succeed, not throw
});
});
@@ -17,6 +17,9 @@ export interface MaestroFlowResult {
// Maestro's TestDebugReporter creates timestamped directories, e.g. "2024-06-15_143022"
const TIMESTAMP_DIR_PATTERN = /^\d{4}-\d{2}-\d{2}_\d{6}$/;
// Per-attempt JUnit XML files use `*-attempt-N.xml` names; this extracts N.
const ATTEMPT_PATTERN = /attempt-(\d+)/;
export function extractFlowKey(filename: string, prefix: string): string | null {
const match = filename.match(new RegExp(`^${prefix}-(.+)\\.json$`));
return match?.[1] ?? null;
@@ -239,9 +242,6 @@ export async function parseMaestroResults(
// 3. Merge: JUnit results + ai-*.json metadata
const results: MaestroFlowResult[] = [];
// Parse attempt index from filename pattern: *-attempt-N.*
const ATTEMPT_PATTERN = /attempt-(\d+)/;
// Group results by flow name
const resultsByName = new Map<string, JUnitResultWithSource[]>();
for (const entry of junitResultsWithSource) {
@@ -322,3 +322,41 @@ async function relativizePathAsync(flowFilePath: string, projectRoot: string): P
}
return relative;
}
/**
* Copies the highest-attempt-index *.xml file from sourceDir to outputPath.
* After the maestro retry loop completes, this produces a single canonical
* JUnit report at final_report_path matching the bash step's "cp latest
* attempt" semantics.
*
* Throws if sourceDir contains no *.xml files or if the copy fails.
*/
export async function copyLatestAttemptXml(args: {
sourceDir: string;
outputPath: string;
}): Promise<void> {
const entries = await fs.readdir(args.sourceDir);
const xmlFiles = entries.filter(f => f.endsWith('.xml')).sort();
if (xmlFiles.length === 0) {
throw new Error(`No *.xml files found in ${args.sourceDir}`);
}
// Pick the file with the highest attempt index. Files without the marker are
// treated as attempt 0. Ties are broken by sorted filename — later wins.
let winner = xmlFiles[0];
let winnerAttempt = (() => {
const m = winner.match(ATTEMPT_PATTERN);
return m ? parseInt(m[1], 10) : 0;
})();
for (let i = 1; i < xmlFiles.length; i++) {
const candidate = xmlFiles[i];
const match = candidate.match(ATTEMPT_PATTERN);
const attempt = match ? parseInt(match[1], 10) : 0;
if (attempt >= winnerAttempt) {
winner = candidate;
winnerAttempt = attempt;
}
}
await fs.copyFile(path.join(args.sourceDir, winner), args.outputPath);
}
@@ -0,0 +1,257 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import path from 'path';
import { z } from 'zod';
import { copyLatestAttemptXml } from './maestroResultParser';
import { sleepAsync } from '../../utils/retry';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
const RetriesSchema = z.number().int().min(0).default(0);
const ShardsSchema = z.number().int().min(1).optional();
function parseInput<S extends z.ZodTypeAny>(
schema: S,
value: unknown,
message: string
): z.output<S> {
const result = schema.safeParse(value);
if (!result.success) {
throw new UserError('ERR_MAESTRO_INVALID_INPUT', message, { cause: result.error });
}
return result.data;
}
// `outputPath: null` means "let maestro pick" (no --output flag). Junit and
// other declared formats pass an explicit path so downstream upload steps
// know where to find the result.
function buildMaestroArgs(args: {
flow_path: string[];
outputPath: string | null;
output_format: string | undefined;
shards: number | undefined;
include_tags: string | undefined;
exclude_tags: string | undefined;
}): string[] {
const out: string[] = ['test'];
if (args.output_format) {
out.push(`--format=${args.output_format.toUpperCase()}`);
}
if (args.outputPath) {
out.push(`--output=${args.outputPath}`);
}
if (args.shards !== undefined) {
out.push(`--shard-split=${args.shards}`);
}
if (args.include_tags) {
out.push(`--include-tags=${args.include_tags}`);
}
if (args.exclude_tags) {
out.push(`--exclude-tags=${args.exclude_tags}`);
}
out.push(...args.flow_path);
return out;
}
export function createMaestroTestsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'maestro_tests',
name: 'Run Maestro Tests',
__metricsId: 'eas/maestro_tests',
inputProviders: [
BuildStepInput.createProvider({
id: 'flow_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'retries',
required: false,
defaultValue: 0,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'shards',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'include_tags',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'exclude_tags',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'output_format',
required: false,
defaultValue: 'junit',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'platform',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({ id: 'junit_report_directory', required: true }),
BuildStepOutput.createProvider({ id: 'final_report_path', required: false }),
BuildStepOutput.createProvider({ id: 'tests_directory', required: true }),
],
fn: async (stepCtx, { inputs, outputs, env, signal }) => {
const { logger, global } = stepCtx;
const platformInput = inputs.platform.value as string | undefined;
const outputFormat = (inputs.output_format.value as string | undefined)?.toLowerCase();
const includeTags = inputs.include_tags.value as string | undefined;
const excludeTags = inputs.exclude_tags.value as string | undefined;
const platform: 'ios' | 'android' =
platformInput === 'ios' || platformInput === 'android'
? platformInput
: global.runtimePlatform === BuildRuntimePlatform.DARWIN
? 'ios'
: 'android';
// Paths derive from env.HOME (not os.homedir()). Maestro is spawned with
// this env and writes debug output under $HOME/.maestro/tests; the step
// must read from the same place or stale files leak across runs.
const home = env.HOME;
if (!home) {
throw new SystemError('HOME env var is not set');
}
const testsDirectory = path.join(home, '.maestro', 'tests');
const junitReportDirectory = path.join(testsDirectory, 'junit-reports');
const finalReportPath =
outputFormat === 'junit'
? path.join(testsDirectory, `${platform}-maestro-junit.xml`)
: undefined;
// Public docs (EAS workflows pre-packaged-jobs) document
// `${MAESTRO_TESTS_DIR}` for users to save screenshots/recordings into
// the uploaded dir.
const spawnEnv = { ...env, MAESTRO_TESTS_DIR: testsDirectory };
// Outputs are published BEFORE any throw below so downstream
// `if: always()` upload steps still see populated values when this
// step fails early.
outputs.tests_directory.set(testsDirectory);
outputs.junit_report_directory.set(junitReportDirectory);
if (finalReportPath !== undefined) {
outputs.final_report_path.set(finalReportPath);
}
const flowPaths = parseInput(
FlowPathSchema,
inputs.flow_path.value,
'flow_path must be a non-empty array of non-empty strings.'
);
const retries = parseInput(
RetriesSchema,
inputs.retries.value,
'retries must be a non-negative integer.'
);
const shards = parseInput(
ShardsSchema,
inputs.shards.value,
'shards must be a positive integer.'
);
try {
await fs.mkdir(junitReportDirectory, { recursive: true });
} catch (err) {
throw new SystemError('Failed to create JUnit report directory', { cause: err });
}
// Retry loop. spawn-async error shapes:
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
// numeric err.status → maestro exited non-zero → retry.
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
// downgraded to "tests failed".
let lastAttemptExitCode: number | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
const outputPath =
outputFormat === 'junit'
? path.join(junitReportDirectory, `${platform}-maestro-junit-attempt-${attempt}.xml`)
: outputFormat
? path.join(testsDirectory, `${platform}-maestro-${outputFormat}.${outputFormat}`)
: null;
try {
await spawn(
'maestro',
buildMaestroArgs({
flow_path: flowPaths,
outputPath,
output_format: outputFormat,
shards,
include_tags: includeTags,
exclude_tags: excludeTags,
}),
{ cwd: stepCtx.workingDirectory, env: spawnEnv, logger, signal }
);
lastAttemptExitCode = 0;
} catch (err: any) {
if (err && (err.code === 'ENOENT' || err.code === 'EACCES')) {
throw new SystemError('Failed to invoke maestro', { cause: err });
}
if (err && typeof err.status === 'number') {
lastAttemptExitCode = err.status;
} else {
throw new SystemError('Unexpected spawn failure invoking maestro', { cause: err });
}
}
if (lastAttemptExitCode === 0 || attempt === retries) {
break;
}
logger.info('Test failed, retrying...');
await sleepAsync(2000);
}
// Copy the latest attempt's JUnit to the final report path so downstream
// upload/report steps have a single canonical file.
if (finalReportPath !== undefined) {
try {
await copyLatestAttemptXml({
sourceDir: junitReportDirectory,
outputPath: finalReportPath,
});
} catch (copyErr: any) {
// Swallow: a copy failure usually means maestro itself failed early
// (bad YAML wrote no *.xml). Throwing SystemError here would mask
// the real reason and cancel billing for a user-side failure — let
// the lastAttemptExitCode check below surface ERR_MAESTRO_TESTS_FAILED.
logger.warn(
`Failed to produce final_report_path at ${finalReportPath}: ${copyErr?.message ?? copyErr}`
);
}
}
// 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.
if (lastAttemptExitCode !== 0) {
const totalAttempts = retries + 1;
throw new UserError(
'ERR_MAESTRO_TESTS_FAILED',
`Maestro tests failed after ${totalAttempts} attempt${totalAttempts === 1 ? '' : 's'}.`
);
}
},
});
}
+1 -1
View File
@@ -50,7 +50,7 @@ yarn generate-graphql-code # Generate TypeScript types from GraphQL schema
yarn verify-graphql-code # Verify GraphQL code is up to date (CI check)
```
The GraphQL schema is fetched from `https://staging-api.expo.dev/graphql` and generates:
The GraphQL schema is fetched from the API URL resolved by `src/api.ts` and generates:
- `src/graphql/generated.ts` - 499KB+ of TypeScript types
- Types for all queries, mutations, and fragments
+28
View File
@@ -0,0 +1,28 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
import { getExpoApiBaseUrl } from './src/api';
const schema =
process.env.GRAPHQL_SCHEMA_URL ?? new URL('/graphql', getExpoApiBaseUrl()).toString();
const config: CodegenConfig = {
overwrite: true,
schema,
documents: ['src/**/!(*.d).ts'],
generates: {
'src/graphql/generated.ts': {
plugins: ['typescript', 'typescript-operations'],
config: {
dedupeOperationSuffix: true,
},
hooks: {
afterOneFileWrite: ['node ./scripts/annotate-graphql-codegen.js'],
},
},
'./graphql.schema.json': {
plugins: ['introspection'],
},
},
};
export default config;
-17
View File
@@ -1,17 +0,0 @@
overwrite: true
schema: 'https://staging-api.expo.dev/graphql'
documents:
- 'src/**/!(*.d).ts'
generates:
src/graphql/generated.ts:
plugins:
- 'typescript'
- 'typescript-operations'
config:
dedupeOperationSuffix: true
hooks:
afterOneFileWrite:
- 'node ./scripts/annotate-graphql-codegen.js'
./graphql.schema.json:
plugins:
- 'introspection'
+324 -1
View File
@@ -25488,6 +25488,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "resolvedImage",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "resourceClass",
"description": "The builder resource class requested by the developer",
@@ -25603,7 +25615,7 @@
"ofType": null
},
"isDeprecated": true,
"deprecationReason": "Check logs instead."
"deprecationReason": "Use 'resolvedImage' for the concrete image the build runs on."
},
{
"name": "status",
@@ -29825,6 +29837,83 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ConvexProjectMutation",
"description": null,
"fields": [
{
"name": "deleteConvexProject",
"description": null,
"args": [
{
"name": "convexProjectId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "setupConvexProject",
"description": null,
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "SetupConvexProjectInput",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "SetupConvexProjectResult",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ConvexTeamConnection",
@@ -29862,6 +29951,38 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "convexTeamName",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "convexTeamSlug",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdAt",
"description": null,
@@ -29894,6 +30015,30 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "invitedAt",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "invitedEmail",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": null,
@@ -55702,6 +55847,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "convexProject",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ConvexProjectMutation",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "convexTeamConnection",
"description": null,
@@ -60001,6 +60162,152 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "SetupConvexProjectInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "appId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "convexTeamConnectionId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deploymentRegion",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "projectName",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SetupConvexProjectResult",
"description": null,
"fields": [
{
"name": "convexDeploymentName",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "convexDeploymentUrl",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "convexProject",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ConvexProject",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deployKey",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "SizeBreakdownCategory",
@@ -62061,6 +62368,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "includedAgentCreditsInCents",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "isDowngrading",
"description": null,
+2 -2
View File
@@ -37,7 +37,7 @@
"typecheck-for-build": "tsc --project tsconfig.build.json",
"test": "jest",
"version": "yarn run -T oclif readme && node scripts/patch-readme && git add README.md",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.yml",
"generate-graphql-code": "graphql-codegen --config graphql-codegen.ts",
"verify-graphql-code": "./scripts/verify-graphql.sh",
"clean": "rimraf dist build tmp node_modules yarn-error.log \"*.tsbuildinfo\"",
"copy-new-templates": "rimraf build/commandUtils/new/templates && mkdir -p build/commandUtils/new && cp -r src/commandUtils/new/templates build/commandUtils/new"
@@ -202,7 +202,7 @@
"description": "manage project and account environment variables"
},
"integrations": {
"description": "manage service connections"
"description": "manage third-party service integrations"
},
"metadata": {
"description": "manage store configuration"
+346
View File
@@ -0,0 +1,346 @@
import { spawn } from 'child_process';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
/*
* Manual Convex integration E2E runner.
*
* Run from packages/eas-cli with:
* yarn ts-node scripts/convex-e2e.ts
*
* This uses the deployed EAS API and real authenticated EAS/Convex-backed
* resources. Cleanup removes EAS-side links when possible, but it does not
* destroy Convex-side resources.
*
* Options:
* CONVEX_E2E_KEEP_PROJECT=1 keeps the temp Expo project and skips cleanup.
* CONVEX_E2E_SKIP_DASHBOARD=1 skips opening the Convex dashboard.
* CONVEX_E2E_ACCOUNT=<account-name> sets the Expo app owner before eas init.
*/
type RunOptions = {
cwd?: string;
allowFailure?: boolean;
env?: NodeJS.ProcessEnv;
};
type RunResult = {
stdout: string;
stderr: string;
exitCode: number | null;
};
const KEEP_PROJECT = process.env.CONVEX_E2E_KEEP_PROJECT === '1';
const SKIP_DASHBOARD = process.env.CONVEX_E2E_SKIP_DASHBOARD === '1';
const ACCOUNT = process.env.CONVEX_E2E_ACCOUNT;
const SCRIPT_DIR = __dirname;
const EAS_CLI_ROOT = path.resolve(SCRIPT_DIR, '..');
const LOCAL_EAS_BIN = path.join(EAS_CLI_ROOT, 'bin', 'run');
const TEMP_ROOT_PREFIX = path.join(os.tmpdir(), 'eas-cli-convex-e2e-');
let tempRoot: string | null = null;
let projectDir: string | null = null;
let initializedProject = false;
let initialConvexTeamSlugs: string[] = [];
let createdConvexTeamSlug: string | null = null;
async function mainAsync(): Promise<void> {
try {
await assertFileExistsAsync(
LOCAL_EAS_BIN,
`Local EAS CLI binary not found at ${LOCAL_EAS_BIN}`
);
await runEasAsync(['whoami']);
const testName = `convex-e2e-${Date.now().toString(36)}`;
tempRoot = await fs.mkdtemp(TEMP_ROOT_PREFIX);
projectDir = path.join(tempRoot, testName);
logStep(`Creating fresh Expo project ${testName}`);
await runAsync('npx', ['--yes', 'create-expo-app@latest', projectDir, '--template', 'blank'], {
cwd: tempRoot,
env: getNpxEnvironment(),
});
await updateAppJsonAsync(projectDir, testName);
logStep('Creating/linking EAS project');
await runEasAsync(['init', '--non-interactive', '--force'], projectDir);
initializedProject = true;
initialConvexTeamSlugs = parseConvexTeamSlugs(
(await runEasAsync(['integrations:convex:team'], projectDir)).stdout
);
logStep('Connecting Convex');
await runEasAsync(
['integrations:convex:connect', '--non-interactive', '--project-name', testName],
projectDir
);
await assertEnvLocalAsync(projectDir);
await assertPackageJsonDependencyAsync(projectDir, 'convex');
const teamOutput = await runEasAsync(['integrations:convex:team'], projectDir);
const projectOutput = await runEasAsync(['integrations:convex:project'], projectDir);
assertIncludes(
projectOutput.stdout,
'Dashboard',
'Expected project output to include dashboard'
);
const teamSlugsAfterConnect = parseConvexTeamSlugs(teamOutput.stdout);
const newTeamSlugs = teamSlugsAfterConnect.filter(
slug => !initialConvexTeamSlugs.includes(slug)
);
createdConvexTeamSlug = newTeamSlugs[0] ?? null;
const convexTeamSlug = createdConvexTeamSlug ?? teamSlugsAfterConnect[0];
if (!convexTeamSlug) {
throw new Error('Could not determine a Convex team slug from team output.');
}
await runEasAsync(
['integrations:convex:team:invite', convexTeamSlug, '--non-interactive'],
projectDir
);
if (SKIP_DASHBOARD) {
logStep('Skipping dashboard command because CONVEX_E2E_SKIP_DASHBOARD=1');
} else {
await runEasAsync(['integrations:convex:dashboard'], projectDir);
}
logStep('Convex E2E flow completed successfully');
} finally {
await cleanupAsync();
}
}
async function cleanupAsync(): Promise<void> {
if (!projectDir || !tempRoot) {
return;
}
if (KEEP_PROJECT) {
logStep('Skipping cleanup because CONVEX_E2E_KEEP_PROJECT=1');
log(`Temp project kept at: ${projectDir}`);
log('EAS-side links and Convex-side resources were left intact.');
return;
}
if (initializedProject) {
logStep('Cleaning up EAS-side Convex links');
const convexTeamSlugToDelete =
createdConvexTeamSlug ?? (await getCreatedConvexTeamSlugForCleanupAsync());
await runCleanupCommandAsync([
'integrations:convex:project:delete',
'--non-interactive',
'--yes',
]);
if (convexTeamSlugToDelete) {
await runCleanupCommandAsync([
'integrations:convex:team:delete',
convexTeamSlugToDelete,
'--non-interactive',
'--yes',
]);
} else {
warn(
'No newly-created Convex team link was detected. Skipping team link deletion to avoid removing a pre-existing EAS-side link.'
);
}
warn('Convex-side resources are not destroyed by this cleanup.');
}
await fs.rm(tempRoot, { recursive: true, force: true });
log(`Removed temp project directory: ${tempRoot}`);
}
async function getCreatedConvexTeamSlugForCleanupAsync(): Promise<string | null> {
if (!projectDir) {
return null;
}
const result = await runEasAsync(['integrations:convex:team'], projectDir, {
allowFailure: true,
});
if (result.exitCode !== 0) {
warn('Could not inspect Convex team links during cleanup.');
return null;
}
return (
parseConvexTeamSlugs(result.stdout).find(slug => !initialConvexTeamSlugs.includes(slug)) ?? null
);
}
async function runCleanupCommandAsync(args: string[]): Promise<void> {
try {
const result = await runEasAsync(args, projectDir ?? undefined, { allowFailure: true });
if (result.exitCode !== 0) {
warn(`Cleanup command exited with ${result.exitCode}: ${args.join(' ')}`);
}
} catch (error) {
warn(`Cleanup command failed: ${args.join(' ')}`);
warn(error instanceof Error ? error.message : String(error));
}
}
async function runEasAsync(
args: string[],
cwd?: string,
options: RunOptions = {}
): Promise<RunResult> {
return await runAsync(LOCAL_EAS_BIN, args, { cwd, ...options });
}
async function runAsync(
command: string,
args: string[],
{ cwd, allowFailure = false, env = {} }: RunOptions = {}
): Promise<RunResult> {
const printableCommand = [command, ...args].join(' ');
log(`\n$ ${printableCommand}`);
if (cwd) {
log(`cwd: ${cwd}`);
}
return await new Promise<RunResult>((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: {
...process.env,
...env,
CI: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
const chunk = data.toString();
stdout += chunk;
process.stdout.write(redact(chunk));
});
child.stderr.on('data', data => {
const chunk = data.toString();
stderr += chunk;
process.stderr.write(redact(chunk));
});
child.on('error', reject);
child.on('close', exitCode => {
const result = { stdout, stderr, exitCode };
if (!allowFailure && exitCode !== 0) {
reject(new Error(`Command failed with exit code ${exitCode}: ${printableCommand}`));
return;
}
resolve(result);
});
});
}
async function updateAppJsonAsync(appDir: string, testName: string): Promise<void> {
const appJsonPath = path.join(appDir, 'app.json');
const rawAppJson = await fs.readFile(appJsonPath, 'utf8');
const appJson = JSON.parse(rawAppJson);
appJson.expo = {
...appJson.expo,
name: testName,
slug: testName,
...(ACCOUNT ? { owner: ACCOUNT } : {}),
};
await fs.writeFile(appJsonPath, `${JSON.stringify(appJson, null, 2)}\n`);
}
async function assertEnvLocalAsync(appDir: string): Promise<void> {
const envLocalPath = path.join(appDir, '.env.local');
const envLocal = await fs.readFile(envLocalPath, 'utf8');
assertIncludes(
envLocal,
'CONVEX_DEPLOY_KEY=',
'Expected .env.local to contain CONVEX_DEPLOY_KEY'
);
assertIncludes(
envLocal,
'EXPO_PUBLIC_CONVEX_URL=',
'Expected .env.local to contain EXPO_PUBLIC_CONVEX_URL'
);
log('\nVerified .env.local contains Convex environment variables:');
log(redact(envLocal));
}
async function assertPackageJsonDependencyAsync(
appDir: string,
packageName: string
): Promise<void> {
const packageJsonPath = path.join(appDir, 'package.json');
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
const dependencies = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
if (!dependencies[packageName]) {
throw new Error(`Expected package.json to include ${packageName}`);
}
log(`Verified package.json includes ${packageName}@${dependencies[packageName]}`);
}
function parseConvexTeamSlugs(output: string): string[] {
const strippedOutput = stripAnsi(output);
return Array.from(strippedOutput.matchAll(/^Team:\s+.+?\s\/\s(.+)$/gm), match => match[1].trim());
}
function assertIncludes(value: string, expected: string, message: string): void {
if (!value.includes(expected)) {
throw new Error(message);
}
}
async function assertFileExistsAsync(filePath: string, message: string): Promise<void> {
try {
await fs.access(filePath);
} catch {
throw new Error(message);
}
}
function logStep(message: string): void {
log(`\n==> ${message}`);
}
function log(message: string): void {
process.stdout.write(`${message}\n`);
}
function warn(message: string): void {
process.stderr.write(`${message}\n`);
}
function redact(value: string): string {
return value
.replace(/^CONVEX_DEPLOY_KEY=.*$/gm, 'CONVEX_DEPLOY_KEY=<redacted>')
.replace(/dev:[^\s|]+\|[^\s]+/g, 'dev:<redacted>');
}
function stripAnsi(value: string): string {
return value.replace(/\u001b\[[0-9;]*m/g, '');
}
function getNpxEnvironment(): NodeJS.ProcessEnv {
return {
npm_config_user_agent: `npm/10 node/${process.versions.node} ${process.platform} ${process.arch}`,
npm_execpath: undefined,
};
}
mainAsync().catch(error => {
warn(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
@@ -0,0 +1,126 @@
import {
confirmRecentConvexInviteAsync,
formatConvexProject,
formatConvexTeam,
formatConvexTeamConnection,
getConvexProjectDashboardUrl,
getConvexTeamDashboardUrl,
} from '../convex';
import {
ConvexProjectData,
ConvexTeamConnectionData,
} from '../../graphql/types/ConvexTeamConnection';
import Log from '../../log';
import { confirmAsync } from '../../prompts';
jest.mock('../../log');
jest.mock('../../prompts');
describe('Convex command utilities', () => {
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: '2024-01-02T00:00:00.000Z',
invitedEmail: '[email protected]',
};
const mockProject: ConvexProjectData = {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'Test Project',
convexProjectSlug: 'test project',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
};
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('builds encoded Convex dashboard URLs', () => {
expect(getConvexTeamDashboardUrl(mockConnection)).toBe(
'https://dashboard.convex.dev/t/test%20team'
);
expect(getConvexProjectDashboardUrl(mockProject)).toBe(
'https://dashboard.convex.dev/t/test%20team/test%20project'
);
});
it('formats team and project metadata with invite details', () => {
expect(formatConvexTeam(mockConnection)).toBe('Test Team / test team');
expect(formatConvexTeamConnection(mockConnection)).toContain('Test Team / test team');
expect(formatConvexTeamConnection(mockConnection)).not.toContain('team-123');
expect(formatConvexTeamConnection(mockConnection)).not.toContain('connection-1');
expect(formatConvexTeamConnection(mockConnection)).toContain('[email protected]');
expect(formatConvexTeamConnection(mockConnection)).toContain('2024-01-02T00:00:00.000Z');
expect(formatConvexProject(mockProject)).toContain('project-123');
expect(formatConvexProject(mockProject)).not.toContain('convex-project-1');
expect(formatConvexProject(mockProject)).toContain('Test Team / test team');
expect(formatConvexProject(mockProject)).not.toContain('team-123');
});
it('does not prompt when the previous invite timestamp is invalid', async () => {
await expect(
confirmRecentConvexInviteAsync(
{ ...mockConnection, invitedAt: 'not-a-date' },
{ nonInteractive: false }
)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
});
it('does not prompt when the previous invite is old', async () => {
await expect(
confirmRecentConvexInviteAsync(
{
...mockConnection,
invitedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
},
{ nonInteractive: false }
)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
});
it('prompts before resending a recent invite in interactive mode', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await expect(
confirmRecentConvexInviteAsync(
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
{ nonInteractive: false }
)
).resolves.toBe(false);
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('Are you sure you want to send another invite?'),
});
});
it('warns and proceeds for recent invites in non-interactive mode', async () => {
await expect(
confirmRecentConvexInviteAsync(
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
{ nonInteractive: true }
)
).resolves.toBe(true);
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('non-interactive mode'));
});
});
@@ -0,0 +1,83 @@
import chalk from 'chalk';
import { ConvexProjectData, ConvexTeamConnectionData } from '../graphql/types/ConvexTeamConnection';
import Log, { link } from '../log';
import { confirmAsync } from '../prompts';
const CONVEX_DASHBOARD_HOST = 'https://dashboard.convex.dev';
const RECENT_INVITE_THRESHOLD_MS = 60 * 60 * 1000;
export function getConvexTeamDashboardUrl(connection: ConvexTeamConnectionData): string {
return `${CONVEX_DASHBOARD_HOST}/t/${encodeURIComponent(connection.convexTeamSlug)}`;
}
export function getConvexProjectDashboardUrl(project: ConvexProjectData): string {
return `${getConvexTeamDashboardUrl(project.convexTeamConnection)}/${encodeURIComponent(
project.convexProjectSlug
)}`;
}
export function formatConvexTeam(connection: ConvexTeamConnectionData): string {
return `${connection.convexTeamName} / ${connection.convexTeamSlug}`;
}
export function formatConvexTeamConnection(connection: ConvexTeamConnectionData): string {
const lines = [
`${chalk.bold('Team')}: ${formatConvexTeam(connection)}`,
`${chalk.bold('Dashboard')}: ${link(getConvexTeamDashboardUrl(connection), { dim: false })}`,
];
if (connection.invitedEmail) {
lines.push(`${chalk.bold('Invited email')}: ${connection.invitedEmail}`);
}
if (connection.invitedAt) {
lines.push(`${chalk.bold('Invited at')}: ${connection.invitedAt}`);
}
return lines.join('\n');
}
export function formatConvexProject(project: ConvexProjectData): string {
return [
`${chalk.bold('Name')}: ${project.convexProjectName}`,
`${chalk.bold('Slug')}: ${project.convexProjectSlug}`,
`${chalk.bold('Identifier')}: ${project.convexProjectIdentifier}`,
`${chalk.bold('Team')}: ${formatConvexTeam(project.convexTeamConnection)}`,
`${chalk.bold('Dashboard')}: ${link(getConvexProjectDashboardUrl(project), { dim: false })}`,
].join('\n');
}
export function logNoConvexTeams(accountName: string): void {
Log.warn(`No Convex team is linked to account ${chalk.bold(accountName)} on EAS.`);
}
export function logNoConvexProject(projectName: string): void {
Log.warn(`No Convex project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
}
export async function confirmRecentConvexInviteAsync(
connection: ConvexTeamConnectionData,
{ nonInteractive }: { nonInteractive: boolean }
): Promise<boolean> {
const invitedAt = connection.invitedAt ? new Date(connection.invitedAt) : null;
if (!invitedAt || Number.isNaN(invitedAt.getTime())) {
return true;
}
const timeSinceInviteMs = Date.now() - invitedAt.getTime();
if (timeSinceInviteMs >= RECENT_INVITE_THRESHOLD_MS) {
return true;
}
const previousInvite = `A Convex team invite was already sent${connection.invitedEmail ? ` to ${connection.invitedEmail}` : ''} at ${connection.invitedAt}.`;
if (nonInteractive) {
Log.warn(
`${previousInvite} Sending another invite because this command is running in non-interactive mode.`
);
return true;
}
return await confirmAsync({
message: `${previousInvite} Are you sure you want to send another invite?`,
});
}
@@ -215,7 +215,6 @@ export default class BuildResign extends EasCommand {
},
},
secrets: prepareCredentialsToResign(credentialsResult.credentials),
builderEnvironment: { image: 'default' },
};
const newBuild = await BuildMutation.retryIosBuildAsync(graphqlClient, {
buildId: build.id,
@@ -0,0 +1,293 @@
import spawnAsync from '@expo/spawn-async';
import * as fs from 'fs-extra';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import {
ConvexTeamConnectionData,
SetupConvexProjectResultData,
} from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, promptAsync, selectAsync } from '../../../../prompts';
import { Actor } from '../../../../user/User';
import IntegrationsConvexConnect from '../connect';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../graphql/mutations/ConvexMutation');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../prompts');
jest.mock('@expo/spawn-async');
jest.mock('fs-extra');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsConvexConnect, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testProjectDir = '/test/project';
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 mockRobotActor: Actor = {
__typename: 'Robot',
id: 'robot-1',
featureGates: {},
isExpoAdmin: false,
accounts: [],
};
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
const mockSetupResult: SetupConvexProjectResultData = {
convexDeploymentName: 'happy-otter-123',
convexDeploymentUrl: 'https://happy-otter-123.convex.cloud',
deployKey: 'dev:happy-otter-123|abc123token',
convexProject: {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'testapp',
convexProjectSlug: 'testapp',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
},
};
function createCommand(argv: string[], actor: Actor = mockActor): IntegrationsConvexConnect {
const command = new IntegrationsConvexConnect(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
exp: { name: 'testapp', slug: 'testapp' },
projectDir: testProjectDir,
},
loggedIn: { graphqlClient, actor },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'withTick').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(fs.pathExists).mockResolvedValue(false as never);
jest.mocked(fs.writeFile).mockResolvedValue(undefined as never);
jest.mocked(fs.readFile).mockResolvedValue('' as never);
jest.mocked(spawnAsync).mockResolvedValue({} as never);
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
jest.mocked(selectAsync).mockResolvedValue('aws-us-east-1');
jest.mocked(confirmAsync).mockResolvedValue(true);
jest.mocked(promptAsync).mockImplementation(async (params: any) => ({
[params.name]: params.initial,
}));
jest.mocked(ConvexMutation.createConvexTeamConnectionAsync).mockResolvedValue(mockConnection);
jest.mocked(ConvexMutation.setupConvexProjectAsync).mockResolvedValue(mockSetupResult);
jest.mocked(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).mockResolvedValue(true);
});
it('creates a Convex team and project with the new mutation shape', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
await createCommand([]).runAsync();
expect(ConvexMutation.createConvexTeamConnectionAsync).toHaveBeenCalledWith(graphqlClient, {
accountId: testAccountId,
deploymentRegion: 'aws-us-east-1',
convexTeamName: testAccountName,
});
expect(ConvexMutation.setupConvexProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
convexTeamConnectionId: 'connection-1',
deploymentRegion: 'aws-us-east-1',
projectName: 'testapp',
});
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
graphqlClient,
{ convexTeamConnectionId: 'connection-1' }
);
expect(spawnAsync).toHaveBeenCalledWith('npx', ['expo', 'install', 'convex'], {
cwd: testProjectDir,
stdio: 'inherit',
});
});
it('uses an existing Convex team connection', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
await createCommand(['--region', 'aws-eu-west-1']).runAsync();
expect(ConvexMutation.createConvexTeamConnectionAsync).not.toHaveBeenCalled();
expect(ConvexMutation.setupConvexProjectAsync).toHaveBeenCalledWith(graphqlClient, {
appId: testProjectId,
convexTeamConnectionId: 'connection-1',
deploymentRegion: 'aws-eu-west-1',
projectName: 'testapp',
});
expect(spawnAsync).toHaveBeenCalledWith('npx', ['expo', 'install', 'convex'], {
cwd: testProjectDir,
stdio: 'inherit',
});
});
it('does not create a Convex team if installing the Convex package fails first', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
jest.mocked(spawnAsync).mockRejectedValue(new Error('install failed') as never);
await expect(createCommand([]).runAsync()).rejects.toThrow('install failed');
expect(ConvexMutation.createConvexTeamConnectionAsync).not.toHaveBeenCalled();
expect(ConvexMutation.setupConvexProjectAsync).not.toHaveBeenCalled();
});
it('skips the verified email invite when the actor has no email', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
await createCommand([], mockRobotActor).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not determine your verified email address')
);
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Convex dashboard'));
});
it('logs the error when sending a verified email invite fails', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest
.mocked(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync)
.mockRejectedValue(new Error('Convex invite service unavailable'));
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Failed to send Convex team invitation to [email protected]')
);
expect(Log.warn).toHaveBeenCalledWith('Convex invite service unavailable');
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Convex dashboard'));
});
it('asks before resending a recent team invite for an existing connection', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
invitedEmail: '[email protected]',
},
]);
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('Are you sure you want to send another invite?'),
});
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith('Skipped sending Convex team invitation.');
});
it('writes the deploy key and Convex URL to .env.local', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
await createCommand([]).runAsync();
const envPath = `${testProjectDir}/.env.local`;
const writeCall = jest.mocked(fs.writeFile).mock.calls.find(call => call[0] === envPath);
expect(writeCall?.[1]).toContain('CONVEX_DEPLOY_KEY=dev:happy-otter-123|abc123token');
expect(writeCall?.[1]).toContain('EXPO_PUBLIC_CONVEX_URL=https://happy-otter-123.convex.cloud');
});
it('prompts before overwriting existing Convex .env.local values', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest.mocked(fs.pathExists).mockResolvedValue(true as never);
jest.mocked(fs.readFile).mockResolvedValue('CONVEX_DEPLOY_KEY=old-key\n' as never);
jest.mocked(confirmAsync).mockResolvedValue(true);
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('already contains CONVEX_DEPLOY_KEY'),
});
});
it('uses defaults in non-interactive mode', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
await createCommand(['--non-interactive']).runAsync();
expect(selectAsync).not.toHaveBeenCalled();
expect(promptAsync).not.toHaveBeenCalled();
expect(ConvexMutation.createConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
deploymentRegion: 'aws-us-east-1',
convexTeamName: testAccountName,
})
);
expect(ConvexMutation.setupConvexProjectAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
appId: testProjectId,
deploymentRegion: 'aws-us-east-1',
projectName: 'testapp',
})
);
});
});
@@ -0,0 +1,125 @@
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 { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import {
ConvexProjectData,
ConvexTeamConnectionData,
} from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { ora } from '../../../../ora';
import IntegrationsConvexDashboard from '../dashboard';
jest.mock('better-opn');
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: jest.fn(() => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
})),
}));
describe(IntegrationsConvexDashboard, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
const mockProject: ConvexProjectData = {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'Test Project',
convexProjectSlug: 'test project',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
};
function createCommand(): IntegrationsConvexDashboard {
const command = new IntegrationsConvexDashboard([], 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.mocked(ConvexQuery.getConvexProjectByAppIdAsync).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 Convex project dashboard', async () => {
await createCommand().runAsync();
expect(openBrowserAsync).toHaveBeenCalledWith(
'https://dashboard.convex.dev/t/test%20team/test%20project'
);
});
it('logs an empty state when no project link exists', async () => {
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(null);
await createCommand().runAsync();
expect(openBrowserAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('No Convex 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('fails the spinner and rethrows when opening the browser throws', async () => {
jest.mocked(openBrowserAsync).mockRejectedValue(new Error('open failed'));
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('open failed');
expect(spinner.fail).toHaveBeenCalledWith(
expect.stringContaining('Unable to open a web browser')
);
});
});
@@ -0,0 +1,120 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import {
ConvexProjectData,
ConvexTeamConnectionData,
} from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { confirmAsync } from '../../../../prompts';
import IntegrationsConvexProjectDelete from '../project/delete';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../graphql/mutations/ConvexMutation');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsConvexProjectDelete, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
const mockProject: ConvexProjectData = {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'Test Project',
convexProjectSlug: 'test-project',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
};
function createCommand(argv: string[]): IntegrationsConvexProjectDelete {
const command = new IntegrationsConvexProjectDelete(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(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(mockProject);
jest.mocked(ConvexMutation.deleteConvexProjectAsync).mockResolvedValue('convex-project-1');
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('deletes the linked Convex project after confirmation', async () => {
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('This does not destroy resources on Convex'),
});
expect(ConvexMutation.deleteConvexProjectAsync).toHaveBeenCalledWith(
graphqlClient,
'convex-project-1'
);
});
it('skips deletion when the user cancels', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await createCommand([]).runAsync();
expect(ConvexMutation.deleteConvexProjectAsync).not.toHaveBeenCalled();
expect(Log.error).toHaveBeenCalledWith('Canceled deletion of the Convex project link');
});
it('uses --yes to skip the confirmation prompt', async () => {
await createCommand(['--yes']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('This does not destroy resources on Convex')
);
expect(ConvexMutation.deleteConvexProjectAsync).toHaveBeenCalledWith(
graphqlClient,
'convex-project-1'
);
});
it('logs an empty state when no project link exists', async () => {
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(null);
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('No Convex project is linked to Expo app')
);
expect(ConvexMutation.deleteConvexProjectAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,78 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import {
ConvexProjectData,
ConvexTeamConnectionData,
} from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import IntegrationsConvexProject from '../project';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../log');
describe(IntegrationsConvexProject, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
const mockProject: ConvexProjectData = {
id: 'convex-project-1',
convexProjectIdentifier: 'project-123',
convexProjectName: 'Test Project',
convexProjectSlug: 'test-project',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
convexTeamConnection: mockConnection,
};
function createCommand(): IntegrationsConvexProject {
const command = new IntegrationsConvexProject([], 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(() => {});
});
it('prints linked Convex project metadata', async () => {
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(mockProject);
await createCommand().runAsync();
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('Convex project linked to testapp')
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('project-123'));
});
it('prints an empty state when no Convex project is linked', async () => {
jest.mocked(ConvexQuery.getConvexProjectByAppIdAsync).mockResolvedValue(null);
await createCommand().runAsync();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('No Convex project is linked to Expo app')
);
});
});
@@ -0,0 +1,187 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, selectAsync } from '../../../../prompts';
import IntegrationsConvexTeamDelete from '../team/delete';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../graphql/mutations/ConvexMutation');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsConvexTeamDelete, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: '2024-01-02T00:00:00.000Z',
invitedEmail: '[email protected]',
};
function createCommand(argv: string[]): IntegrationsConvexTeamDelete {
const command = new IntegrationsConvexTeamDelete(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
},
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(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest.mocked(ConvexMutation.deleteConvexTeamConnectionAsync).mockResolvedValue(mockConnection);
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('deletes the selected Convex team link after confirmation', async () => {
await createCommand(['test-team']).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('This does not destroy resources on Convex'),
});
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
'connection-1'
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Test Team / test-team'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
});
it('skips deletion when the user cancels', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
await createCommand(['test-team']).runAsync();
expect(ConvexMutation.deleteConvexTeamConnectionAsync).not.toHaveBeenCalled();
expect(Log.error).toHaveBeenCalledWith('Canceled deletion of the Convex team link');
});
it('uses --yes to skip the confirmation prompt', async () => {
await createCommand(['test-team', '--yes']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('This does not destroy resources on Convex')
);
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
'connection-1'
);
});
it('logs an empty state when no team links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
await createCommand([]).runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Convex team is linked'));
expect(ConvexMutation.deleteConvexTeamConnectionAsync).not.toHaveBeenCalled();
});
it('throws when the provided team is not linked to the account', async () => {
await expect(createCommand(['missing-team']).runAsync()).rejects.toThrow(
'Convex team missing-team is not linked to this account.'
);
expect(ConvexMutation.deleteConvexTeamConnectionAsync).not.toHaveBeenCalled();
});
it('resolves a team by slug when multiple links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
mockConnection,
{
...mockConnection,
id: 'connection-2',
convexTeamIdentifier: 'team-456',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
},
]);
await createCommand(['other-team', '--non-interactive', '--yes']).runAsync();
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
'connection-2'
);
});
it('requires a team slug in non-interactive mode when multiple links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
mockConnection,
{
...mockConnection,
id: 'connection-2',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
},
]);
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
'Convex team slug must be provided'
);
});
it('prompts to select a team link when multiple links exist in interactive mode', async () => {
const otherConnection = {
...mockConnection,
id: 'connection-2',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
};
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection, otherConnection]);
jest.mocked(selectAsync).mockResolvedValue(otherConnection);
await createCommand(['--yes']).runAsync();
expect(selectAsync).toHaveBeenCalledWith(
'Select a Convex team link to remove',
expect.any(Array)
);
expect(ConvexMutation.deleteConvexTeamConnectionAsync).toHaveBeenCalledWith(
graphqlClient,
'connection-2'
);
});
});
@@ -0,0 +1,220 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, selectAsync } from '../../../../prompts';
import IntegrationsConvexTeamInvite from '../team/invite';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../graphql/mutations/ConvexMutation');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../ora', () => ({
ora: () => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
}),
}));
describe(IntegrationsConvexTeamInvite, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockActor = {
__typename: 'User',
id: 'test-user-id',
email: '[email protected]',
accounts: [],
};
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: '2024-01-02T00:00:00.000Z',
invitedEmail: '[email protected]',
};
function createCommand(
argv: string[],
actor: { __typename: string; [key: string]: any } = mockActor
): IntegrationsConvexTeamInvite {
const command = new IntegrationsConvexTeamInvite(argv, mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
},
loggedIn: { graphqlClient, actor },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'addNewLineIfNone').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
jest.mocked(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).mockResolvedValue(true);
jest.mocked(confirmAsync).mockResolvedValue(true);
});
it('sends an invite for the linked Convex team', async () => {
await createCommand([]).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
graphqlClient,
{ convexTeamConnectionId: 'connection-1' }
);
});
it('prints previous invite metadata when it exists', async () => {
await createCommand([]).runAsync();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Test Team / test-team'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('connection-1'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Previous invite'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('[email protected]'));
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('2024-01-02T00:00:00.000Z'));
});
it('prompts before resending a recent invite', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
]);
await createCommand([]).runAsync();
expect(confirmAsync).toHaveBeenCalledWith({
message: expect.stringContaining('Are you sure you want to send another invite?'),
});
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalled();
});
it('skips resending a recent invite when the user does not confirm', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
]);
await createCommand([]).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith('Skipped sending Convex team invitation.');
});
it('resends recent invites in non-interactive mode with a warning', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
{
...mockConnection,
invitedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
]);
await createCommand(['--non-interactive']).runAsync();
expect(confirmAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('non-interactive mode'));
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalled();
});
it('skips the invite when the actor has no email', async () => {
await createCommand([], { __typename: 'Robot', id: 'robot-1' }).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).not.toHaveBeenCalled();
expect(Log.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not determine your verified email address')
);
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Convex dashboard'));
});
it('selects a team link when multiple are available', async () => {
const otherConnection = {
...mockConnection,
id: 'connection-2',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
};
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection, otherConnection]);
jest.mocked(selectAsync).mockResolvedValue(otherConnection);
await createCommand([]).runAsync();
expect(selectAsync).toHaveBeenCalledWith(
'Select a Convex team link to invite yourself to',
expect.any(Array)
);
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
graphqlClient,
{ convexTeamConnectionId: 'connection-2' }
);
});
it('resolves a team by slug when multiple links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
mockConnection,
{
...mockConnection,
id: 'connection-2',
convexTeamIdentifier: 'team-456',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
},
]);
await createCommand(['other-team', '--non-interactive']).runAsync();
expect(ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync).toHaveBeenCalledWith(
graphqlClient,
{ convexTeamConnectionId: 'connection-2' }
);
});
it('requires a team slug in non-interactive mode when multiple links exist', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([
mockConnection,
{
...mockConnection,
id: 'connection-2',
convexTeamName: 'Other Team',
convexTeamSlug: 'other-team',
},
]);
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(
'Convex team slug must be provided'
);
});
});
@@ -0,0 +1,78 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
import Log from '../../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import IntegrationsConvexTeam from '../team';
jest.mock('../../../../graphql/queries/ConvexQuery');
jest.mock('../../../../project/projectUtils');
jest.mock('../../../../log');
describe(IntegrationsConvexTeam, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const testAccountId = 'test-account-id';
const testAccountName = 'testuser';
const mockAccount = {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
};
const mockConnection: ConvexTeamConnectionData = {
id: 'connection-1',
convexTeamIdentifier: 'team-123',
convexTeamName: 'Test Team',
convexTeamSlug: 'test-team',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
invitedAt: null,
invitedEmail: null,
};
function createCommand(): IntegrationsConvexTeam {
const command = new IntegrationsConvexTeam([], mockConfig);
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
privateProjectConfig: {
projectId: testProjectId,
},
loggedIn: { graphqlClient },
} as never);
return command;
}
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(Log, 'log').mockImplementation(() => {});
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
jest.mocked(getOwnerAccountForProjectIdAsync).mockResolvedValue(mockAccount as any);
});
it('prints linked Convex team metadata', async () => {
jest
.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync)
.mockResolvedValue([mockConnection]);
await createCommand().runAsync();
expect(Log.log).toHaveBeenCalledWith(
expect.stringContaining('Convex teams linked to @testuser')
);
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('Test Team / test-team'));
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('team-123'));
});
it('prints an empty state when no Convex teams are linked', async () => {
jest.mocked(ConvexQuery.getConvexTeamConnectionsByAccountIdAsync).mockResolvedValue([]);
await createCommand().runAsync();
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Convex team is linked'));
});
});
@@ -0,0 +1,350 @@
import spawnAsync from '@expo/spawn-async';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import dotenv from 'dotenv';
import * as fs from 'fs-extra';
import path from 'path';
import EasCommand from '../../../commandUtils/EasCommand';
import { confirmRecentConvexInviteAsync, formatConvexTeam } from '../../../commandUtils/convex';
import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
import { ConvexMutation } from '../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../graphql/types/ConvexTeamConnection';
import Log from '../../../log';
import { ora } from '../../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
import { Actor } from '../../../user/User';
const CONVEX_REGIONS = [
{ title: 'US East (aws-us-east-1)', value: 'aws-us-east-1' },
{ title: 'EU West (aws-eu-west-1)', value: 'aws-eu-west-1' },
];
const DEFAULT_REGION = 'aws-us-east-1';
export default class IntegrationsConvexConnect extends EasCommand {
static override description = 'connect Convex to your Expo project';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
static override flags = {
...EASNonInteractiveFlag,
region: Flags.string({
description: 'Convex deployment region (e.g. aws-us-east-1, aws-eu-west-1)',
options: CONVEX_REGIONS.map(r => r.value),
}),
'team-name': Flags.string({
description: 'Name for the new Convex team (defaults to EAS account name)',
}),
'project-name': Flags.string({
description: 'Name for the Convex project (defaults to app slug)',
}),
};
async runAsync(): Promise<void> {
const {
flags: {
region: regionFlag,
'team-name': teamNameFlag,
'project-name': projectNameFlag,
'non-interactive': nonInteractive,
},
} = await this.parse(IntegrationsConvexConnect);
const {
privateProjectConfig: { projectId, exp, projectDir },
loggedIn: { graphqlClient, actor },
} = await this.getContextAsync(IntegrationsConvexConnect, {
nonInteractive,
withServerSideEnvironment: null,
});
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
// 1. Check for existing Convex team connections
const existingConnections = await ConvexQuery.getConvexTeamConnectionsByAccountIdAsync(
graphqlClient,
account.id
);
const region = await this.resolveRegionAsync(regionFlag, nonInteractive);
let connection: ConvexTeamConnectionData | null = null;
let teamName: string | null = null;
if (existingConnections.length === 0) {
// 2a. No connection - resolve a team name and create it after local package install succeeds
teamName = await this.resolveTeamNameAsync(teamNameFlag, account.name, nonInteractive);
} else if (existingConnections.length === 1) {
// 2b. Single existing connection
connection = existingConnections[0];
Log.withTick(`Using existing Convex team ${chalk.bold(formatConvexTeam(connection))}`);
} else {
// 2c. Multiple connections - prompt to select
connection = await this.selectConnectionAsync(existingConnections, nonInteractive);
}
// 3. Resolve project name before project setup mutation
const projectName = await this.resolveProjectNameAsync(
projectNameFlag,
exp.slug,
nonInteractive
);
// 4. Install the Convex package before creating new server-side resources
await this.installConvexPackageAsync(projectDir);
if (!connection) {
const spinner = ora('Creating Convex team').start();
try {
connection = await ConvexMutation.createConvexTeamConnectionAsync(graphqlClient, {
accountId: account.id,
deploymentRegion: region,
convexTeamName: teamName ?? account.name,
});
spinner.succeed(`Created Convex team ${chalk.bold(formatConvexTeam(connection))}`);
} catch (error) {
spinner.fail('Failed to create Convex team');
throw error;
}
}
// 5. Set up Convex project
const spinner = ora('Setting up Convex project').start();
let setupResult;
try {
setupResult = await ConvexMutation.setupConvexProjectAsync(graphqlClient, {
appId: projectId,
convexTeamConnectionId: connection.id,
deploymentRegion: region,
projectName,
});
spinner.succeed(
`Created Convex project ${chalk.bold(projectName)} with deployment ${chalk.bold(setupResult.convexDeploymentName)}`
);
} catch (error) {
spinner.fail('Failed to set up Convex project');
throw error;
}
// 6. Send team invite (non-fatal)
await this.sendTeamInviteAsync(graphqlClient, connection, actor, { nonInteractive });
// 7. Write deploy key and URL to .env.local
await this.writeEnvLocalAsync(
projectDir,
setupResult.deployKey,
setupResult.convexDeploymentUrl,
nonInteractive
);
// 8. Success message
Log.addNewLineIfNone();
Log.log(chalk.green('Convex is ready!'));
Log.newLine();
Log.log('Next steps:');
Log.log(` 1. Start the Convex dev server: ${chalk.cyan('npx convex dev')}`);
Log.newLine();
if (this.getActorEmail(actor)) {
Log.log(
`Check your email for an invitation to join your Convex team. Accept it for full dashboard access.`
);
}
}
private async resolveRegionAsync(
flagValue: string | undefined,
nonInteractive: boolean
): Promise<string> {
if (flagValue) {
return flagValue;
}
if (nonInteractive) {
return DEFAULT_REGION;
}
return await selectAsync('Select a Convex deployment region', CONVEX_REGIONS);
}
private async resolveTeamNameAsync(
flagValue: string | undefined,
accountName: string,
nonInteractive: boolean
): Promise<string> {
if (flagValue) {
return flagValue;
}
if (nonInteractive) {
return accountName;
}
const { teamName } = await promptAsync({
type: 'text',
name: 'teamName',
message: 'Convex team name',
initial: accountName,
validate: (value: string) => (value.trim() ? true : 'Team name cannot be empty'),
});
return teamName;
}
private async resolveProjectNameAsync(
flagValue: string | undefined,
slug: string,
nonInteractive: boolean
): Promise<string> {
if (flagValue) {
return flagValue;
}
if (nonInteractive) {
return slug;
}
const { projectName } = await promptAsync({
type: 'text',
name: 'projectName',
message: 'Convex project name',
initial: slug,
validate: (value: string) => (value.trim() ? true : 'Project name cannot be empty'),
});
return projectName;
}
private async selectConnectionAsync(
connections: ConvexTeamConnectionData[],
nonInteractive: boolean
): Promise<ConvexTeamConnectionData> {
if (nonInteractive) {
return connections[0];
}
const choices = connections.map(c => ({
title: `${formatConvexTeam(c)} (created ${new Date(c.createdAt).toLocaleDateString()})`,
value: c,
}));
return await selectAsync('Select a Convex team connection', choices);
}
private getActorEmail(actor: Actor): string | null {
return actor.__typename === 'User' ? actor.email : null;
}
private async sendTeamInviteAsync(
graphqlClient: Parameters<typeof ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync>[0],
connection: ConvexTeamConnectionData,
actor: Actor,
{ nonInteractive }: { nonInteractive: boolean }
): Promise<void> {
const email = this.getActorEmail(actor);
if (!email) {
Log.warn(
`Could not determine your verified email address, so no Convex team invitation was sent. Run ${chalk.cyan(
'eas integrations:convex:team:invite'
)} after signing in with a user account.`
);
return;
}
if (!(await confirmRecentConvexInviteAsync(connection, { nonInteractive }))) {
Log.warn('Skipped sending Convex team invitation.');
return;
}
try {
await ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync(graphqlClient, {
convexTeamConnectionId: connection.id,
});
Log.withTick(`Sent Convex team invitation to ${chalk.bold(email)}`);
} catch (error) {
Log.warn(
`Failed to send Convex team invitation to ${email}. Run ${chalk.cyan(
'eas integrations:convex:team:invite'
)} to retry.`
);
Log.warn(error instanceof Error ? error.message : String(error));
}
}
private async installConvexPackageAsync(projectDir: string): Promise<void> {
Log.newLine();
Log.log(`Running ${chalk.bold('npx expo install convex')}`);
Log.newLine();
try {
await spawnAsync('npx', ['expo', 'install', 'convex'], {
cwd: projectDir,
stdio: 'inherit',
});
Log.withTick(`Installed the ${chalk.bold('convex')} npm package`);
} catch (error) {
Log.warn(
`Failed to install the ${chalk.bold('convex')} npm package. Run ${chalk.cyan(
'npx expo install convex'
)} from your project directory, then run ${chalk.cyan('npx convex dev')}.`
);
throw error;
}
}
private async writeEnvLocalAsync(
projectDir: string,
deployKey: string,
convexUrl: string,
nonInteractive: boolean
): Promise<void> {
const envPath = path.join(projectDir, '.env.local');
let existingContent: Record<string, string> = {};
let rawContent = '';
if (await fs.pathExists(envPath)) {
rawContent = await fs.readFile(envPath, 'utf8');
existingContent = dotenv.parse(rawContent);
if (existingContent.CONVEX_DEPLOY_KEY && !nonInteractive) {
const overwrite = await confirmAsync({
message: `.env.local already contains CONVEX_DEPLOY_KEY. Overwrite Convex values?`,
});
if (!overwrite) {
Log.log('Skipping .env.local update. Deploy key:');
Log.log(` CONVEX_DEPLOY_KEY=${deployKey}`);
Log.log(` EXPO_PUBLIC_CONVEX_URL=${convexUrl}`);
return;
}
}
}
const updatedContent = this.mergeEnvContent(rawContent, {
CONVEX_DEPLOY_KEY: deployKey,
EXPO_PUBLIC_CONVEX_URL: convexUrl,
});
await fs.writeFile(envPath, updatedContent);
Log.withTick(`Wrote Convex 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)) {
// Replace existing line if present
const regex = new RegExp(`^${key}=.*$`, 'm');
if (regex.test(content)) {
content = content.replace(regex, `${key}=${value}`);
delete keysToAdd[key];
}
}
// Append any keys that weren't already in the file
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;
}
}
@@ -0,0 +1,46 @@
import openBrowserAsync from 'better-opn';
import EasCommand from '../../../commandUtils/EasCommand';
import { getConvexProjectDashboardUrl, logNoConvexProject } from '../../../commandUtils/convex';
import { ConvexQuery } from '../../../graphql/queries/ConvexQuery';
import { ora } from '../../../ora';
export default class IntegrationsConvexDashboard extends EasCommand {
static override description = 'open the Convex dashboard for the linked Convex project';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsConvexDashboard, {
nonInteractive: false,
withServerSideEnvironment: null,
});
const convexProject = await ConvexQuery.getConvexProjectByAppIdAsync(graphqlClient, projectId);
if (!convexProject) {
logNoConvexProject(exp.slug);
return;
}
const dashboardUrl = getConvexProjectDashboardUrl(convexProject);
const failedMessage = `Unable to open a web browser. Convex 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,34 @@
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { formatConvexProject, logNoConvexProject } from '../../../commandUtils/convex';
import { ConvexQuery } from '../../../graphql/queries/ConvexQuery';
import Log from '../../../log';
export default class IntegrationsConvexProject extends EasCommand {
static override description = 'display the Convex project linked to the current Expo app';
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsConvexProject, {
nonInteractive: false,
withServerSideEnvironment: null,
});
const convexProject = await ConvexQuery.getConvexProjectByAppIdAsync(graphqlClient, projectId);
if (!convexProject) {
logNoConvexProject(exp.slug);
return;
}
Log.log(chalk.bold(`Convex project linked to ${exp.slug}`));
Log.log(formatConvexProject(convexProject));
}
}
@@ -0,0 +1,87 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../../commandUtils/EasCommand';
import {
formatConvexProject,
getConvexProjectDashboardUrl,
logNoConvexProject,
} from '../../../../commandUtils/convex';
import { EASNonInteractiveFlag } from '../../../../commandUtils/flags';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import Log, { link } from '../../../../log';
import { ora } from '../../../../ora';
import { confirmAsync } from '../../../../prompts';
export default class IntegrationsConvexProjectDelete extends EasCommand {
static override description =
'remove the Convex project link for the current Expo app from EAS servers';
static override flags = {
...EASNonInteractiveFlag,
yes: Flags.boolean({
char: 'y',
description: 'Skip confirmation prompt',
default: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const {
flags: { 'non-interactive': nonInteractive, yes },
} = await this.parse(IntegrationsConvexProjectDelete);
const {
privateProjectConfig: { projectId, exp },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsConvexProjectDelete, {
nonInteractive,
withServerSideEnvironment: null,
});
const convexProject = await ConvexQuery.getConvexProjectByAppIdAsync(graphqlClient, projectId);
if (!convexProject) {
logNoConvexProject(exp.slug);
return;
}
Log.addNewLineIfNone();
Log.log(formatConvexProject(convexProject));
Log.newLine();
const dashboardUrl = getConvexProjectDashboardUrl(convexProject);
if (!nonInteractive && !yes) {
const confirmed = await confirmAsync({
message: `Remove this Convex project link from EAS servers? This does not destroy resources on Convex. Convex dashboard: ${link(
dashboardUrl,
{ dim: false }
)}`,
});
if (!confirmed) {
Log.error('Canceled deletion of the Convex project link');
return;
}
} else {
Log.warn(
`Removing the Convex project link from EAS servers. This does not destroy resources on Convex: ${dashboardUrl}`
);
}
const spinner = ora('Removing Convex project link').start();
try {
await ConvexMutation.deleteConvexProjectAsync(graphqlClient, convexProject.id);
spinner.succeed(
`Removed Convex project ${chalk.bold(convexProject.convexProjectName)} from EAS servers`
);
} catch (error) {
spinner.fail('Failed to remove Convex project link');
throw error;
}
}
}
@@ -0,0 +1,45 @@
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { formatConvexTeamConnection, logNoConvexTeams } from '../../../commandUtils/convex';
import { ConvexQuery } from '../../../graphql/queries/ConvexQuery';
import Log from '../../../log';
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
export default class IntegrationsConvexTeam extends EasCommand {
static override description =
"display Convex teams linked to the current Expo app's owner account";
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsConvexTeam, {
nonInteractive: false,
withServerSideEnvironment: null,
});
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const connections = await ConvexQuery.getConvexTeamConnectionsByAccountIdAsync(
graphqlClient,
account.id
);
if (connections.length === 0) {
logNoConvexTeams(account.name);
return;
}
Log.log(chalk.bold(`Convex teams linked to @${account.name}`));
for (const [index, connection] of connections.entries()) {
if (index > 0) {
Log.newLine();
}
Log.log(formatConvexTeamConnection(connection));
}
}
}
@@ -0,0 +1,138 @@
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../../commandUtils/EasCommand';
import {
formatConvexTeam,
formatConvexTeamConnection,
getConvexTeamDashboardUrl,
logNoConvexTeams,
} from '../../../../commandUtils/convex';
import { EASNonInteractiveFlag } from '../../../../commandUtils/flags';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
import Log, { link } from '../../../../log';
import { ora } from '../../../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { confirmAsync, selectAsync } from '../../../../prompts';
export default class IntegrationsConvexTeamDelete extends EasCommand {
static override description =
"remove a Convex team link from the current Expo app owner account's EAS servers";
static override args = {
CONVEX_TEAM: Args.string({
required: false,
description: 'Slug of the Convex team to remove',
}),
};
static override flags = {
...EASNonInteractiveFlag,
yes: Flags.boolean({
char: 'y',
description: 'Skip confirmation prompt',
default: false,
}),
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const {
args: { CONVEX_TEAM: team },
flags: { 'non-interactive': nonInteractive, yes },
} = await this.parse(IntegrationsConvexTeamDelete);
const {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient },
} = await this.getContextAsync(IntegrationsConvexTeamDelete, {
nonInteractive,
withServerSideEnvironment: null,
});
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const connections = await ConvexQuery.getConvexTeamConnectionsByAccountIdAsync(
graphqlClient,
account.id
);
if (connections.length === 0) {
logNoConvexTeams(account.name);
return;
}
const connection = await this.resolveConnectionAsync(connections, team, nonInteractive);
Log.addNewLineIfNone();
Log.log(formatConvexTeamConnection(connection));
Log.newLine();
const dashboardUrl = getConvexTeamDashboardUrl(connection);
if (!nonInteractive && !yes) {
const confirmed = await confirmAsync({
message: `Remove this Convex team link from EAS servers? This does not destroy resources on Convex. Convex dashboard: ${link(
dashboardUrl,
{ dim: false }
)}`,
});
if (!confirmed) {
Log.error('Canceled deletion of the Convex team link');
return;
}
} else {
Log.warn(
`Removing the Convex team link from EAS servers. This does not destroy resources on Convex: ${dashboardUrl}`
);
}
const spinner = ora('Removing Convex team link').start();
try {
await ConvexMutation.deleteConvexTeamConnectionAsync(graphqlClient, connection.id);
spinner.succeed(
`Removed Convex team ${chalk.bold(formatConvexTeam(connection))} from EAS servers`
);
} catch (error) {
spinner.fail('Failed to remove Convex team link');
throw error;
}
}
private async resolveConnectionAsync(
connections: ConvexTeamConnectionData[],
team: string | undefined,
nonInteractive: boolean
): Promise<ConvexTeamConnectionData> {
if (team) {
const connection = connections.find(
item => item.convexTeamSlug === team || item.convexTeamName === team || item.id === team
);
if (!connection) {
throw new Error(`Convex team ${team} is not linked to this account.`);
}
return connection;
}
if (connections.length === 1) {
return connections[0];
}
if (nonInteractive) {
throw new Error(
'Convex team slug must be provided in non-interactive mode when multiple Convex team links exist.'
);
}
return await selectAsync(
'Select a Convex team link to remove',
connections.map(connection => ({
title: formatConvexTeam(connection),
value: connection,
}))
);
}
}
@@ -0,0 +1,159 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../../commandUtils/EasCommand';
import {
confirmRecentConvexInviteAsync,
formatConvexTeam,
getConvexTeamDashboardUrl,
logNoConvexTeams,
} from '../../../../commandUtils/convex';
import { EASNonInteractiveFlag } from '../../../../commandUtils/flags';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { ConvexQuery } from '../../../../graphql/queries/ConvexQuery';
import { ConvexTeamConnectionData } from '../../../../graphql/types/ConvexTeamConnection';
import Log, { link } from '../../../../log';
import { ora } from '../../../../ora';
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
import { selectAsync } from '../../../../prompts';
export default class IntegrationsConvexTeamInvite extends EasCommand {
static override description = 'send a Convex team invitation to your verified email address';
static override args = {
CONVEX_TEAM: Args.string({
required: false,
description: 'Slug of the Convex team to invite yourself to',
}),
};
static override flags = {
...EASNonInteractiveFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectConfig,
};
async runAsync(): Promise<void> {
const {
args: { CONVEX_TEAM: team },
flags: { 'non-interactive': nonInteractive },
} = await this.parse(IntegrationsConvexTeamInvite);
const {
privateProjectConfig: { projectId },
loggedIn: { graphqlClient, actor },
} = await this.getContextAsync(IntegrationsConvexTeamInvite, {
nonInteractive,
withServerSideEnvironment: null,
});
const email = this.getActorEmail(actor);
if (!email) {
Log.warn(
`Could not determine your verified email address, so no Convex team invitation was sent. Run ${chalk.cyan(
'eas integrations:convex:team:invite'
)} after signing in with a user account.`
);
return;
}
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
const connections = await ConvexQuery.getConvexTeamConnectionsByAccountIdAsync(
graphqlClient,
account.id
);
if (connections.length === 0) {
logNoConvexTeams(account.name);
return;
}
const connection = await this.resolveConnectionAsync(connections, team, nonInteractive);
Log.addNewLineIfNone();
this.logTeam(connection);
this.logPreviousInvite(connection);
Log.newLine();
if (!(await confirmRecentConvexInviteAsync(connection, { nonInteractive }))) {
Log.warn('Skipped sending Convex team invitation.');
return;
}
const spinner = ora(`Sending Convex team invitation to ${email}`).start();
try {
await ConvexMutation.sendConvexTeamInviteToVerifiedEmailAsync(graphqlClient, {
convexTeamConnectionId: connection.id,
});
spinner.succeed(
`Sent Convex team invitation to ${chalk.bold(email)} for ${chalk.bold(formatConvexTeam(connection))}`
);
Log.log(`Convex dashboard: ${link(getConvexTeamDashboardUrl(connection), { dim: false })}`);
} catch (error) {
spinner.fail('Failed to send Convex team invitation');
throw error;
}
}
private getActorEmail(actor: { __typename: string; [key: string]: any }): string | null {
return 'email' in actor && typeof actor.email === 'string' ? actor.email : null;
}
private logTeam(connection: ConvexTeamConnectionData): void {
Log.log(chalk.bold(`Convex team ${formatConvexTeam(connection)}`));
Log.log(
`${chalk.bold('Dashboard')}: ${link(getConvexTeamDashboardUrl(connection), { dim: false })}`
);
}
private logPreviousInvite(connection: ConvexTeamConnectionData): void {
if (!connection.invitedEmail && !connection.invitedAt) {
return;
}
Log.newLine();
Log.log(chalk.bold('Previous invite'));
if (connection.invitedEmail) {
Log.log(`${chalk.bold('Email')}: ${connection.invitedEmail}`);
}
if (connection.invitedAt) {
Log.log(`${chalk.bold('Sent at')}: ${connection.invitedAt}`);
}
}
private async resolveConnectionAsync(
connections: ConvexTeamConnectionData[],
team: string | undefined,
nonInteractive: boolean
): Promise<ConvexTeamConnectionData> {
if (team) {
const connection = connections.find(
item => item.convexTeamSlug === team || item.convexTeamName === team || item.id === team
);
if (!connection) {
throw new Error(`Convex team ${team} is not linked to this account.`);
}
return connection;
}
if (connections.length === 1) {
return connections[0];
}
if (nonInteractive) {
throw new Error(
'Convex team slug must be provided in non-interactive mode when multiple Convex team links exist.'
);
}
return await selectAsync(
'Select a Convex team link to invite yourself to',
connections.map(connection => ({
title: formatConvexTeam(connection),
value: connection,
}))
);
}
}
+1
View File
@@ -103,6 +103,7 @@ async function createFingerprintWithoutLoggingAsync(
if (options.debug) {
fingerprintOptions.debug = true;
}
fingerprintOptions.silent = true;
return await withTemporaryEnvAsync(options.env ?? {}, () =>
Fingerprint.createFingerprintAsync(projectDir, fingerprintOptions)
+94 -1
View File
@@ -3616,6 +3616,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
releaseChannel?: Maybe<Scalars['String']['output']>;
requiredPackageManager?: Maybe<Scalars['String']['output']>;
resolvedEnvironment?: Maybe<Scalars['EnvironmentVariableEnvironment']['output']>;
resolvedImage?: Maybe<Scalars['String']['output']>;
/**
* The builder resource class requested by the developer
* @deprecated Use resourceClassDisplayName instead
@@ -3629,7 +3630,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
/** @deprecated Use 'runtime' field instead. */
runtimeVersion?: Maybe<Scalars['String']['output']>;
sdkVersion?: Maybe<Scalars['String']['output']>;
/** @deprecated Check logs instead. */
/** @deprecated Use 'resolvedImage' for the concrete image the build runs on. */
selectedImage?: Maybe<Scalars['String']['output']>;
status: BuildStatus;
submissions: Array<Submission>;
@@ -4231,12 +4232,32 @@ export type ConvexProject = {
updatedAt: Scalars['DateTime']['output'];
};
export type ConvexProjectMutation = {
__typename?: 'ConvexProjectMutation';
deleteConvexProject: Scalars['ID']['output'];
setupConvexProject: SetupConvexProjectResult;
};
export type ConvexProjectMutationDeleteConvexProjectArgs = {
convexProjectId: Scalars['ID']['input'];
};
export type ConvexProjectMutationSetupConvexProjectArgs = {
input: SetupConvexProjectInput;
};
export type ConvexTeamConnection = {
__typename?: 'ConvexTeamConnection';
account: Account;
convexTeamIdentifier: Scalars['String']['output'];
convexTeamName: Scalars['String']['output'];
convexTeamSlug: Scalars['String']['output'];
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
invitedAt?: Maybe<Scalars['DateTime']['output']>;
invitedEmail?: Maybe<Scalars['String']['output']>;
updatedAt: Scalars['DateTime']['output'];
};
@@ -7836,6 +7857,7 @@ export type RootMutation = {
build: BuildMutation;
/** Mutations that create, update, and delete Build Annotations */
buildAnnotation: BuildAnnotationMutation;
convexProject: ConvexProjectMutation;
convexTeamConnection: ConvexTeamConnectionMutation;
customDomain: CustomDomainMutation;
deployments: DeploymentsMutation;
@@ -8434,6 +8456,21 @@ export type SentryProjectMutationDeleteSentryProjectArgs = {
sentryProjectId: Scalars['ID']['input'];
};
export type SetupConvexProjectInput = {
appId: Scalars['ID']['input'];
convexTeamConnectionId: Scalars['ID']['input'];
deploymentRegion?: InputMaybe<Scalars['String']['input']>;
projectName: Scalars['String']['input'];
};
export type SetupConvexProjectResult = {
__typename?: 'SetupConvexProjectResult';
convexDeploymentName: Scalars['String']['output'];
convexDeploymentUrl: Scalars['String']['output'];
convexProject: ConvexProject;
deployKey: Scalars['String']['output'];
};
export type SizeBreakdownCategory = {
__typename?: 'SizeBreakdownCategory';
assetCount: Scalars['Int']['output'];
@@ -8750,6 +8787,7 @@ export type SubscriptionDetails = {
endedAt?: Maybe<Scalars['DateTime']['output']>;
futureSubscription?: Maybe<FutureSubscription>;
id: Scalars['ID']['output'];
includedAgentCreditsInCents: Scalars['Int']['output'];
isDowngrading?: Maybe<Scalars['Boolean']['output']>;
meteredBillingStatus: MeteredBillingStatus;
name?: Maybe<Scalars['String']['output']>;
@@ -11904,6 +11942,41 @@ export type RetryIosBuildMutationVariables = Exact<{
export type RetryIosBuildMutation = { __typename?: 'RootMutation', build: { __typename?: 'BuildMutation', retryIosBuild: { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'PartnerActor', id: string, displayName: string } | { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null } } };
export type CreateConvexTeamConnectionMutationVariables = Exact<{
convexTeamConnectionData: CreateConvexTeamConnectionInput;
}>;
export type CreateConvexTeamConnectionMutation = { __typename?: 'RootMutation', convexTeamConnection: { __typename?: 'ConvexTeamConnectionMutation', createConvexTeamConnection: { __typename?: 'ConvexTeamConnection', id: string, convexTeamIdentifier: string, convexTeamName: string, convexTeamSlug: string, createdAt: any, updatedAt: any, invitedAt?: any | null, invitedEmail?: string | null } } };
export type DeleteConvexTeamConnectionMutationVariables = Exact<{
convexTeamConnectionId: Scalars['ID']['input'];
}>;
export type DeleteConvexTeamConnectionMutation = { __typename?: 'RootMutation', convexTeamConnection: { __typename?: 'ConvexTeamConnectionMutation', deleteConvexTeamConnection: { __typename?: 'ConvexTeamConnection', id: string, convexTeamIdentifier: string, convexTeamName: string, convexTeamSlug: string, createdAt: any, updatedAt: any, invitedAt?: any | null, invitedEmail?: string | null } } };
export type SetupConvexProjectMutationVariables = Exact<{
input: SetupConvexProjectInput;
}>;
export type SetupConvexProjectMutation = { __typename?: 'RootMutation', convexProject: { __typename?: 'ConvexProjectMutation', setupConvexProject: { __typename?: 'SetupConvexProjectResult', convexDeploymentName: string, convexDeploymentUrl: string, deployKey: string, convexProject: { __typename?: 'ConvexProject', id: string, convexProjectIdentifier: string, convexProjectName: string, convexProjectSlug: string, createdAt: any, updatedAt: any, convexTeamConnection: { __typename?: 'ConvexTeamConnection', id: string, convexTeamIdentifier: string, convexTeamName: string, convexTeamSlug: string, createdAt: any, updatedAt: any, invitedAt?: any | null, invitedEmail?: string | null } } } } };
export type DeleteConvexProjectMutationVariables = Exact<{
convexProjectId: Scalars['ID']['input'];
}>;
export type DeleteConvexProjectMutation = { __typename?: 'RootMutation', convexProject: { __typename?: 'ConvexProjectMutation', deleteConvexProject: string } };
export type SendConvexTeamInviteToVerifiedEmailMutationVariables = Exact<{
input: SendConvexTeamInviteToVerifiedEmailInput;
}>;
export type SendConvexTeamInviteToVerifiedEmailMutation = { __typename?: 'RootMutation', convexTeamConnection: { __typename?: 'ConvexTeamConnectionMutation', sendConvexTeamInviteToVerifiedEmail: boolean } };
export type CreateDeviceRunSessionMutationVariables = Exact<{
deviceRunSessionInput: CreateDeviceRunSessionInput;
}>;
@@ -12385,6 +12458,20 @@ export type ViewUpdateChannelsPaginatedOnAppQueryVariables = Exact<{
export type ViewUpdateChannelsPaginatedOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, channelsPaginated: { __typename?: 'AppChannelsConnection', edges: Array<{ __typename?: 'AppChannelEdge', cursor: string, node: { __typename?: 'UpdateChannel', id: string, name: string, branchMapping: string } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type ConvexTeamConnectionsByAccountIdQueryVariables = Exact<{
accountId: Scalars['String']['input'];
}>;
export type ConvexTeamConnectionsByAccountIdQuery = { __typename?: 'RootQuery', account: { __typename?: 'AccountQuery', byId: { __typename?: 'Account', id: string, convexTeamConnections: Array<{ __typename?: 'ConvexTeamConnection', id: string, convexTeamIdentifier: string, convexTeamName: string, convexTeamSlug: string, createdAt: any, updatedAt: any, invitedAt?: any | null, invitedEmail?: string | null }> } } };
export type ConvexProjectByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
export type ConvexProjectByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, convexProject?: { __typename?: 'ConvexProject', id: string, convexProjectIdentifier: string, convexProjectName: string, convexProjectSlug: string, createdAt: any, updatedAt: any, convexTeamConnection: { __typename?: 'ConvexTeamConnection', id: string, convexTeamIdentifier: string, convexTeamName: string, convexTeamSlug: string, createdAt: any, updatedAt: any, invitedAt?: any | null, invitedEmail?: string | null } } | null } } };
export type DeviceRunSessionByIdQueryVariables = Exact<{
deviceRunSessionId: Scalars['ID']['input'];
}>;
@@ -12672,6 +12759,12 @@ export type BuildWithSubmissionsFragment = { __typename?: 'Build', id: string, s
export type BuildWithFingerprintFragment = { __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string, debugInfoUrl?: string | null, builds: { __typename?: 'AppBuildsConnection', edges: Array<{ __typename?: 'AppBuildEdge', node: { __typename?: 'Build', platform: AppPlatform, id: string } }> }, updates: { __typename?: 'AppUpdatesConnection', edges: Array<{ __typename?: 'AppUpdateEdge', node: { __typename?: 'Update', id: string, platform: string } }> } } | null, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, initiatingActor?: { __typename: 'PartnerActor', id: string, displayName: string } | { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null };
export type ConvexTeamConnectionFragment = { __typename?: 'ConvexTeamConnection', id: string, convexTeamIdentifier: string, convexTeamName: string, convexTeamSlug: string, createdAt: any, updatedAt: any, invitedAt?: any | null, invitedEmail?: string | null };
export type ConvexProjectFragment = { __typename?: 'ConvexProject', id: string, convexProjectIdentifier: string, convexProjectName: string, convexProjectSlug: string, createdAt: any, updatedAt: any, convexTeamConnection: { __typename?: 'ConvexTeamConnection', id: string, convexTeamIdentifier: string, convexTeamName: string, convexTeamSlug: string, createdAt: any, updatedAt: any, invitedAt?: any | null, invitedEmail?: string | null } };
export type SetupConvexProjectResultFragment = { __typename?: 'SetupConvexProjectResult', convexDeploymentName: string, convexDeploymentUrl: string, deployKey: string, convexProject: { __typename?: 'ConvexProject', id: string, convexProjectIdentifier: string, convexProjectName: string, convexProjectSlug: string, createdAt: any, updatedAt: any, convexTeamConnection: { __typename?: 'ConvexTeamConnection', id: string, convexTeamIdentifier: string, convexTeamName: string, convexTeamSlug: string, createdAt: any, updatedAt: any, invitedAt?: any | null, invitedEmail?: string | null } } };
export type EnvironmentSecretFragment = { __typename?: 'EnvironmentSecret', id: string, name: string, type: EnvironmentSecretType, createdAt: any };
export type EnvironmentVariableFragment = { __typename?: 'EnvironmentVariable', id: string, name: string, value?: string | null, environments?: Array<any> | null, createdAt: any, updatedAt: any, scope: EnvironmentVariableScope, visibility?: EnvironmentVariableVisibility | null, type: EnvironmentSecretType };
@@ -0,0 +1,196 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
CreateConvexTeamConnectionInput,
SendConvexTeamInviteToVerifiedEmailInput,
SetupConvexProjectInput,
} from '../generated';
import {
ConvexProjectFragmentNode,
ConvexTeamConnectionData,
ConvexTeamConnectionFragmentNode,
SetupConvexProjectResultData,
SetupConvexProjectResultFragmentNode,
} from '../types/ConvexTeamConnection';
type CreateConvexTeamConnectionMutation = {
convexTeamConnection: {
createConvexTeamConnection: ConvexTeamConnectionData;
};
};
type CreateConvexTeamConnectionMutationVariables = {
convexTeamConnectionData: CreateConvexTeamConnectionInput;
};
type DeleteConvexTeamConnectionMutation = {
convexTeamConnection: {
deleteConvexTeamConnection: ConvexTeamConnectionData;
};
};
type DeleteConvexTeamConnectionMutationVariables = {
convexTeamConnectionId: string;
};
type SetupConvexProjectMutation = {
convexProject: {
setupConvexProject: SetupConvexProjectResultData;
};
};
type SetupConvexProjectMutationVariables = {
input: SetupConvexProjectInput;
};
type DeleteConvexProjectMutation = {
convexProject: {
deleteConvexProject: string;
};
};
type DeleteConvexProjectMutationVariables = {
convexProjectId: string;
};
type SendConvexTeamInviteToVerifiedEmailMutation = {
convexTeamConnection: {
sendConvexTeamInviteToVerifiedEmail: boolean;
};
};
type SendConvexTeamInviteToVerifiedEmailMutationVariables = {
input: SendConvexTeamInviteToVerifiedEmailInput;
};
export const ConvexMutation = {
async createConvexTeamConnectionAsync(
graphqlClient: ExpoGraphqlClient,
input: CreateConvexTeamConnectionInput
): Promise<ConvexTeamConnectionData> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<CreateConvexTeamConnectionMutation, CreateConvexTeamConnectionMutationVariables>(
gql`
mutation CreateConvexTeamConnection(
$convexTeamConnectionData: CreateConvexTeamConnectionInput!
) {
convexTeamConnection {
createConvexTeamConnection(
convexTeamConnectionData: $convexTeamConnectionData
) {
id
...ConvexTeamConnectionFragment
}
}
}
${print(ConvexTeamConnectionFragmentNode)}
`,
{ convexTeamConnectionData: input }
)
.toPromise()
);
return data.convexTeamConnection.createConvexTeamConnection;
},
async deleteConvexTeamConnectionAsync(
graphqlClient: ExpoGraphqlClient,
convexTeamConnectionId: string
): Promise<ConvexTeamConnectionData> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteConvexTeamConnectionMutation, DeleteConvexTeamConnectionMutationVariables>(
gql`
mutation DeleteConvexTeamConnection($convexTeamConnectionId: ID!) {
convexTeamConnection {
deleteConvexTeamConnection(
convexTeamConnectionId: $convexTeamConnectionId
) {
id
...ConvexTeamConnectionFragment
}
}
}
${print(ConvexTeamConnectionFragmentNode)}
`,
{ convexTeamConnectionId }
)
.toPromise()
);
return data.convexTeamConnection.deleteConvexTeamConnection;
},
async setupConvexProjectAsync(
graphqlClient: ExpoGraphqlClient,
input: SetupConvexProjectInput
): Promise<SetupConvexProjectResultData> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<SetupConvexProjectMutation, SetupConvexProjectMutationVariables>(
gql`
mutation SetupConvexProject($input: SetupConvexProjectInput!) {
convexProject {
setupConvexProject(input: $input) {
...SetupConvexProjectResultFragment
}
}
}
${print(ConvexTeamConnectionFragmentNode)}
${print(ConvexProjectFragmentNode)}
${print(SetupConvexProjectResultFragmentNode)}
`,
{ input }
)
.toPromise()
);
return data.convexProject.setupConvexProject;
},
async deleteConvexProjectAsync(
graphqlClient: ExpoGraphqlClient,
convexProjectId: string
): Promise<string> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteConvexProjectMutation, DeleteConvexProjectMutationVariables>(
gql`
mutation DeleteConvexProject($convexProjectId: ID!) {
convexProject {
deleteConvexProject(convexProjectId: $convexProjectId)
}
}
`,
{ convexProjectId }
)
.toPromise()
);
return data.convexProject.deleteConvexProject;
},
async sendConvexTeamInviteToVerifiedEmailAsync(
graphqlClient: ExpoGraphqlClient,
input: SendConvexTeamInviteToVerifiedEmailInput
): Promise<boolean> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<
SendConvexTeamInviteToVerifiedEmailMutation,
SendConvexTeamInviteToVerifiedEmailMutationVariables
>(
gql`
mutation SendConvexTeamInviteToVerifiedEmail($input: SendConvexTeamInviteToVerifiedEmailInput!) {
convexTeamConnection {
sendConvexTeamInviteToVerifiedEmail(input: $input)
}
}
`,
{ input }
)
.toPromise()
);
return data.convexTeamConnection.sendConvexTeamInviteToVerifiedEmail;
},
};
@@ -0,0 +1,102 @@
import { print } from 'graphql';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import {
ConvexProjectData,
ConvexProjectFragmentNode,
ConvexTeamConnectionData,
ConvexTeamConnectionFragmentNode,
} from '../types/ConvexTeamConnection';
type ConvexTeamConnectionsByAccountIdQuery = {
account: {
byId: {
id: string;
convexTeamConnections: ConvexTeamConnectionData[];
};
};
};
type ConvexTeamConnectionsByAccountIdQueryVariables = {
accountId: string;
};
type ConvexProjectByAppIdQuery = {
app: {
byId: {
id: string;
convexProject?: ConvexProjectData | null;
};
};
};
type ConvexProjectByAppIdQueryVariables = {
appId: string;
};
export const ConvexQuery = {
async getConvexTeamConnectionsByAccountIdAsync(
graphqlClient: ExpoGraphqlClient,
accountId: string
): Promise<ConvexTeamConnectionData[]> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<
ConvexTeamConnectionsByAccountIdQuery,
ConvexTeamConnectionsByAccountIdQueryVariables
>(
gql`
query ConvexTeamConnectionsByAccountId($accountId: String!) {
account {
byId(accountId: $accountId) {
id
convexTeamConnections {
id
...ConvexTeamConnectionFragment
}
}
}
}
${print(ConvexTeamConnectionFragmentNode)}
`,
{ accountId }
)
.toPromise()
);
return data.account.byId.convexTeamConnections;
},
async getConvexProjectByAppIdAsync(
graphqlClient: ExpoGraphqlClient,
appId: string
): Promise<ConvexProjectData | null> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<ConvexProjectByAppIdQuery, ConvexProjectByAppIdQueryVariables>(
gql`
query ConvexProjectByAppId($appId: String!) {
app {
byId(appId: $appId) {
id
convexProject {
id
...ConvexProjectFragment
}
}
}
}
${print(ConvexTeamConnectionFragmentNode)}
${print(ConvexProjectFragmentNode)}
`,
{ appId },
{ additionalTypenames: ['App', 'ConvexProject'] }
)
.toPromise()
);
return data.app.byId.convexProject ?? null;
},
};
@@ -0,0 +1,74 @@
import gql from 'graphql-tag';
import { ConvexProject, ConvexTeamConnection, SetupConvexProjectResult } from '../generated';
export type ConvexTeamConnectionData = Pick<
ConvexTeamConnection,
| 'id'
| 'convexTeamIdentifier'
| 'convexTeamName'
| 'convexTeamSlug'
| 'createdAt'
| 'updatedAt'
| 'invitedAt'
| 'invitedEmail'
>;
export type ConvexProjectData = Pick<
ConvexProject,
| 'id'
| 'convexProjectIdentifier'
| 'convexProjectName'
| 'convexProjectSlug'
| 'createdAt'
| 'updatedAt'
> & {
convexTeamConnection: ConvexTeamConnectionData;
};
export type SetupConvexProjectResultData = Pick<
SetupConvexProjectResult,
'convexDeploymentName' | 'convexDeploymentUrl' | 'deployKey'
> & {
convexProject: ConvexProjectData;
};
export const ConvexTeamConnectionFragmentNode = gql`
fragment ConvexTeamConnectionFragment on ConvexTeamConnection {
id
convexTeamIdentifier
convexTeamName
convexTeamSlug
createdAt
updatedAt
invitedAt
invitedEmail
}
`;
export const ConvexProjectFragmentNode = gql`
fragment ConvexProjectFragment on ConvexProject {
id
convexProjectIdentifier
convexProjectName
convexProjectSlug
createdAt
updatedAt
convexTeamConnection {
id
...ConvexTeamConnectionFragment
}
}
`;
export const SetupConvexProjectResultFragmentNode = gql`
fragment SetupConvexProjectResultFragment on SetupConvexProjectResult {
convexDeploymentName
convexDeploymentUrl
deployKey
convexProject {
id
...ConvexProjectFragment
}
}
`;
@@ -65,6 +65,11 @@ jest.mock('@expo/build-tools', () => {
})),
};
});
jest.mock('@expo/turtle-spawn', () =>
jest.fn(async () => {
throw new Error('Cannot find module expo/package.json');
})
);
jest.mock('../service', () => {
return function () {
const BuildService = new (jest.requireActual('../service').default)();
@@ -80,6 +85,9 @@ jest.mock('../config', () => {
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
};
});
jest.mock('../utils/turtleFetch', () => ({
turtleFetch: jest.fn(async () => ({ ok: true })),
}));
jest.mock('../upload', () => {
return {
...jest.requireActual('../upload'),
+4 -34
View File
@@ -5,11 +5,9 @@ import { getBuildEnv } from '../env';
describe(getBuildEnv.name, () => {
const originalSentryDsn = config.sentry.dsn;
const originalCocoapodsCacheUrl = config.cocoapodsCacheUrl;
afterEach(() => {
config.sentry.dsn = originalSentryDsn;
config.cocoapodsCacheUrl = originalCocoapodsCacheUrl;
});
it('passes the CLI sentry DSN to worker child processes', () => {
@@ -36,7 +34,7 @@ describe(getBuildEnv.name, () => {
expect(env.EAS_CLI_SENTRY_DSN).toBe(config.sentry.dsn);
});
it('passes through EXPO_USE_PRECOMPILED_MODULES for flagged iOS jobs', () => {
it('does not add precompiled modules env vars to flagged iOS jobs', () => {
const env = getBuildEnv({
job: {
platform: Platform.IOS,
@@ -56,37 +54,9 @@ describe(getBuildEnv.name, () => {
buildId: 'build-id',
});
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBe('1');
expect(env.EXPO_PRECOMPILED_MODULES_BASE_URL).toBe(
'https://storage.googleapis.com/eas-build-precompiled-modules/'
);
expect(env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(env.EXPO_PRECOMPILED_MODULES_BASE_URL).toBeUndefined();
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
});
it('passes precompiled modules through the CocoaPods cache URL when available', () => {
config.cocoapodsCacheUrl = 'https://cache.example.com';
const env = getBuildEnv({
job: {
platform: Platform.IOS,
type: Workflow.GENERIC,
builderEnvironment: {
env: {
EAS_USE_PRECOMPILED_MODULES: '1',
},
},
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.EXPO_PRECOMPILED_MODULES_BASE_URL).toBe(
'https://cache.example.com/storage.googleapis.com/eas-build-precompiled-modules/'
);
});
});
@@ -0,0 +1,257 @@
import {
ArchiveSourceType,
BuildMode,
BuildTrigger,
Platform,
Workflow,
errors,
} from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import path from 'path';
import { build } from '../build';
import { createBuildContext } from '../context';
import BuildService, { getExpoPackageVersionAsync } from '../service';
import { turtleFetch } from '../utils/turtleFetch';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => jest.fn());
jest.mock('../build', () => ({
build: jest.fn(),
}));
jest.mock('../context', () => ({
createBuildContext: jest.fn(),
}));
jest.mock('../external/analytics', () => ({
Analytics: jest.fn().mockImplementation(() => ({
flushEventsAsync: jest.fn(),
logEvent: jest.fn(),
})),
}));
jest.mock('../logger', () => {
const logger = {
child: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
level: jest.fn(),
warn: jest.fn(),
};
logger.child.mockReturnValue(logger);
return {
__esModule: true,
default: logger,
createBuildLoggerWithSecretsFilter: jest.fn(async () => ({
cleanUp: jest.fn(),
logBuffer: { getLogs: jest.fn(() => []), getPhaseLogs: jest.fn(() => []) },
logger,
})),
};
});
jest.mock('../sentry', () => ({
__esModule: true,
default: {
handleError: jest.fn(),
},
}));
jest.mock('../utils/turtleFetch', () => ({
turtleFetch: jest.fn(),
}));
jest.mock('../config', () => {
const config = jest.requireActual('../config').default;
return {
__esModule: true,
default: {
...config,
buildId: 'build-id',
wwwApiV2BaseUrl: 'http://api.expo.test/v2/',
},
};
});
describe(getExpoPackageVersionAsync, () => {
const projectRoot = '/test-project';
const expoPackageJsonPath = path.join(projectRoot, 'node_modules/expo/package.json');
const buildContext = {
env: { PATH: '/usr/bin' },
getReactNativeProjectDirectory: () => projectRoot,
} as any;
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
});
it('returns the exact installed expo package version', async () => {
jest.mocked(spawn).mockResolvedValue({ stdout: Buffer.from(expoPackageJsonPath) } as any);
vol.fromJSON({
[expoPackageJsonPath]: JSON.stringify({ version: '55.0.17' }),
});
await expect(getExpoPackageVersionAsync(buildContext)).resolves.toBe('55.0.17');
expect(spawn).toHaveBeenCalledWith(
'node',
['--print', "require.resolve('expo/package.json')"],
expect.objectContaining({
cwd: projectRoot,
env: buildContext.env,
})
);
});
it('throws a user error when expo package version resolution fails', async () => {
jest.mocked(spawn).mockRejectedValue(new Error('Cannot find module expo/package.json'));
await expect(getExpoPackageVersionAsync(buildContext)).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
});
await expect(getExpoPackageVersionAsync(buildContext)).rejects.toBeInstanceOf(errors.UserError);
});
it('throws a user error when the installed expo package version is not valid semver', async () => {
jest.mocked(spawn).mockResolvedValue({ stdout: Buffer.from(expoPackageJsonPath) } as any);
vol.fromJSON({
[expoPackageJsonPath]: JSON.stringify({ version: 'invalid-version' }),
});
await expect(getExpoPackageVersionAsync(buildContext)).rejects.toMatchObject({
errorCode: 'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
});
await expect(getExpoPackageVersionAsync(buildContext)).rejects.toBeInstanceOf(errors.UserError);
});
});
describe(BuildService, () => {
const projectRoot = '/test-project';
const job = {
appId: 'app-id',
cache: {
clear: false,
disabled: false,
paths: [],
},
mode: BuildMode.BUILD,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'https://example.com/project.tar.gz',
},
projectRootDirectory: '.',
secrets: {
robotAccessToken: 'robot-token',
},
triggeredBy: BuildTrigger.EAS_CLI,
type: Workflow.GENERIC,
};
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(path.join(projectRoot, 'node_modules/expo/package.json')),
} as any);
jest.mocked(turtleFetch).mockResolvedValue({ ok: true } as any);
jest.mocked(build).mockRejectedValue(new Error('raw build failure'));
jest.mocked(createBuildContext).mockReturnValue({
env: { PATH: '/usr/bin' },
getReactNativeProjectDirectory: () => projectRoot,
job,
logger: {},
} as any);
});
it('sends expo_package_version to Datadog error logs without changing sdk_version', async () => {
vol.fromJSON({
[path.join(projectRoot, 'node_modules/expo/package.json')]: JSON.stringify({
version: '55.0.17',
}),
});
const service = new BuildService();
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
await (service as any).startBuildInternal({
initiatingUserId: 'user-id',
job,
metadata: {
buildProfile: 'production',
reactNativeVersion: '0.83.0',
sdkVersion: '55.0.0',
},
projectId: 'project-id',
});
expect(turtleFetch).toHaveBeenCalledWith(
'http://api.expo.test/v2/turtle-builds/error-logs',
'POST',
expect.objectContaining({
json: expect.objectContaining({
tags: expect.objectContaining({
expo_package_version: '55.0.17',
sdk_version: '55.0.0',
}),
}),
})
);
});
it('sends null expo_package_version to Datadog error logs when expo package version resolution fails', async () => {
jest.mocked(spawn).mockRejectedValue(new Error('Cannot find module expo/package.json'));
const service = new BuildService();
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
await (service as any).startBuildInternal({
initiatingUserId: 'user-id',
job,
metadata: {
sdkVersion: '55.0.0',
},
projectId: 'project-id',
});
expect(turtleFetch).toHaveBeenCalledWith(
'http://api.expo.test/v2/turtle-builds/error-logs',
'POST',
expect.objectContaining({
json: expect.objectContaining({
tags: expect.objectContaining({
expo_package_version: null,
sdk_version: '55.0.0',
}),
}),
})
);
});
it('does not try to resolve expo_package_version when build context setup fails', async () => {
jest.mocked(createBuildContext).mockImplementation(() => {
throw new Error('context setup failure');
});
const service = new BuildService();
jest.spyOn(service, 'finishError').mockResolvedValue(undefined);
await (service as any).startBuildInternal({
initiatingUserId: 'user-id',
job,
metadata: {
sdkVersion: '55.0.0',
},
projectId: 'project-id',
});
expect(spawn).not.toHaveBeenCalled();
expect(turtleFetch).toHaveBeenCalledWith(
'http://api.expo.test/v2/turtle-builds/error-logs',
'POST',
expect.objectContaining({
json: expect.objectContaining({
tags: expect.objectContaining({
expo_package_version: null,
sdk_version: '55.0.0',
}),
}),
})
);
});
});
-19
View File
@@ -12,9 +12,6 @@ import {
} from './external/turtle';
import { getAccessedEnvs } from './utils/env';
const PRECOMPILED_MODULES_BASE_URL =
'https://storage.googleapis.com/eas-build-precompiled-modules/';
// keep in sync with local-build-plugin env vars
// see packages/local-build-plugin/src/build.ts
export function getBuildEnv({
@@ -56,10 +53,6 @@ export function getBuildEnv({
if (runnerPlatform === Platform.IOS) {
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', config.cocoapodsCacheUrl);
setEnv(env, 'COMPILER_INDEX_STORE_ENABLE', 'NO');
if (job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES === '1') {
setEnv(env, 'EXPO_USE_PRECOMPILED_MODULES', '1');
setEnv(env, 'EXPO_PRECOMPILED_MODULES_BASE_URL', getPrecompiledModulesBaseUrl());
}
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
setEnv(env, 'USE_CCACHE', '1');
@@ -170,18 +163,6 @@ function setEnv(env: Env, key: string, value: string | null | undefined): void {
}
}
function getPrecompiledModulesBaseUrl(): string {
if (!config.cocoapodsCacheUrl) {
return PRECOMPILED_MODULES_BASE_URL;
}
const parsedUrl = new URL(PRECOMPILED_MODULES_BASE_URL);
return PRECOMPILED_MODULES_BASE_URL.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${config.cocoapodsCacheUrl.replace(/\/$/, '')}/${parsedUrl.host}`
);
}
const ResourceClassToMaxHeapSize: Record<ResourceClass, string | null> = {
[ResourceClass.LINUX_C3D_STANDARD_4]: '4g', // 4 out of 16 GB, 12 GB left
[ResourceClass.LINUX_C3D_STANDARD_8]: '8g', // 8 out of 32 GB, 24 GB left
+47
View File
@@ -17,9 +17,12 @@ import {
errors,
} from '@expo/eas-build-job';
import { LoggerLevel } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import assert from 'assert';
import fs from 'fs-extra';
import path from 'path';
import semver from 'semver';
import { setTimeout as setTimeoutAsync } from 'timers/promises';
import { build } from './build';
@@ -333,6 +336,11 @@ export default class BuildService {
const robotAccessToken = job.secrets?.robotAccessToken;
if (robotAccessToken) {
const expoPackageVersionResult = this.buildContext
? await asyncResult(getExpoPackageVersionAsync(this.buildContext))
: null;
const expoPackageVersion =
expoPackageVersionResult?.ok === true ? expoPackageVersionResult.value : null;
let rawErrorMessage: string = '';
if (maybeRawError?.stderr) {
rawErrorMessage += '\n' + getLastNLines(100, maybeRawError.stderr);
@@ -358,6 +366,7 @@ export default class BuildService {
platform: job.platform,
workflow: job.type,
sdk_version: metadata?.sdkVersion ?? null,
expo_package_version: expoPackageVersion,
react_native_version: metadata?.reactNativeVersion ?? null,
app_id: job.appId ?? null,
build_profile: metadata?.buildProfile ?? null,
@@ -401,3 +410,41 @@ function getLastNLines(numberOfLines: number, stream: string): string {
return lines.slice(lines.length - numberOfLines, lines.length).join('\n');
}
}
export async function getExpoPackageVersionAsync(ctx: BuildContext<Job>): Promise<string> {
const reactNativeProjectDirectory = ctx.getReactNativeProjectDirectory();
const expoPackageJsonPathResult = await asyncResult(
spawn('node', ['--print', "require.resolve('expo/package.json')"], {
cwd: reactNativeProjectDirectory,
env: ctx.env,
stdio: 'pipe',
})
);
if (!expoPackageJsonPathResult.ok) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_NOT_FOUND',
'Cannot resolve the installed expo package version because require.resolve("expo/package.json") failed.',
{ cause: expoPackageJsonPathResult.reason }
);
}
const expoPackageJsonPath = expoPackageJsonPathResult.value.stdout.toString().trim();
const expoPackageJsonResult = await asyncResult(fs.readJson(expoPackageJsonPath));
if (!expoPackageJsonResult.ok) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_READ_FAILED',
'Cannot resolve the installed expo package version because expo/package.json could not be read.',
{ cause: expoPackageJsonResult.reason }
);
}
const expoPackageVersion = expoPackageJsonResult.value.version;
if (typeof expoPackageVersion !== 'string' || !semver.valid(expoPackageVersion)) {
throw new errors.UserError(
'EAS_BUILD_EXPO_PACKAGE_VERSION_INVALID',
'Cannot resolve the installed expo package version because expo/package.json has an invalid version.'
);
}
return expoPackageVersion;
}