mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b10d44f0ed | ||
|
|
1e2a72982f | ||
|
|
5198a2cc9c | ||
|
|
ca5f0d2306 | ||
|
|
9eefd5a5c4 | ||
|
|
a797caaa8a | ||
|
|
11323be04b | ||
|
|
b1f886ef8d | ||
|
|
14e75df9e5 | ||
|
|
dd88335654 | ||
|
|
5183a6581a | ||
|
|
fb7c812e50 | ||
|
|
e690ac0060 | ||
|
|
c2190ca74f | ||
|
|
157b7cb79f | ||
|
|
0d2b1c8d88 | ||
|
|
788cb57ff1 | ||
|
|
1634a50b6d | ||
|
|
805b403ae3 | ||
|
|
1672bcdadd | ||
|
|
30b1eb2dda | ||
|
|
99ce876d50 | ||
|
|
602502e502 | ||
|
|
30a3924f43 | ||
|
|
d2e6b4c45e | ||
|
|
419efa485f | ||
|
|
3bb4240cb4 | ||
|
|
1677efc00c | ||
|
|
c7b3105f9f | ||
|
|
6fceead233 | ||
|
|
4a109648a6 | ||
|
|
ea139e207c | ||
|
|
7b3c8cf245 | ||
|
|
efbe0bbdff | ||
|
|
8f645d8870 | ||
|
|
97007d17cc | ||
|
|
0dbaa34a8b | ||
|
|
2fc537f9a7 |
No files matched your search
@@ -5,6 +5,14 @@ on:
|
||||
|
||||
name: Release workflow
|
||||
|
||||
# Releases push commits to main, so they must never overlap. The group is a
|
||||
# constant rather than per-ref, so two different tags serialize. Queued runs
|
||||
# are never cancelled, because a half-finished release leaves npm, the git
|
||||
# history, and cli-versions.json out of sync.
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Create a release
|
||||
@@ -47,6 +55,10 @@ jobs:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
# The changelog is generated on the branch it is committed to, so the
|
||||
# job never switches branches with a dirty working tree.
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Install dependencies
|
||||
working-directory: ./scripts
|
||||
@@ -57,23 +69,67 @@ jobs:
|
||||
run: |
|
||||
git config user.email "[email protected]"
|
||||
git config user.name "Expo CI"
|
||||
- name: Check whether the changelog is already updated
|
||||
run: |
|
||||
if grep -qF "## [$EAS_CLI_VERSION](" CHANGELOG.md; then
|
||||
echo "CHANGELOG.md already has an entry for v$EAS_CLI_VERSION."
|
||||
echo "CHANGELOG_ALREADY_UPDATED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "CHANGELOG_ALREADY_UPDATED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
- name: Update changelog
|
||||
if: env.CHANGELOG_ALREADY_UPDATED == 'false'
|
||||
working-directory: ./scripts
|
||||
run: yarn release-changelog "$EAS_CLI_VERSION" > /tmp/current-changelog
|
||||
- name: Reuse the existing changelog entry
|
||||
if: env.CHANGELOG_ALREADY_UPDATED == 'true'
|
||||
run: |
|
||||
{
|
||||
echo "v$EAS_CLI_VERSION"
|
||||
echo
|
||||
awk -v heading="## [$EAS_CLI_VERSION](" '
|
||||
index($0, heading) == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md | sed '/./,$!d'
|
||||
} > /tmp/current-changelog
|
||||
- name: Publish release
|
||||
run: gh release edit "v$EAS_CLI_VERSION" --draft=false -F /tmp/current-changelog
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Commit and push changelog
|
||||
run: |
|
||||
git fetch
|
||||
git checkout main
|
||||
git add CHANGELOG.md
|
||||
git commit --allow-empty -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
|
||||
git push
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changelog changes to commit."
|
||||
else
|
||||
git commit -m "update CHANGELOG.md for v$EAS_CLI_VERSION"
|
||||
git pull --rebase --autostash origin main
|
||||
git push
|
||||
fi
|
||||
bump-cli-versions:
|
||||
name: Gate on npm and bump cli-versions.json
|
||||
runs-on: ubuntu-latest
|
||||
needs: release-changelog
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
token: ${{ secrets.EXPO_BOT_PAT }}
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- uses: ./.github/actions/setup-mise
|
||||
- name: Resolve version
|
||||
run: echo "EAS_CLI_VERSION=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_ENV"
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.email "[email protected]"
|
||||
git config user.name "Expo CI"
|
||||
- name: Verify eas-cli is available on npm
|
||||
run: |
|
||||
availability_deadline_epoch=$(( $(date +%s) + 600 ))
|
||||
availability_timeout_seconds=1800
|
||||
availability_deadline_epoch=$(( $(date +%s) + availability_timeout_seconds ))
|
||||
|
||||
while true; do
|
||||
published="$(npm view "eas-cli@$EAS_CLI_VERSION" version --prefer-online 2>/dev/null || true)"
|
||||
@@ -95,7 +151,7 @@ jobs:
|
||||
sleep "$retry_delay"
|
||||
done
|
||||
|
||||
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after 10 minutes." >&2
|
||||
echo "eas-cli@$EAS_CLI_VERSION was not available on npm after ${availability_timeout_seconds}s." >&2
|
||||
exit 1
|
||||
- name: Bump STAGING and PRODUCTION
|
||||
run: |
|
||||
@@ -109,11 +165,20 @@ jobs:
|
||||
- name: Commit and push version update
|
||||
run: |
|
||||
git add cli-versions.json
|
||||
git commit --allow-empty -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
|
||||
git push
|
||||
if git diff --cached --quiet; then
|
||||
echo "cli-versions.json already points at $EAS_CLI_VERSION."
|
||||
else
|
||||
git commit -m "bump STAGING and PRODUCTION to $EAS_CLI_VERSION in cli-versions.json"
|
||||
git pull --rebase --autostash origin main
|
||||
git push
|
||||
fi
|
||||
- name: Prepare changelog for Slack
|
||||
run: |
|
||||
tail -n +3 /tmp/current-changelog > /tmp/changelog.md
|
||||
awk -v heading="## [$EAS_CLI_VERSION](" '
|
||||
index($0, heading) == 1 { found = 1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md | sed '/./,$!d' > /tmp/changelog.md
|
||||
npx slackmd /tmp/changelog.md
|
||||
{
|
||||
echo 'SLACK_CHANGELOG<<EOF'
|
||||
|
||||
@@ -8,6 +8,55 @@ This is the log of notable changes to EAS CLI and related packages.
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Stop spinners from printing their text over and over after the terminal is resized. ([#4473](https://github.com/expo/eas-cli/pull/4473) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
## [24.8.0](https://github.com/expo/eas-cli/releases/tag/v24.8.0) - 2026-09-24
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Add `--build-fingerprint` to `eas simulator` to install the newest build with a given fingerprint. ([#4472](https://github.com/expo/eas-cli/pull/4472) by [@tchayen](https://github.com/tchayen))
|
||||
- [eas-cli] Accept build IDs in `workflow:ssh <id>`. ([#4452](https://github.com/expo/eas-cli/pull/4452) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
- [eas-cli] Make `eas simulator:*` commands visible in help output. ([#4456](https://github.com/expo/eas-cli/pull/4456) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [build-tools] Pass `--share-url` when launching serve-sim so Share copies the expo.dev preview page instead of the tunnel URL. ([#4434](https://github.com/expo/eas-cli/pull/4434) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Add `eas integrations:supabase:advisors` to list unresolved Supabase Security and Performance Advisor findings for the linked Supabase project. ([#4342](https://github.com/expo/eas-cli/pull/4342) by [@fiberjw](https://github.com/fiberjw))
|
||||
- [eas-cli] Accept individual App Store Connect API keys for submissions, TestFlight setup, and metadata. ([#4249](https://github.com/expo/eas-cli/pull/4249) by [@sswrk](https://github.com/sswrk))
|
||||
- [build-tools] Add `launch_app_identifier`, `launch_args`, and `open_url` inputs to the serve-sim, agent-device, argent, and Appium remote sessions, so serve-sim launches an installed app before the stream starts. The inputs are iOS only, and Android sessions keep using `eas/launch_application`. ([#4324](https://github.com/expo/eas-cli/pull/4324) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Stream iOS Simulator previews at 1600px and 10 Mbps. ([#4439](https://github.com/expo/eas-cli/pull/4439) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Exclude time waiting for simulator concurrency from the session startup timeout and show queue progress. ([#4411](https://github.com/expo/eas-cli/pull/4411) by [@szdziedzic](https://github.com/szdziedzic))
|
||||
- [eas-cli] List `eas simulator` only once in `eas --help`. The `sim` shorthand is now a hidden alias, so it still runs but no longer duplicates the entry. ([#4457](https://github.com/expo/eas-cli/pull/4457) by [@krystofwoldrich](https://github.com/krystofwoldrich))
|
||||
- [build-tools] Start the Argent tool-server with `ARGENT_EMULATOR_NO_WINDOW=1`, so Argent can boot the Android emulator again after it stops. Without it, the windowed emulator fails on the Linux image because `libpulse.so.0` is missing. ([#4465](https://github.com/expo/eas-cli/pull/4465) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] Fix `eas account:view` printing `Role: undefined` for the new Release Manager role. ([#4300](https://github.com/expo/eas-cli/pull/4300) by [@byronkarlen](https://github.com/byronkarlen))
|
||||
|
||||
### 🧹 Chores
|
||||
|
||||
- [eas-cli] Stop creating `CLAUDE.md` in new apps; use `AGENTS.md` for agent instructions. ([#4446](https://github.com/expo/eas-cli/pull/4446) by [@davidmokos](https://github.com/davidmokos))
|
||||
- Upgrade `typescript` to 6.0.3, `ts-jest` to 29.4.12, `@types/node` to 20.19.43, `gql.tada` to 1.11.3, and `https-proxy-agent` to 7.0.6. ([#XXXX](https://github.com/expo/eas-cli/pull/XXXX) by [@wschurman](https://github.com/wschurman))
|
||||
- [build-tools] Use the website origin for simulator preview CORS and framing, and allow deploy previews and local website dev servers on staging. ([#4437](https://github.com/expo/eas-cli/pull/4437) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [24.7.0](https://github.com/expo/eas-cli/releases/tag/v24.7.0) - 2026-09-16
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [build-tools] Stream iOS Simulator previews with H.264. ([#4416](https://github.com/expo/eas-cli/pull/4416) by [@gwdp](https://github.com/gwdp))
|
||||
- [build-tools] Report the expo.dev preview page as a simulator session's preview URL, and the preview server behind it as `previewApiUrl`. ([#4353](https://github.com/expo/eas-cli/pull/4353) by [@gwdp](https://github.com/gwdp))
|
||||
- [eas-cli] `eas simulator:start` and `eas simulator:get` now report the expo.dev preview page instead of the raw tunnel URL, in both the printed instructions and `--json`. ([#4352](https://github.com/expo/eas-cli/pull/4352) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
- [eas-cli] Skip the managed-workflow Expo SDK and Metro config checks for builds that use a custom build config, since those builds run their own steps instead of the standard managed pipeline. ([#4424](https://github.com/expo/eas-cli/pull/4424) by [@douglowder](https://github.com/douglowder))
|
||||
- [build-tools] Honor `EAS_OVERRIDE_PACKAGE_MANAGER` and `EAS_FALLBACK_PACKAGE_MANAGER` when installing and launching simulator packages (serve-sim, device-hub, Argent, agent-device, Appium) so they can use bun instead of stalling on npm audit. ([#4343](https://github.com/expo/eas-cli/pull/4343) by [@gwdp](https://github.com/gwdp))
|
||||
|
||||
## [24.6.0](https://github.com/expo/eas-cli/releases/tag/v24.6.0) - 2026-09-15
|
||||
|
||||
### 🎉 New features
|
||||
|
||||
- [eas-cli] Display logs in `eas build --wait`. ([#4303](https://github.com/expo/eas-cli/pull/4303) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
1. Invoke the GitHub workflow ["Manually trigger a release"](https://github.com/expo/eas-cli/actions/workflows/trigger-release.yml). The next version is chosen automatically based on the changelog entries. If you want to use different version, pass the version string as an input to the workflow, in semver format, e.g. "1.2.3"
|
||||
2. That's it! GitHub Actions is going to take care of the rest. Watch the #eas-cli Slack channel for a successful release notification.
|
||||
|
||||
The release also sets the `STAGING` version in `cli-versions.json`. It does not change the `PRODUCTION` version. See [EAS Build CLI versions](#eas-build-cli-versions).
|
||||
The release also sets the `STAGING` and `PRODUCTION` versions in `cli-versions.json`. See [EAS Build CLI versions](#eas-build-cli-versions).
|
||||
|
||||
## EAS Build CLI versions
|
||||
|
||||
@@ -14,7 +14,7 @@ The release also sets the `STAGING` version in `cli-versions.json`. It does not
|
||||
|
||||
This file replaces the old `latest-eas-build` and `latest-eas-build-staging` npm dist-tags. The build servers read the file from the `main` branch.
|
||||
|
||||
A release updates `STAGING` to the new version automatically. To promote a version to production, invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
|
||||
A release updates `STAGING` and `PRODUCTION` to the new version automatically. To set a different version to production (to fix a regression, for example), invoke the GitHub workflow ["Promote eas-cli to production"](https://github.com/expo/eas-cli/actions/workflows/promote-eas-cli-production.yml). Pass the version to promote as an input. If you leave the input empty, the workflow uses the current `STAGING` version. The workflow checks that the version is published on npm before it updates `PRODUCTION`.
|
||||
|
||||
## Choosing the next version
|
||||
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "24.5.0",
|
||||
"PRODUCTION": "24.5.0"
|
||||
"STAGING": "24.8.0",
|
||||
"PRODUCTION": "24.8.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "24.6.0",
|
||||
"version": "24.8.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
+3
-3
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "29.5.11",
|
||||
"@types/node": "20.14.8",
|
||||
"@types/node": "20.19.43",
|
||||
"eslint-plugin-async-protect": "3.1.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-watch-typeahead": "2.2.2",
|
||||
@@ -32,8 +32,8 @@
|
||||
"oxlint": "1.43.0",
|
||||
"oxlint-tsgolint": "0.11.1",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "29.1.1",
|
||||
"typescript": "5.5.4"
|
||||
"ts-jest": "29.4.12",
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"packageManager": "[email protected]"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/build-tools",
|
||||
"version": "24.5.0",
|
||||
"version": "24.8.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -41,17 +41,17 @@
|
||||
"dependencies": {
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/downloader": "24.0.0",
|
||||
"@expo/eas-build-job": "24.5.0",
|
||||
"@expo/downloader": "24.8.0",
|
||||
"@expo/eas-build-job": "24.8.0",
|
||||
"@expo/env": "^0.4.0",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/logger": "24.8.0",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@expo/plist": "^0.3.5",
|
||||
"@expo/results": "^1.0.0",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "24.5.0",
|
||||
"@expo/template-file": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/steps": "24.8.0",
|
||||
"@expo/template-file": "24.8.0",
|
||||
"@expo/turtle-spawn": "24.8.0",
|
||||
"@expo/xcpretty": "^4.3.1",
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
"@sentry/node": "7.77.0",
|
||||
@@ -61,7 +61,7 @@
|
||||
"fast-glob": "^3.3.2",
|
||||
"fast-xml-parser": "^4.4.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"gql.tada": "^1.8.13",
|
||||
"gql.tada": "^1.11.3",
|
||||
"joi": "^17.13.1",
|
||||
"jose": "5.10.0",
|
||||
"lodash": "^4.18.1",
|
||||
@@ -88,7 +88,7 @@
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/lodash": "^4.17.4",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node-fetch": "^2.6.11",
|
||||
"@types/node-forge": "^1.3.11",
|
||||
"@types/plist": "^3.0.5",
|
||||
@@ -101,10 +101,10 @@
|
||||
"memfs": "^4.17.1",
|
||||
"nock": "13.4.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-jest": "^29.4.12",
|
||||
"ts-mockito": "^2.6.1",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript": "^6.0.3",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
}
|
||||
+17
-24
@@ -1,11 +1,9 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { BuildContext } from '../../../context';
|
||||
import { distributionCertificate } from '../__tests__/fixtures';
|
||||
import Keychain from '../keychain';
|
||||
|
||||
@@ -17,11 +15,9 @@ jest.setTimeout(60 * 1000);
|
||||
// We need the JS code to modify the file system, so we need to mock it.
|
||||
jest.unmock('fs');
|
||||
|
||||
let ctx: BuildContext<Ios.Job>;
|
||||
|
||||
describe('Keychain class', () => {
|
||||
describe('ensureCertificateImported method', () => {
|
||||
let keychain: Keychain<Ios.Job>;
|
||||
let keychain: Keychain;
|
||||
const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`);
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -36,37 +32,34 @@ describe('Keychain class', () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
|
||||
workingdir: '/workingdir',
|
||||
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
|
||||
logger: mockLogger,
|
||||
env: {},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
keychain = new Keychain(ctx);
|
||||
await keychain.create();
|
||||
keychain = new Keychain();
|
||||
await keychain.create({ logger: mockLogger });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await keychain.destroy();
|
||||
await keychain.destroy({ logger: mockLogger });
|
||||
});
|
||||
|
||||
it("should throw an error if the certificate hasn't been imported", async () => {
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
distributionCertificate.fingerprint
|
||||
)
|
||||
keychain.ensureCertificateImported({
|
||||
teamId: distributionCertificate.teamId,
|
||||
fingerprint: distributionCertificate.fingerprint,
|
||||
})
|
||||
).rejects.toThrowError(/hasn't been imported successfully/);
|
||||
});
|
||||
|
||||
it("shouldn't throw any error if the certificate has been imported successfully", async () => {
|
||||
await keychain.importCertificate(certificatePath, distributionCertificate.password);
|
||||
await keychain.importCertificate({
|
||||
logger: mockLogger,
|
||||
certPath: certificatePath,
|
||||
certPassword: distributionCertificate.password,
|
||||
});
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
distributionCertificate.fingerprint
|
||||
)
|
||||
keychain.ensureCertificateImported({
|
||||
teamId: distributionCertificate.teamId,
|
||||
fingerprint: distributionCertificate.fingerprint,
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import { SpawnResult } from '@expo/turtle-spawn';
|
||||
|
||||
import { runFastlane } from '../../fastlane';
|
||||
import Keychain from '../keychain';
|
||||
|
||||
jest.mock('../../fastlane');
|
||||
const runFastlaneMock = jest.mocked(runFastlane);
|
||||
const logger = createLogger({ name: 'test' });
|
||||
const logError = jest.spyOn(logger, 'error').mockImplementation();
|
||||
let keychain: Keychain;
|
||||
const options = {
|
||||
logger,
|
||||
certPath: '/test.p12',
|
||||
certPassword: 'certificate-password',
|
||||
};
|
||||
const result = (stdout: string, stderr = ''): SpawnResult => ({
|
||||
stdout,
|
||||
stderr,
|
||||
output: [stdout, stderr],
|
||||
pid: 1,
|
||||
status: 0,
|
||||
signal: null,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
keychain = new Keychain();
|
||||
runFastlaneMock.mockResolvedValueOnce(result(''));
|
||||
await keychain.create({ logger });
|
||||
runFastlaneMock.mockClear();
|
||||
});
|
||||
|
||||
it('runs the import with the certificate and keychain credentials, without streaming output', async () => {
|
||||
runFastlaneMock.mockResolvedValueOnce(result('Result: true'));
|
||||
await keychain.importCertificate(options);
|
||||
expect(runFastlaneMock).toHaveBeenCalledWith([
|
||||
'run',
|
||||
'import_certificate',
|
||||
'certificate_path:/test.p12',
|
||||
'certificate_password:certificate-password',
|
||||
`keychain_path:${keychain.data.path}`,
|
||||
`keychain_password:${keychain.data.password}`,
|
||||
]);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe.each(['success', 'failure'])('Fastlane %s', outcome => {
|
||||
it.each([
|
||||
{
|
||||
output: 'SecKeychainItemImport: MAC verification failed during PKCS12 import',
|
||||
codes: ['PKCS12_MAC_VERIFICATION_FAILED'],
|
||||
},
|
||||
{
|
||||
output: 'SecKeychainItemImport: Unknown format in import',
|
||||
codes: ['PKCS12_UNKNOWN_FORMAT'],
|
||||
},
|
||||
{
|
||||
output: 'SecKeychainItemImport: Some other error',
|
||||
codes: ['CERTIFICATE_IMPORT_FAILED'],
|
||||
},
|
||||
{
|
||||
output:
|
||||
'SecKeychainItemImport: Unknown format in import\nSecKeychainItemSetAccessWithPassword: Access denied',
|
||||
codes: ['PKCS12_UNKNOWN_FORMAT', 'PRIVATE_KEY_ACCESS_FAILED'],
|
||||
},
|
||||
{
|
||||
output:
|
||||
'seckeychainitemimport: unknown format in import\nSecKeychainItemImport: Some other error',
|
||||
codes: ['PKCS12_UNKNOWN_FORMAT', 'CERTIFICATE_IMPORT_FAILED'],
|
||||
},
|
||||
])('reports only the applicable codes: $codes', async ({ output, codes }) => {
|
||||
if (outcome === 'success') {
|
||||
runFastlaneMock.mockResolvedValueOnce(result(output));
|
||||
await keychain.importCertificate(options);
|
||||
} else {
|
||||
runFastlaneMock.mockRejectedValueOnce(
|
||||
Object.assign(new Error('import failed'), {
|
||||
stderr: output,
|
||||
})
|
||||
);
|
||||
const failure = await keychain.importCertificate(options).catch(error => error);
|
||||
expect(failure.metadata.diagnosticCodes).toEqual(codes);
|
||||
}
|
||||
expect(logError.mock.calls.map(([fields]) => fields.diagnosticCode)).toEqual(codes);
|
||||
});
|
||||
});
|
||||
|
||||
it('never forwards output, command arguments, private key data, or the original error', async () => {
|
||||
const secret = 'very-secret-password';
|
||||
const error = Object.assign(new Error(`fastlane certificate_password:${secret}`), {
|
||||
stdout: `private key data ${secret}`,
|
||||
stderr: `security: SecKeychainItemSetAccessWithPassword: ${secret}`,
|
||||
});
|
||||
runFastlaneMock.mockRejectedValueOnce(error);
|
||||
const failure = await keychain.importCertificate(options).catch(error => error);
|
||||
expect(failure).toBeInstanceOf(errors.UserError);
|
||||
expect(failure.errorCode).toBe(errors.ErrorCode.UNKNOWN_ERROR);
|
||||
expect(failure.metadata).toEqual({ diagnosticCodes: ['PRIVATE_KEY_ACCESS_FAILED'] });
|
||||
expect(failure.toExternalExpoError().message).toContain(
|
||||
'Fastlane could not complete certificate import'
|
||||
);
|
||||
expect(failure.cause).toBeUndefined();
|
||||
expect(JSON.stringify([failure, logError.mock.calls])).not.toContain(secret);
|
||||
expect(logError).toHaveBeenCalledWith(
|
||||
{ diagnosticCode: 'PRIVATE_KEY_ACCESS_FAILED' },
|
||||
expect.stringContaining('private key')
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['ENOENT', 'EACCES'])(
|
||||
'classifies a %s process launch failure as a system error',
|
||||
async code => {
|
||||
runFastlaneMock.mockRejectedValueOnce(Object.assign(new Error('secret command'), { code }));
|
||||
const error = await keychain.importCertificate(options).catch(error => error);
|
||||
expect(error).toBeInstanceOf(errors.SystemError);
|
||||
expect(error.trackingCode).toBe('IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED');
|
||||
expect(error.cause).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
null,
|
||||
new Error('failure'),
|
||||
{ stderr: 'SecKeychainItemImport: Unknown format in import' },
|
||||
Object.assign(new Error('failure'), { stdout: 123, stderr: {} }),
|
||||
])('handles rejected values without usable process output: %p', async value => {
|
||||
runFastlaneMock.mockRejectedValueOnce(value);
|
||||
await expect(keychain.importCertificate(options)).rejects.toThrow(
|
||||
'Fastlane could not complete certificate import'
|
||||
);
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -18,7 +18,7 @@ jest.unmock('fs');
|
||||
describe('ProvisioningProfile class', () => {
|
||||
describe('verifyCertificate method', () => {
|
||||
let ctx: BuildContext<Ios.Job>;
|
||||
let keychain: Keychain<Ios.Job>;
|
||||
let keychain: Keychain;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = new BuildContext({ projectRootDirectory: '.' } as Ios.Job, {
|
||||
@@ -30,12 +30,12 @@ describe('ProvisioningProfile class', () => {
|
||||
},
|
||||
uploadArtifact: jest.fn(),
|
||||
});
|
||||
keychain = new Keychain(ctx);
|
||||
await keychain.create();
|
||||
keychain = new Keychain();
|
||||
await keychain.create({ logger: mockLogger });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await keychain.destroy();
|
||||
await keychain.destroy({ logger: mockLogger });
|
||||
});
|
||||
|
||||
it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => {
|
||||
|
||||
@@ -1,19 +1,53 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import { errors } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn, { SpawnResult } from '@expo/turtle-spawn';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { BuildContext } from '../../context';
|
||||
import { runFastlane } from '../fastlane';
|
||||
|
||||
export default class Keychain<TJob extends Ios.Job> {
|
||||
// Do not log raw fastlane output or spawn errors: they can contain passwords,
|
||||
// command arguments, and private key attributes from set-key-partition-list.
|
||||
const IMPORT_CERTIFICATE_DIAGNOSTICS = [
|
||||
{
|
||||
code: 'PKCS12_MAC_VERIFICATION_FAILED',
|
||||
pattern: /SecKeychainItemImport: MAC verification failed during PKCS12 import/i,
|
||||
message:
|
||||
'macOS could not verify the PKCS#12 MAC. Check the certificate password and PKCS#12 export format; this error does not prove that the password is wrong.',
|
||||
},
|
||||
{
|
||||
code: 'PKCS12_UNKNOWN_FORMAT',
|
||||
pattern: /SecKeychainItemImport: Unknown format in import/i,
|
||||
message: 'macOS did not recognize the certificate import format.',
|
||||
},
|
||||
{
|
||||
code: 'CERTIFICATE_IMPORT_FAILED',
|
||||
pattern:
|
||||
/SecKeychainItemImport:(?! MAC verification failed during PKCS12 import| Unknown format in import)/i,
|
||||
message: 'macOS reported a certificate import error (SecKeychainItemImport).',
|
||||
},
|
||||
{
|
||||
code: 'PRIVATE_KEY_ACCESS_FAILED',
|
||||
pattern: /SecKeychainItemSetAccessWithPassword:/,
|
||||
message:
|
||||
'macOS could not set access to the imported private key (SecKeychainItemSetAccessWithPassword).',
|
||||
},
|
||||
{
|
||||
code: 'KEYCHAIN_ITEM_LOOKUP_FAILED',
|
||||
pattern: /SecItemCopyMatching:/,
|
||||
message:
|
||||
'macOS could not find an item while configuring private key access (SecItemCopyMatching).',
|
||||
},
|
||||
];
|
||||
|
||||
export default class Keychain {
|
||||
private readonly keychainPath: string;
|
||||
private readonly keychainPassword: string;
|
||||
private created = false;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(private readonly ctx: BuildContext<TJob>) {
|
||||
constructor() {
|
||||
this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`);
|
||||
this.keychainPassword = uuid();
|
||||
}
|
||||
@@ -25,8 +59,8 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
};
|
||||
}
|
||||
|
||||
public async create(): Promise<void> {
|
||||
this.ctx.logger.debug(`Creating keychain - ${this.keychainPath}`);
|
||||
public async create({ logger }: { logger: bunyan }): Promise<void> {
|
||||
logger.debug(`Creating keychain - ${this.keychainPath}`);
|
||||
await runFastlane([
|
||||
'run',
|
||||
'create_keychain',
|
||||
@@ -38,23 +72,77 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
this.created = true;
|
||||
}
|
||||
|
||||
public async importCertificate(certPath: string, certPassword: string): Promise<void> {
|
||||
public async importCertificate({
|
||||
logger,
|
||||
certPath,
|
||||
certPassword,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
certPath: string;
|
||||
certPassword: string;
|
||||
}): Promise<void> {
|
||||
if (!this.created) {
|
||||
throw new Error('You must create a keychain first.');
|
||||
}
|
||||
|
||||
this.ctx.logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
|
||||
await runFastlane([
|
||||
'run',
|
||||
'import_certificate',
|
||||
`certificate_path:${certPath}`,
|
||||
`certificate_password:${certPassword}`,
|
||||
`keychain_path:${this.keychainPath}`,
|
||||
`keychain_password:${this.keychainPassword}`,
|
||||
]);
|
||||
logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
|
||||
try {
|
||||
const result = await runFastlane([
|
||||
'run',
|
||||
'import_certificate',
|
||||
`certificate_path:${certPath}`,
|
||||
`certificate_password:${certPassword}`,
|
||||
`keychain_path:${this.keychainPath}`,
|
||||
`keychain_password:${this.keychainPassword}`,
|
||||
]);
|
||||
const output = [result.stdout, result.stderr].join('\n');
|
||||
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
|
||||
if (diagnostic.pattern.test(output)) {
|
||||
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const processError =
|
||||
error instanceof Error
|
||||
? (error as Error &
|
||||
Partial<Pick<SpawnResult, 'stdout' | 'stderr'> & NodeJS.ErrnoException>)
|
||||
: undefined;
|
||||
const output = [processError?.stdout, processError?.stderr]
|
||||
.filter(value => typeof value === 'string')
|
||||
.join('\n');
|
||||
const diagnosticCodes: string[] = [];
|
||||
for (const diagnostic of IMPORT_CERTIFICATE_DIAGNOSTICS) {
|
||||
if (diagnostic.pattern.test(output)) {
|
||||
diagnosticCodes.push(diagnostic.code);
|
||||
logger.error({ diagnosticCode: diagnostic.code }, diagnostic.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Never attach the original error: its message includes passwords.
|
||||
|
||||
if (processError?.code === 'ENOENT' || processError?.code === 'EACCES') {
|
||||
throw new errors.SystemError('Fastlane could not be started to import the certificate.', {
|
||||
trackingCode: 'IOS_CERTIFICATE_IMPORT_PROCESS_START_FAILED',
|
||||
});
|
||||
}
|
||||
throw new errors.UserError(
|
||||
errors.ErrorCode.UNKNOWN_ERROR,
|
||||
'Fastlane could not complete certificate import. Check the certificate import diagnostics in the Prepare credentials logs.',
|
||||
{
|
||||
trackingCode: 'IOS_CERTIFICATE_IMPORT_FAILED',
|
||||
metadata: { diagnosticCodes },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async ensureCertificateImported(teamId: string, fingerprint: string): Promise<void> {
|
||||
public async ensureCertificateImported({
|
||||
teamId,
|
||||
fingerprint,
|
||||
}: {
|
||||
teamId: string;
|
||||
fingerprint: string;
|
||||
}): Promise<void> {
|
||||
const identities = await this.findIdentitiesByTeamId(teamId);
|
||||
if (!identities.includes(fingerprint)) {
|
||||
throw new Error(
|
||||
@@ -63,27 +151,33 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(keychainPath?: string): Promise<void> {
|
||||
public async destroy({
|
||||
logger,
|
||||
keychainPath,
|
||||
}: {
|
||||
logger: bunyan;
|
||||
keychainPath?: string;
|
||||
}): Promise<void> {
|
||||
if (!keychainPath && !this.created) {
|
||||
this.ctx.logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
|
||||
logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
|
||||
return;
|
||||
}
|
||||
if (this.destroyed) {
|
||||
this.ctx.logger.warn('The keychain has been already destroyed');
|
||||
logger.warn('The keychain has been already destroyed');
|
||||
return;
|
||||
}
|
||||
const keychainToDeletePath = keychainPath ?? this.keychainPath;
|
||||
this.ctx.logger.info(`Destroying keychain - ${keychainToDeletePath}`);
|
||||
logger.info(`Destroying keychain - ${keychainToDeletePath}`);
|
||||
try {
|
||||
await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]);
|
||||
this.destroyed = true;
|
||||
} catch (err) {
|
||||
this.ctx.logger.error({ err }, 'Failed to delete the keychain\n');
|
||||
logger.error({ err }, 'Failed to delete the keychain\n');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanUpKeychains(): Promise<void> {
|
||||
public async cleanUpKeychains({ logger }: { logger: bunyan }): Promise<void> {
|
||||
const { stdout } = await spawn('security', ['list-keychains'], { stdio: 'pipe' });
|
||||
const keychainList = (/"(.*)"/g.exec(stdout) ?? ([] as string[])).map(i =>
|
||||
i.slice(1, i.length - 1)
|
||||
@@ -92,7 +186,7 @@ export default class Keychain<TJob extends Ios.Job> {
|
||||
/eas-build-[\w-]+\.keychain$/.exec(keychain)
|
||||
);
|
||||
for (const turtleKeychainPath of turtleKeychainList) {
|
||||
await this.destroy(turtleKeychainPath);
|
||||
await this.destroy({ logger, keychainPath: turtleKeychainPath });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface Credentials {
|
||||
export type TargetProvisioningProfiles = Record<string, ProvisioningProfileData>;
|
||||
|
||||
export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
private keychain?: Keychain<TJob>;
|
||||
private keychain?: Keychain;
|
||||
private readonly provisioningProfiles: ProvisioningProfile<TJob>[] = [];
|
||||
private cleanedUp = false;
|
||||
|
||||
@@ -48,8 +48,8 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
this.ctx.logger.info('Preparing credentials');
|
||||
|
||||
this.ctx.logger.info('Creating keychain');
|
||||
this.keychain = new Keychain(this.ctx);
|
||||
await this.keychain.create();
|
||||
this.keychain = new Keychain();
|
||||
await this.keychain.create({ logger: this.ctx.logger });
|
||||
|
||||
const targets = Object.keys(buildCredentials);
|
||||
const targetProvisioningProfiles: TargetProvisioningProfiles = {};
|
||||
@@ -82,7 +82,7 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
}
|
||||
|
||||
if (this.keychain) {
|
||||
await this.keychain.destroy();
|
||||
await this.keychain.destroy({ logger: this.ctx.logger });
|
||||
}
|
||||
if (this.provisioningProfiles) {
|
||||
for (const provisioningProfile of this.provisioningProfiles) {
|
||||
@@ -120,10 +120,11 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
);
|
||||
|
||||
this.ctx.logger.info('Importing distribution certificate into the keychain');
|
||||
await this.keychain.importCertificate(
|
||||
distCertPath,
|
||||
targetCredentials.distributionCertificate.password
|
||||
);
|
||||
await this.keychain.importCertificate({
|
||||
logger: this.ctx.logger,
|
||||
certPath: distCertPath,
|
||||
certPassword: targetCredentials.distributionCertificate.password,
|
||||
});
|
||||
|
||||
this.ctx.logger.info('Initializing provisioning profile');
|
||||
const provisioningProfile = new ProvisioningProfile(
|
||||
@@ -138,10 +139,10 @@ export default class IosCredentialsManager<TJob extends Ios.Job> {
|
||||
this.ctx.logger.info(
|
||||
'Validating whether the distribution certificate has been imported successfully'
|
||||
);
|
||||
await this.keychain.ensureCertificateImported(
|
||||
provisioningProfile.data.teamId,
|
||||
certificateFingerprint
|
||||
);
|
||||
await this.keychain.ensureCertificateImported({
|
||||
teamId: provisioningProfile.data.teamId,
|
||||
fingerprint: certificateFingerprint,
|
||||
});
|
||||
|
||||
this.ctx.logger.info(
|
||||
'Verifying whether the distribution certificate and provisioning profile match'
|
||||
|
||||
@@ -256,7 +256,9 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects sharding with maestro-runner', async () => {
|
||||
it('passes shards to maestro-runner as parallel devices', async () => {
|
||||
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
|
||||
jest.spyOn(fs, 'copyFile').mockResolvedValue();
|
||||
const step = createStep({
|
||||
flow_path: ['flows/a.yaml'],
|
||||
platform: 'android',
|
||||
@@ -264,10 +266,9 @@ describe('createMaestroTestsBuildFunction', () => {
|
||||
shards: 2,
|
||||
});
|
||||
|
||||
await expect(step.executeAsync()).rejects.toThrow(
|
||||
'maestro-runner does not support EAS Maestro test sharding'
|
||||
);
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
await step.executeAsync();
|
||||
|
||||
expect(mockedSpawn.mock.calls[0][1]).toContain('--parallel=2');
|
||||
});
|
||||
|
||||
it('rejects a non-junit output_format with maestro-runner', async () => {
|
||||
|
||||
@@ -45,6 +45,7 @@ jest.mock('../../utils/localEgress', () => ({
|
||||
monitorLocalEgressAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: () => 'session-id',
|
||||
getNgrokTunnelDomainOrThrow: () => 'example.test',
|
||||
getNgrokAuthtokenOrThrow: () => 'ngrok-token',
|
||||
@@ -52,7 +53,11 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
spawnDetached: () => ({ pid: 123, getOutput: () => '', stopAsync: jest.fn() }),
|
||||
waitForFileAsync: async () => ({ port: 1234, token: 'controller-token' }),
|
||||
startNgrokTunnelAsync: async () => ({ url: 'https://controller.test', stopAsync: jest.fn() }),
|
||||
startNgrokTunnelAsync: async () => ({
|
||||
url: 'https://controller.test',
|
||||
subdomainId: 'controller-id',
|
||||
stopAsync: jest.fn(),
|
||||
}),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
@@ -115,7 +120,8 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
|
||||
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(handoff);
|
||||
jest.mocked(monitorLocalEgressAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://preview.test',
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://preview.test',
|
||||
previewToken: 'preview-secret',
|
||||
stopAsync: stopPreview,
|
||||
});
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
uploadRemoteSessionConfigAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForFileAsync,
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartAgentDeviceRemoteSessionBuildFunction } from '../startAgentDeviceRemoteSession';
|
||||
|
||||
// The daemon entry path and the state directory are resolved from the home directory when
|
||||
// the module loads, so point it at a temp home we can populate.
|
||||
jest.mock('node:os', () => {
|
||||
const actual = jest.requireActual('node:os');
|
||||
const actualPath = jest.requireActual('node:path');
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => actualPath.join(actual.tmpdir(), 'eas-agent-device-orchestration-home'),
|
||||
};
|
||||
});
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('../../utils/agentDeviceArtifacts', () => ({
|
||||
pollAgentDeviceArtifactsForUploadAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/agentDeviceEvents', () => ({
|
||||
startAgentDeviceEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
spawnDetached: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
startNgrokTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
waitForFileAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const TEST_HOME = path.join(os.tmpdir(), 'eas-agent-device-orchestration-home');
|
||||
const DAEMON_ENTRY_PATH = path.join(
|
||||
TEST_HOME,
|
||||
'.bun/install/global/node_modules/agent-device/dist/src/internal/daemon.js'
|
||||
);
|
||||
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const mockPreviewStopAsync = jest.fn();
|
||||
const mockTunnelStopAsync = jest.fn();
|
||||
const mockDaemonStopAsync = jest.fn();
|
||||
const mockEventCollectionStopAsync = jest.fn();
|
||||
|
||||
async function runAsync(
|
||||
logger: { info: jest.Mock; warn: jest.Mock },
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
);
|
||||
}
|
||||
|
||||
describe('createStartAgentDeviceRemoteSessionBuildFunction orchestration', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
jest.mocked(spawn).mockResolvedValue(undefined as never);
|
||||
jest.mocked(pollAgentDeviceArtifactsForUploadAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue({
|
||||
stopAsync: mockEventCollectionStopAsync,
|
||||
getLastEventObservedAt: () => undefined,
|
||||
});
|
||||
jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(getNgrokAuthtokenOrThrow).mockReturnValue('ngrok-token');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 4242,
|
||||
getOutput: () => '',
|
||||
stopAsync: mockDaemonStopAsync,
|
||||
});
|
||||
jest.mocked(waitForFileAsync).mockResolvedValue({ port: 5678, token: 'daemon-token' });
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://agent-device-abc.tunnel.example.com',
|
||||
subdomainId: 'agent-device-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
|
||||
|
||||
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
|
||||
await fs.promises.writeFile(DAEMON_ENTRY_PATH, '');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(TEST_HOME, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports the preview URL and tears every resource down', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.LINUX);
|
||||
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({ runtimePlatform: BuildRuntimePlatform.LINUX })
|
||||
);
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
agentDeviceRemoteSessionUrl: 'https://agent-device-abc.tunnel.example.com',
|
||||
agentDeviceRemoteSessionToken: 'daemon-token',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.tunnel.example.com',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockEventCollectionStopAsync).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await runAsync(logger, BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
});
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting the daemon when a launch is asked for on Android', async () => {
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
|
||||
await expect(
|
||||
runAsync(logger, BuildRuntimePlatform.LINUX, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
})
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(spawnDetached).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const buildFunction = createStartAgentDeviceRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
});
|
||||
+188
-2
@@ -1,9 +1,38 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { stopAgentDeviceEventCollectionSafelyAsync } from '../startAgentDeviceRemoteSession';
|
||||
import { spawnDetached } from '../../utils/remoteDeviceRunSession';
|
||||
import {
|
||||
startAgentDeviceDaemonAsync,
|
||||
stopAgentDeviceEventCollectionSafelyAsync,
|
||||
} from '../startAgentDeviceRemoteSession';
|
||||
|
||||
jest.mock('../../../sentry');
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
spawnDetached: jest.fn(),
|
||||
}));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
async function writeDaemonEntry(cwd: string): Promise<string> {
|
||||
const daemonPath = path.join(
|
||||
cwd,
|
||||
'node_modules',
|
||||
'agent-device',
|
||||
'dist',
|
||||
'src',
|
||||
'internal',
|
||||
'daemon.js'
|
||||
);
|
||||
await fs.promises.mkdir(path.dirname(daemonPath), { recursive: true });
|
||||
await fs.promises.writeFile(daemonPath, '');
|
||||
return daemonPath;
|
||||
}
|
||||
|
||||
describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
|
||||
beforeEach(() => {
|
||||
@@ -12,7 +41,6 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
|
||||
|
||||
it('reports an unexpected stop failure without rejecting', async () => {
|
||||
const error = new Error('stop failed');
|
||||
const logger = { warn: jest.fn() } as unknown as bunyan;
|
||||
|
||||
await expect(
|
||||
stopAgentDeviceEventCollectionSafelyAsync({
|
||||
@@ -37,3 +65,161 @@ describe(stopAgentDeviceEventCollectionSafelyAsync, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(startAgentDeviceDaemonAsync, () => {
|
||||
const stopAsync = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
stopAsync.mockResolvedValue(undefined);
|
||||
jest.mocked(spawnDetached).mockReturnValue({
|
||||
pid: 1234,
|
||||
getOutput: () => '',
|
||||
stopAsync,
|
||||
});
|
||||
jest.mocked(spawn).mockImplementation((async (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: { cwd?: string }
|
||||
) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
return { stdout: '1.3.11\n' };
|
||||
}
|
||||
if (typeof options?.cwd === 'string' && command !== 'git') {
|
||||
await writeDaemonEntry(options.cwd);
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
});
|
||||
|
||||
it('installs with bun add by default and launches the published daemon', async () => {
|
||||
const handle = await startAgentDeviceDaemonAsync({
|
||||
packageVersion: '1.2.3',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['add', '[email protected]'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
|
||||
);
|
||||
const addCwd = jest.mocked(spawn).mock.calls[0][2]?.cwd as string;
|
||||
expect(spawnDetached).toHaveBeenCalledWith({
|
||||
command: 'node',
|
||||
args: [path.join(addCwd, 'node_modules/agent-device/dist/src/internal/daemon.js')],
|
||||
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
|
||||
});
|
||||
|
||||
await handle.stopAsync();
|
||||
await expect(fs.promises.access(addCwd)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('installs with npm when EAS_OVERRIDE_PACKAGE_MANAGER is npm', async () => {
|
||||
await startAgentDeviceDaemonAsync({
|
||||
packageVersion: undefined,
|
||||
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npm',
|
||||
['install', '--no-audit', 'agent-device@latest'],
|
||||
expect.objectContaining({ cwd: expect.stringContaining('eas-agent-device-') })
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to git clone when the published daemon is missing', async () => {
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
return { stdout: '1.3.11\n' };
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
|
||||
await startAgentDeviceDaemonAsync({
|
||||
packageVersion: '1.2.3',
|
||||
env: {},
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'git',
|
||||
[
|
||||
'clone',
|
||||
'--depth',
|
||||
'1',
|
||||
'--branch',
|
||||
'v1.2.3',
|
||||
'https://github.com/callstack/agent-device.git',
|
||||
'/tmp/agent-device-src',
|
||||
],
|
||||
expect.objectContaining({ env: {}, logger })
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'bun',
|
||||
['install', '--production'],
|
||||
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
|
||||
);
|
||||
expect(spawnDetached).toHaveBeenCalledWith({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
cwd: '/tmp/agent-device-src',
|
||||
env: expect.objectContaining({ AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' }),
|
||||
});
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: expect.objectContaining({
|
||||
packageSpec: '[email protected]',
|
||||
packageManager: 'bun',
|
||||
bunVersion: '1.3.11',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('clones latest and reports unknown bun when diagnostics fail', async () => {
|
||||
jest.mocked(spawn).mockImplementation((async (command: string, args: string[]) => {
|
||||
if (command === 'bun' && args[0] === '--version') {
|
||||
throw new Error('bun missing');
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
|
||||
await startAgentDeviceDaemonAsync({
|
||||
packageVersion: undefined,
|
||||
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'npm' },
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'git',
|
||||
[
|
||||
'clone',
|
||||
'--depth',
|
||||
'1',
|
||||
'https://github.com/callstack/agent-device.git',
|
||||
'/tmp/agent-device-src',
|
||||
],
|
||||
expect.objectContaining({ logger })
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'npm',
|
||||
['install', '--no-audit', '--omit=dev'],
|
||||
expect.objectContaining({ cwd: '/tmp/agent-device-src' })
|
||||
);
|
||||
expect(Sentry.capture).toHaveBeenCalledWith(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
expect.any(Error),
|
||||
expect.objectContaining({
|
||||
extras: expect.objectContaining({
|
||||
packageVersion: 'latest',
|
||||
packageManager: 'npm',
|
||||
bunVersion: 'unknown',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+84
-1
@@ -1,10 +1,19 @@
|
||||
import { BuildRuntimePlatform } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { AndroidEmulatorUtils } from '../../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../../utils/IosSimulatorUtils';
|
||||
import { selectXcodeDeveloperDirectoryAsync } from '../../utils/remoteDeviceRunSession';
|
||||
|
||||
import { resolveAppium3VersionSpec, resolveAppiumDeviceAsync } from '../startAppiumRemoteSession';
|
||||
import {
|
||||
createStartAppiumRemoteSessionBuildFunction,
|
||||
installAppiumAsync,
|
||||
resolveAppium3VersionSpec,
|
||||
resolveAppiumDeviceAsync,
|
||||
} from '../startAppiumRemoteSession';
|
||||
|
||||
jest.mock('../../../utils/AndroidEmulatorUtils', () => ({
|
||||
AndroidEmulatorUtils: { getAttachedDevicesAsync: jest.fn() },
|
||||
@@ -13,8 +22,10 @@ jest.mock('../../../utils/IosSimulatorUtils', () => ({
|
||||
IosSimulatorUtils: { getAvailableDevicesAsync: jest.fn() },
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as never;
|
||||
|
||||
@@ -84,3 +95,75 @@ describe(resolveAppiumDeviceAsync, () => {
|
||||
expect(selectXcodeDeveloperDirectoryAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(installAppiumAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.mocked(spawn).mockImplementation((async (_command: string, args: string[]) => {
|
||||
if (args.includes('--json')) {
|
||||
return { stdout: JSON.stringify({}) };
|
||||
}
|
||||
return { stdout: '' };
|
||||
}) as never);
|
||||
});
|
||||
|
||||
it('installs Appium with npm by default', async () => {
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '^3',
|
||||
driverName: 'xcuitest',
|
||||
env: { EXISTING: 'value' },
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'npm',
|
||||
['install', '--no-audit', 'appium@^3'],
|
||||
expect.objectContaining({
|
||||
cwd: result.appiumHome,
|
||||
env: expect.objectContaining({ APPIUM_HOME: result.appiumHome, EXISTING: 'value' }),
|
||||
})
|
||||
);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
result.appiumBinPath,
|
||||
['driver', 'install', 'xcuitest'],
|
||||
expect.objectContaining({ env: result.appiumEnv, logger })
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('installs Appium with bun add when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
|
||||
const result = await installAppiumAsync({
|
||||
versionSpec: '3.5.0',
|
||||
driverName: 'uiautomator2',
|
||||
env: { EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' },
|
||||
logger,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'bun',
|
||||
['add', '[email protected]'],
|
||||
expect.objectContaining({ cwd: result.appiumHome })
|
||||
);
|
||||
} finally {
|
||||
await fs.promises.rm(result.appiumHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStartAppiumRemoteSessionBuildFunction', () => {
|
||||
it('declares the launch inputs so serve-sim can launch the app', () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
});
|
||||
+83
-2
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import { isProcessDescendantOfAsync } from '../../../utils/processes';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../../utils/argentArtifacts';
|
||||
@@ -42,6 +43,7 @@ jest.mock('../../utils/argentEvents', () => ({
|
||||
startArgentEventCollectionAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
ensureFfmpegInstalledOnceAsync: jest.fn(),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokAuthtokenOrThrow: jest.fn(),
|
||||
@@ -88,10 +90,12 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
});
|
||||
jest.mocked(startNgrokTunnelAsync).mockResolvedValue({
|
||||
url: 'https://argent-abc.tunnel.example.com',
|
||||
subdomainId: 'argent-abc',
|
||||
stopAsync: mockTunnelStopAsync,
|
||||
});
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.tunnel.example.com',
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.tunnel.example.com',
|
||||
stopAsync: mockPreviewStopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
@@ -155,6 +159,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
// (3) The tool-server and the collector are pinned to the exact same event log path.
|
||||
const serverEnv = jest.mocked(spawnDetached).mock.calls[0][0].env;
|
||||
expect(serverEnv.ARGENT_EVENT_LOG).toBe(EXPECTED_EVENT_LOG_PATH);
|
||||
expect(serverEnv.ARGENT_EMULATOR_NO_WINDOW).toBe('1');
|
||||
expect(serverEnv.EXISTING).toBe('value');
|
||||
expect(jest.mocked(startArgentEventCollectionAsync).mock.calls[0][0]).toMatchObject({
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
@@ -179,10 +184,86 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: expect.objectContaining({
|
||||
webPreviewUrl: 'https://web-preview.tunnel.example.com',
|
||||
webPreviewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.tunnel.example.com',
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to serve-sim and announces them on an iOS session', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.DARWIN },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
},
|
||||
outputs: {},
|
||||
env: { EXISTING: 'value' },
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() };
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
|
||||
await expect(
|
||||
buildFunction.fn!(
|
||||
{
|
||||
logger,
|
||||
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
|
||||
} as unknown as BuildStepContext,
|
||||
{
|
||||
inputs: {
|
||||
package_version: { value: undefined },
|
||||
max_idle_time_minutes: { value: undefined },
|
||||
max_duration_seconds: { value: undefined },
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
},
|
||||
outputs: {},
|
||||
env: {},
|
||||
} as never
|
||||
)
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const ctx = {} as unknown as CustomBuildContext;
|
||||
const buildFunction = createStartArgentRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual(expect.arrayContaining(['launch_app_identifier', 'launch_args', 'open_url']));
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,7 @@ describe(warnIfArgentPackageVersionCannotBeVerified, () => {
|
||||
).not.toThrow();
|
||||
expect(warn).toHaveBeenCalledTimes(2);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Continuing and letting bun x resolve it.')
|
||||
expect.stringContaining('Continuing and letting the package manager resolve it.')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,11 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const server = { pid: 12345, getOutput: () => '', stopAsync: jest.fn() };
|
||||
const tunnel = { url: 'https://egress.example.com', stopAsync: jest.fn() };
|
||||
const tunnel = {
|
||||
url: 'https://egress.example.com',
|
||||
subdomainId: 'egress-id',
|
||||
stopAsync: jest.fn(),
|
||||
};
|
||||
|
||||
async function start(signal?: AbortSignal): Promise<void> {
|
||||
await createStartLocalEgressBuildFunction().fn!({ logger } as BuildStepContext, {
|
||||
|
||||
+73
-6
@@ -1,6 +1,7 @@
|
||||
import { type bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, type BuildStepContext, type BuildStepEnv } from '@expo/steps';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { type CustomBuildContext } from '../../../customBuildContext';
|
||||
import {
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
@@ -12,14 +13,25 @@ import {
|
||||
} from '../../utils/remoteDeviceRunSession';
|
||||
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';
|
||||
|
||||
jest.mock('../../utils/remoteDeviceRunSession');
|
||||
jest.mock('../../utils/remoteDeviceRunSession', () => ({
|
||||
...jest.requireActual('../../utils/remoteDeviceRunSession'),
|
||||
getDeviceRunSessionIdOrThrow: jest.fn(),
|
||||
getNgrokTunnelDomainOrThrow: jest.fn(),
|
||||
selectXcodeDeveloperDirectoryAsync: jest.fn(),
|
||||
startDeviceWebPreviewWithTunnelAsync: jest.fn(),
|
||||
uploadRemoteSessionConfigAsync: jest.fn(),
|
||||
waitForDeviceRunSessionStoppedAsync: jest.fn(),
|
||||
}));
|
||||
|
||||
const ctx = {} as CustomBuildContext;
|
||||
const env = {} as BuildStepEnv;
|
||||
const logger = { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
|
||||
const stopAsync = jest.fn();
|
||||
|
||||
async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise<void> {
|
||||
async function runAsync(
|
||||
runtimePlatform: BuildRuntimePlatform,
|
||||
launchInputs: Record<string, { value: unknown }> = {}
|
||||
): Promise<void> {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
await buildFunction.fn!(
|
||||
{
|
||||
@@ -30,6 +42,7 @@ async function runAsync(runtimePlatform: BuildRuntimePlatform): Promise<void> {
|
||||
inputs: {
|
||||
package_version: { value: '1.2.3' },
|
||||
max_duration_seconds: { value: 120 },
|
||||
...launchInputs,
|
||||
},
|
||||
outputs: {},
|
||||
env,
|
||||
@@ -44,7 +57,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
|
||||
jest.mocked(selectXcodeDeveloperDirectoryAsync).mockResolvedValue(undefined);
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
stopAsync,
|
||||
});
|
||||
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
|
||||
@@ -54,7 +68,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
|
||||
it('reports the session token when serve-sim minted one', async () => {
|
||||
jest.mocked(startDeviceWebPreviewWithTunnelAsync).mockResolvedValue({
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewPageUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
apiUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
stopAsync,
|
||||
});
|
||||
@@ -64,7 +79,8 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://web-preview.example.test',
|
||||
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
previewToken: 'tok-1',
|
||||
},
|
||||
})
|
||||
@@ -85,11 +101,17 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
logger,
|
||||
timeoutMs: 60_000,
|
||||
packageVersion: '1.2.3',
|
||||
launchAppIdentifier: undefined,
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
});
|
||||
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
|
||||
ctx,
|
||||
deviceRunSessionId: 'device-run-session-id',
|
||||
remoteConfig: { previewUrl: 'https://web-preview.example.test' },
|
||||
remoteConfig: {
|
||||
previewUrl: 'https://expo.dev/simulator-preview/preview-id',
|
||||
previewApiUrl: 'https://web-preview.example.test',
|
||||
},
|
||||
logger,
|
||||
});
|
||||
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
|
||||
@@ -101,4 +123,49 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
|
||||
});
|
||||
expect(stopAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('declares the launch inputs', () => {
|
||||
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
|
||||
const globalCtx = createGlobalContextMock();
|
||||
|
||||
expect(
|
||||
buildFunction.inputProviders?.map(provider => provider(globalCtx, 'Test step').id)
|
||||
).toEqual([
|
||||
'launch_app_identifier',
|
||||
'launch_args',
|
||||
'open_url',
|
||||
'package_version',
|
||||
'max_duration_seconds',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hands the launch inputs to the web preview and announces them', async () => {
|
||||
await runAsync(BuildRuntimePlatform.DARWIN, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
launch_args: { value: ['-EXDevMenuIsOnboardingFinished', '1'] },
|
||||
open_url: { value: 'exp://127.0.0.1:8081' },
|
||||
});
|
||||
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).toHaveBeenCalledWith(
|
||||
ctx,
|
||||
expect.objectContaining({
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ' +
|
||||
'["-EXDevMenuIsOnboardingFinished","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails before starting anything when a launch is asked for on Android', async () => {
|
||||
await expect(
|
||||
runAsync(BuildRuntimePlatform.LINUX, {
|
||||
launch_app_identifier: { value: 'host.exp.Exponent' },
|
||||
})
|
||||
).rejects.toThrow('runs on linux');
|
||||
expect(startDeviceWebPreviewWithTunnelAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -184,7 +184,7 @@ function logApplicationLaunch(
|
||||
logger.info(`Launching ${applicationIdentifier}${argumentsDescription}.`);
|
||||
}
|
||||
|
||||
function parseNonEmptyStringInput(value: unknown, inputName: string): string {
|
||||
export function parseNonEmptyStringInput(value: unknown, inputName: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
@@ -194,7 +194,7 @@ function parseNonEmptyStringInput(value: unknown, inputName: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseLaunchArgsInput(value: unknown): string[] {
|
||||
export function parseLaunchArgsInput(value: unknown): string[] {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
@@ -207,7 +207,7 @@ function parseLaunchArgsInput(value: unknown): string[] {
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOpenUrlInput(value: unknown): string {
|
||||
export function parseOpenUrlInput(value: unknown): string {
|
||||
const openUrl = parseNonEmptyStringInput(value, 'open_url');
|
||||
if (!URL.canParse(openUrl)) {
|
||||
throw new UserError(
|
||||
|
||||
@@ -108,6 +108,9 @@ function buildMaestroArgs({
|
||||
}
|
||||
case 'maestro-runner': {
|
||||
const args = [`--platform=${platform}`, 'test', `--output=${output}`, '--flatten'];
|
||||
if (shards !== undefined && shards > 1) {
|
||||
args.push(`--parallel=${shards}`);
|
||||
}
|
||||
if (includeTags) {
|
||||
args.push(`--include-tags=${includeTags}`);
|
||||
}
|
||||
@@ -259,12 +262,6 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
|
||||
undefined,
|
||||
'android_connection_mode and EAS_MAESTRO_ANDROID_CONNECTION_MODE must be either "adb" or "dadb".'
|
||||
);
|
||||
if (backend === 'maestro-runner' && shards !== undefined && shards > 1) {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
'maestro-runner does not support EAS Maestro test sharding. Remove shards or set it to 1.'
|
||||
);
|
||||
}
|
||||
if (backend === 'maestro-runner' && outputFormat !== undefined && outputFormat !== 'junit') {
|
||||
throw new UserError(
|
||||
'ERR_MAESTRO_INVALID_INPUT',
|
||||
|
||||
@@ -18,13 +18,22 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageAdd,
|
||||
resolvePackageInstall,
|
||||
} from '../../utils/packageManager';
|
||||
import { pollAgentDeviceArtifactsForUploadAsync } from '../utils/agentDeviceArtifacts';
|
||||
import { startAgentDeviceEventCollectionAsync } from '../utils/agentDeviceEvents';
|
||||
import {
|
||||
type DetachedProcessHandle,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
@@ -53,6 +62,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
name: 'Start agent device remote session',
|
||||
__metricsId: 'eas/start_agent_device_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -83,6 +93,14 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
logger.info(
|
||||
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
@@ -115,14 +133,23 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
| Awaited<ReturnType<typeof startAgentDeviceEventCollectionAsync>>
|
||||
| undefined;
|
||||
try {
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
logger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
);
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
@@ -132,7 +159,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
remoteConfig: {
|
||||
agentDeviceRemoteSessionUrl,
|
||||
agentDeviceRemoteSessionToken: daemonToken,
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
@@ -183,6 +211,74 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
|
||||
});
|
||||
}
|
||||
|
||||
export async function startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const installDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'eas-agent-device-'));
|
||||
await fs.promises.writeFile(
|
||||
path.join(installDir, 'package.json'),
|
||||
`${JSON.stringify({ name: 'eas-agent-device', private: true })}\n`
|
||||
);
|
||||
|
||||
try {
|
||||
const add = resolvePackageAdd(packageManager, packageSpec);
|
||||
logger.info(`Installing ${packageSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, { cwd: installDir, env, logger });
|
||||
|
||||
const daemonPath = getInstalledAgentDeviceDaemonPath(installDir);
|
||||
if (!fs.existsSync(daemonPath)) {
|
||||
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
|
||||
}
|
||||
|
||||
logger.info(`Launching daemon from ${daemonPath} after ${add.command} install.`);
|
||||
const daemonProcess = spawnDetached({
|
||||
command: 'node',
|
||||
args: [daemonPath],
|
||||
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
|
||||
});
|
||||
return {
|
||||
...daemonProcess,
|
||||
stopAsync: async () => {
|
||||
await daemonProcess.stopAsync();
|
||||
await fs.promises.rm(installDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
await fs.promises.rm(installDir, { recursive: true, force: true });
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
|
||||
Sentry.capture(
|
||||
'Failed to start agent-device daemon from the configured package manager; falling back to git clone',
|
||||
error,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
phase: 'agent-device-daemon-start',
|
||||
fallback: 'git-clone',
|
||||
},
|
||||
extras: {
|
||||
packageSpec,
|
||||
packageVersion: packageVersion ?? 'latest',
|
||||
packageManager,
|
||||
bunVersion,
|
||||
},
|
||||
}
|
||||
);
|
||||
logger.warn(
|
||||
`Failed to start daemon from ${packageSpec} via ${packageManager}; falling back to git clone: ${error.message}`
|
||||
);
|
||||
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopAgentDeviceEventCollectionSafelyAsync({
|
||||
eventCollection,
|
||||
deviceRunSessionId,
|
||||
@@ -205,61 +301,6 @@ export async function stopAgentDeviceEventCollectionSafelyAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function startAgentDeviceDaemonAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
logger,
|
||||
}: {
|
||||
packageVersion: string | undefined;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
}): Promise<DetachedProcessHandle> {
|
||||
const packageSpec = createAgentDevicePackageSpec(packageVersion);
|
||||
try {
|
||||
logger.info(`Installing ${packageSpec} globally with Bun.`);
|
||||
await spawn('bun', ['add', '--global', packageSpec], {
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
|
||||
if (!fs.existsSync(daemonPath)) {
|
||||
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
|
||||
}
|
||||
|
||||
logger.info(`Launching daemon from ${daemonPath}.`);
|
||||
return spawnDetached({
|
||||
command: 'node',
|
||||
args: [daemonPath],
|
||||
env: { ...env, ...AGENT_DEVICE_DAEMON_ENV },
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
|
||||
Sentry.capture(
|
||||
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
|
||||
error,
|
||||
{
|
||||
level: 'warning',
|
||||
tags: {
|
||||
phase: 'agent-device-daemon-start',
|
||||
fallback: 'git-clone',
|
||||
},
|
||||
extras: {
|
||||
packageSpec,
|
||||
packageVersion: packageVersion ?? 'latest',
|
||||
bunVersion,
|
||||
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
|
||||
},
|
||||
}
|
||||
);
|
||||
logger.warn(
|
||||
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
|
||||
);
|
||||
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
|
||||
}
|
||||
}
|
||||
|
||||
async function startAgentDeviceDaemonFromGitAsync({
|
||||
packageVersion,
|
||||
env,
|
||||
@@ -276,14 +317,17 @@ async function startAgentDeviceDaemonFromGitAsync({
|
||||
);
|
||||
await cloneAgentDeviceAsync({ packageVersion, env, logger });
|
||||
|
||||
logger.info('Installing agent-device dependencies.');
|
||||
await spawn('bun', ['install', '--production'], {
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const install = resolvePackageInstall(packageManager, { production: true });
|
||||
logger.info(`Installing agent-device dependencies with ${install.command}.`);
|
||||
await spawn(install.command, install.args, {
|
||||
cwd: SRC_DIR,
|
||||
env,
|
||||
logger,
|
||||
});
|
||||
|
||||
logger.info('Launching daemon from cloned agent-device source.');
|
||||
// Git fallback is TypeScript source. The published path runs node on dist JS.
|
||||
return spawnDetached({
|
||||
command: 'bun',
|
||||
args: ['run', 'src/daemon.ts'],
|
||||
@@ -346,11 +390,9 @@ function createAgentDevicePackageSpec(packageVersion: string | undefined): strin
|
||||
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
|
||||
}
|
||||
|
||||
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
|
||||
function getInstalledAgentDeviceDaemonPath(installDir: string): string {
|
||||
return path.join(
|
||||
getBunInstallDirectory(env),
|
||||
'install',
|
||||
'global',
|
||||
installDir,
|
||||
'node_modules',
|
||||
AGENT_DEVICE_PACKAGE_NAME,
|
||||
'dist',
|
||||
@@ -360,11 +402,6 @@ function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
|
||||
);
|
||||
}
|
||||
|
||||
function getBunInstallDirectory(env: BuildStepEnv): string {
|
||||
const bunInstall = env.BUN_INSTALL?.trim();
|
||||
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
|
||||
}
|
||||
|
||||
function parseDaemonInfo(raw: string): { port: number; token: string } {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (
|
||||
|
||||
@@ -21,13 +21,21 @@ import {
|
||||
} from '../utils/localEgressSession';
|
||||
import { AndroidEmulatorUtils } from '../../utils/AndroidEmulatorUtils';
|
||||
import { IosSimulatorUtils } from '../../utils/IosSimulatorUtils';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageAdd,
|
||||
} from '../../utils/packageManager';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { startAppiumEventCollectionAsync } from '../utils/appiumEvents';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
@@ -54,6 +62,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
name: 'Start Appium remote session',
|
||||
__metricsId: 'eas/start_appium_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -72,6 +81,14 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
const packageVersion = inputs.package_version.value as string | undefined;
|
||||
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
const versionSpec = resolveAppium3VersionSpec(packageVersion);
|
||||
|
||||
logger.info(
|
||||
@@ -133,12 +150,19 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
|
||||
// expo-device-hub has no serial-selection flag. Device run session workflows must expose
|
||||
// a single booted Android emulator so the Hub and Appium resolve the same device.
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: APPIUM_STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
});
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
@@ -153,7 +177,8 @@ export function createStartAppiumRemoteSessionBuildFunction(
|
||||
'appium:automationName': device.automationName,
|
||||
'appium:udid': device.udid,
|
||||
},
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
@@ -198,7 +223,7 @@ export function resolveAppium3VersionSpec(packageVersion: string | undefined): s
|
||||
return versionSpec;
|
||||
}
|
||||
|
||||
type AppiumDevice = {
|
||||
export type AppiumDevice = {
|
||||
platformName: 'iOS' | 'Android';
|
||||
automationName: 'XCUITest' | 'UiAutomator2';
|
||||
driverName: 'xcuitest' | 'uiautomator2';
|
||||
@@ -247,7 +272,7 @@ export async function resolveAppiumDeviceAsync({
|
||||
}
|
||||
}
|
||||
|
||||
async function installAppiumAsync({
|
||||
export async function installAppiumAsync({
|
||||
versionSpec,
|
||||
driverName,
|
||||
env,
|
||||
@@ -265,9 +290,14 @@ async function installAppiumAsync({
|
||||
);
|
||||
const appiumEnv: BuildStepEnv = { ...env, APPIUM_HOME: appiumHome };
|
||||
const appiumBinPath = path.join(appiumHome, 'node_modules', '.bin', 'appium');
|
||||
const add = resolvePackageAdd(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
`appium@${versionSpec}`
|
||||
);
|
||||
|
||||
logger.info(`Installing appium@${versionSpec}.`);
|
||||
await spawn('npm', ['install', '--prefix', appiumHome, `appium@${versionSpec}`], {
|
||||
logger.info(`Installing appium@${versionSpec} with ${add.command}.`);
|
||||
await spawn(add.command, add.args, {
|
||||
cwd: appiumHome,
|
||||
env: appiumEnv,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -19,15 +19,23 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { isProcessDescendantOfAsync } from '../../utils/processes';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { pollArgentArtifactsForUploadAsync } from '../utils/argentArtifacts';
|
||||
import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../utils/argentEvents';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokAuthtokenOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
spawnDetached,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
@@ -66,6 +74,7 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
name: 'Start argent remote session',
|
||||
__metricsId: 'eas/start_argent_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -98,6 +107,14 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
||||
const versionSpec = packageVersion ?? 'latest';
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
logger.info(
|
||||
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
|
||||
);
|
||||
@@ -113,42 +130,45 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
// Never rejects, so `void` is safe.
|
||||
void ensureFfmpegInstalledOnceAsync({ runtimePlatform, env, logger });
|
||||
|
||||
const packageManager = resolveConfiguredPackageManager(env, PackageManager.BUN);
|
||||
const argentExec = (args: string[]): { command: string; args: string[] } =>
|
||||
resolvePackageExec(packageManager, args);
|
||||
|
||||
logger.info('Enabling the Argent artifacts list endpoint flag.');
|
||||
await spawn(
|
||||
'bun',
|
||||
[
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
],
|
||||
{ env, logger }
|
||||
);
|
||||
const enableArtifacts = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_ARTIFACTS_LIST_ENDPOINT_FLAG,
|
||||
]);
|
||||
await spawn(enableArtifacts.command, enableArtifacts.args, { env, logger });
|
||||
|
||||
logger.info('Enabling the Argent tool-server event log flag.');
|
||||
await spawn(
|
||||
'bun',
|
||||
['x', `${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'enable', ARGENT_EVENT_LOG_FLAG],
|
||||
{ env, logger }
|
||||
);
|
||||
const enableEventLog = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'enable',
|
||||
ARGENT_EVENT_LOG_FLAG,
|
||||
]);
|
||||
await spawn(enableEventLog.command, enableEventLog.args, { env, logger });
|
||||
|
||||
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via bun x.`);
|
||||
// Keep Argent itself in foreground mode under the detached bun process. This preserves
|
||||
// the bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const startServer = argentExec([
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
]);
|
||||
logger.info(
|
||||
`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} tool-server via ${startServer.command}.`
|
||||
);
|
||||
// Keep Argent itself in foreground mode under the detached process. This preserves
|
||||
// the npx/bun -> Argent CLI -> tool-server ancestry used to identify the matching state file.
|
||||
const argentServer = spawnDetached({
|
||||
command: 'bun',
|
||||
args: [
|
||||
'x',
|
||||
`${ARGENT_PACKAGE_NAME}@${versionSpec}`,
|
||||
'server',
|
||||
'start',
|
||||
'--port',
|
||||
'0',
|
||||
'--idle-timeout',
|
||||
'0',
|
||||
'--force',
|
||||
],
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH },
|
||||
command: startServer.command,
|
||||
args: startServer.args,
|
||||
env: { ...env, ARGENT_EVENT_LOG: ARGENT_EVENT_LOG_PATH, ARGENT_EMULATOR_NO_WINDOW: '1' },
|
||||
});
|
||||
if (argentServer.pid === undefined) {
|
||||
throw new SystemError(
|
||||
@@ -209,14 +229,23 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
const publicToolsUrl = toolsTunnel.url;
|
||||
logger.info(`Tunnel is ready at ${publicToolsUrl}.`);
|
||||
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
webPreview = await startDeviceWebPreviewWithTunnelAsync(ctx, {
|
||||
runtimePlatform,
|
||||
baseDomain: ngrokTunnelDomain,
|
||||
env,
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
});
|
||||
logger.info(`Web preview URL: ${webPreview.previewUrl}`);
|
||||
logger.info(
|
||||
`Web preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`
|
||||
);
|
||||
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
env,
|
||||
@@ -226,7 +255,8 @@ export function createStartArgentRemoteSessionBuildFunction(
|
||||
remoteConfig: {
|
||||
toolsUrl: publicToolsUrl,
|
||||
...(toolServerToken ? { toolsAuthToken: toolServerToken } : {}),
|
||||
webPreviewUrl: webPreview.previewUrl,
|
||||
webPreviewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { webPreviewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
@@ -306,7 +336,7 @@ export function warnIfArgentPackageVersionCannotBeVerified({
|
||||
logger.warn(
|
||||
`Argent remote simulator sessions require ${ARGENT_PACKAGE_NAME}@${MIN_ARGENT_REMOTE_SESSION_VERSION} or newer, ` +
|
||||
`but package_version "${packageVersion}" is not an exact semver version that EAS can verify. ` +
|
||||
`Continuing and letting bun x resolve it.`
|
||||
`Continuing and letting the package manager resolve it.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
withLocalEgressSession,
|
||||
} from '../utils/localEgressSession';
|
||||
import {
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
getDeviceRunSessionIdOrThrow,
|
||||
getNgrokTunnelDomainOrThrow,
|
||||
parseServeSimLaunchInputs,
|
||||
selectXcodeDeveloperDirectoryAsync,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
@@ -29,6 +32,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
name: 'Start web preview remote session',
|
||||
__metricsId: 'eas/start_serve_sim_remote_session',
|
||||
inputProviders: [
|
||||
...createServeSimLaunchInputProviders(),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'package_version',
|
||||
required: false,
|
||||
@@ -46,8 +50,20 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
|
||||
const packageVersion = inputs.package_version?.value as string | undefined;
|
||||
const { runtimePlatform } = global;
|
||||
const launch = parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: inputs.launch_app_identifier?.value as string | undefined,
|
||||
launchArgs: inputs.launch_args?.value,
|
||||
openUrl: inputs.open_url?.value as string | undefined,
|
||||
},
|
||||
{ runtimePlatform }
|
||||
);
|
||||
|
||||
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
|
||||
const launchDescription = describeServeSimLaunch(launch);
|
||||
if (launchDescription) {
|
||||
logger.info(launchDescription);
|
||||
}
|
||||
|
||||
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
|
||||
await selectXcodeDeveloperDirectoryAsync({ env, logger });
|
||||
@@ -60,8 +76,11 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
logger,
|
||||
timeoutMs: STARTUP_TIMEOUT_MS,
|
||||
packageVersion,
|
||||
launchAppIdentifier: launch.launchAppIdentifier,
|
||||
launchArgs: launch.launchArgs,
|
||||
openUrl: launch.openUrl,
|
||||
});
|
||||
logger.info(`Preview URL: ${webPreview.previewUrl}`);
|
||||
logger.info(`Preview URL: ${webPreview.previewPageUrl} (server: ${webPreview.apiUrl}).`);
|
||||
|
||||
try {
|
||||
await uploadRemoteSessionConfigWithLocalEgressAsync({
|
||||
@@ -70,7 +89,8 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
|
||||
ctx,
|
||||
deviceRunSessionId,
|
||||
remoteConfig: {
|
||||
previewUrl: webPreview.previewUrl,
|
||||
previewUrl: webPreview.previewPageUrl,
|
||||
previewApiUrl: webPreview.apiUrl,
|
||||
...(webPreview.previewToken ? { previewToken: webPreview.previewToken } : {}),
|
||||
},
|
||||
logger,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
|
||||
import { BuildRuntimePlatform, BuildStepEnv, BuildStepInputValueTypeName } from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from 'node:timers';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { createGlobalContextMock } from '../../../__tests__/utils/context';
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { Sentry } from '../../../sentry';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
@@ -16,15 +18,20 @@ import { sleepAsync } from '../../../utils/retry';
|
||||
import {
|
||||
createExpoDeviceHubArgs,
|
||||
createServeSimArgs,
|
||||
createServeSimLaunchInputProviders,
|
||||
describeServeSimLaunch,
|
||||
ensureFfmpegInstalledOnceAsync,
|
||||
fetchWebPreviewTurnArgsAsync,
|
||||
metricsCorsOriginToServeSimArgs,
|
||||
parseServeSimLaunchInputs,
|
||||
simulatorPreviewPageUrl,
|
||||
startDeviceWebPreviewWithTunnelAsync,
|
||||
startExpoDeviceHubWithTunnelAsync,
|
||||
startNgrokTunnelAsync,
|
||||
turnIceServersToWebPreviewArgs,
|
||||
waitForDeviceRunSessionStoppedAsync,
|
||||
waitForWebPreviewReadyAsync,
|
||||
websiteOrigin,
|
||||
websiteOriginServeSimArgs,
|
||||
} from '../remoteDeviceRunSession';
|
||||
|
||||
jest.mock('@ngrok/ngrok');
|
||||
@@ -122,6 +129,161 @@ function createEnvMock(): BuildStepEnv {
|
||||
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
|
||||
}
|
||||
|
||||
describe(createServeSimLaunchInputProviders, () => {
|
||||
it('declares the launch inputs as optional', () => {
|
||||
const globalCtx = createGlobalContextMock();
|
||||
const inputs = createServeSimLaunchInputProviders().map(provider =>
|
||||
provider(globalCtx, 'Test step')
|
||||
);
|
||||
|
||||
expect(
|
||||
inputs.map(({ id, required, allowedValueTypeName }) => ({
|
||||
id,
|
||||
required,
|
||||
allowedValueTypeName,
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
id: 'launch_app_identifier',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
},
|
||||
{
|
||||
id: 'launch_args',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
},
|
||||
{
|
||||
id: 'open_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(parseServeSimLaunchInputs, () => {
|
||||
const darwin = { runtimePlatform: BuildRuntimePlatform.DARWIN };
|
||||
|
||||
it('reads the launch identifier, arguments and URL', () => {
|
||||
expect(
|
||||
parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
},
|
||||
darwin
|
||||
)
|
||||
).toEqual({
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to no launch when the step declares nothing', () => {
|
||||
expect(parseServeSimLaunchInputs({}, { runtimePlatform: BuildRuntimePlatform.LINUX })).toEqual({
|
||||
launchAppIdentifier: undefined,
|
||||
launchArgs: [],
|
||||
openUrl: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects launch arguments that are not a list', () => {
|
||||
expect(() =>
|
||||
parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: 'oops',
|
||||
},
|
||||
darwin
|
||||
)
|
||||
).toThrow('must be an array of strings');
|
||||
});
|
||||
|
||||
it('rejects launch arguments that are not all strings', () => {
|
||||
expect(() =>
|
||||
parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-flag', 1],
|
||||
},
|
||||
darwin
|
||||
)
|
||||
).toThrow('must be an array of strings');
|
||||
});
|
||||
|
||||
it('rejects launch arguments with no application to launch', () => {
|
||||
expect(() => parseServeSimLaunchInputs({ launchArgs: ['-flag'] }, darwin)).toThrow(
|
||||
'Pass "launch_app_identifier"'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a URL with no application to open it in', () => {
|
||||
expect(() => parseServeSimLaunchInputs({ openUrl: 'exp://127.0.0.1:8081' }, darwin)).toThrow(
|
||||
'Pass "launch_app_identifier"'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an identifier that resolved to an empty string', () => {
|
||||
expect(() => parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin)).toThrow(
|
||||
'must be a non-empty string'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a URL that is not a URL', () => {
|
||||
expect(() =>
|
||||
parseServeSimLaunchInputs(
|
||||
{ launchAppIdentifier: 'host.exp.Exponent', openUrl: 'not a url' },
|
||||
darwin
|
||||
)
|
||||
).toThrow('must be a valid URL');
|
||||
});
|
||||
|
||||
it('raises a user error, not a platform error', () => {
|
||||
expect(() => parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin)).toThrow(UserError);
|
||||
try {
|
||||
parseServeSimLaunchInputs({ launchAppIdentifier: '' }, darwin);
|
||||
} catch (error) {
|
||||
expect((error as UserError).errorCode).toBe('EAS_LAUNCH_APPLICATION_INVALID_INPUT');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a launch on a session that does not run an iOS simulator', () => {
|
||||
expect(() =>
|
||||
parseServeSimLaunchInputs(
|
||||
{ launchAppIdentifier: 'host.exp.Exponent' },
|
||||
{ runtimePlatform: BuildRuntimePlatform.LINUX }
|
||||
)
|
||||
).toThrow('runs on linux');
|
||||
});
|
||||
});
|
||||
|
||||
describe(describeServeSimLaunch, () => {
|
||||
it('says nothing when there is no application to launch', () => {
|
||||
expect(describeServeSimLaunch({ launchArgs: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it('names only the application when there are no arguments or URL', () => {
|
||||
expect(describeServeSimLaunch({ launchAppIdentifier: 'host.exp.Exponent' })).toBe(
|
||||
'serve-sim will launch host.exp.Exponent.'
|
||||
);
|
||||
});
|
||||
|
||||
it('names the application, its arguments and the URL', () => {
|
||||
expect(
|
||||
describeServeSimLaunch({
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-flag', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
})
|
||||
).toBe(
|
||||
'serve-sim will launch host.exp.Exponent with arguments ["-flag","1"], then open exp://127.0.0.1:8081.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(createServeSimArgs, () => {
|
||||
it('uses the latest Expo package and applies the EAS streaming policy', () => {
|
||||
expect(
|
||||
@@ -130,7 +292,6 @@ describe(createServeSimArgs, () => {
|
||||
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
|
||||
})
|
||||
).toEqual([
|
||||
'--yes',
|
||||
'@expo/serve-sim@latest',
|
||||
'--port',
|
||||
'4321',
|
||||
@@ -140,13 +301,13 @@ describe(createServeSimArgs, () => {
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'vp8',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
'960',
|
||||
'1600',
|
||||
'--mjpeg-quality',
|
||||
'0.55',
|
||||
'--video-bitrate',
|
||||
'6000000',
|
||||
'10000000',
|
||||
'--video-fps',
|
||||
'60',
|
||||
'--turn-url',
|
||||
@@ -154,33 +315,70 @@ describe(createServeSimArgs, () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends metrics CORS args after the TURN args when provided', () => {
|
||||
it('appends the website args after the TURN args when provided', () => {
|
||||
const args = createServeSimArgs({
|
||||
port: 4321,
|
||||
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
|
||||
metricsCorsArgs: ['--metrics-cors-origin', 'https://expo.dev'],
|
||||
websiteArgs: ['--cors-origin', 'https://expo.dev'],
|
||||
});
|
||||
expect(args.slice(-4)).toEqual([
|
||||
'--turn-url',
|
||||
'turns:turn.example.test:443',
|
||||
'--metrics-cors-origin',
|
||||
'--cors-origin',
|
||||
'https://expo.dev',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins the requested package version', () => {
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'@expo/[email protected]',
|
||||
]);
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: '0.1.38' })[0]).toBe(
|
||||
'@expo/[email protected]'
|
||||
);
|
||||
});
|
||||
|
||||
it('pins a dist-tag', () => {
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'@expo/serve-sim@next',
|
||||
expect(createServeSimArgs({ port: 4321, packageVersion: 'next' })[0]).toBe(
|
||||
'@expo/serve-sim@next'
|
||||
);
|
||||
});
|
||||
|
||||
it('appends --share-url after the website args', () => {
|
||||
const args = createServeSimArgs({
|
||||
port: 4321,
|
||||
websiteArgs: ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'],
|
||||
shareUrl: 'https://expo.dev/simulator-preview/abc',
|
||||
});
|
||||
expect(args.slice(-4)).toEqual([
|
||||
'--frame-ancestor',
|
||||
'https://expo.dev',
|
||||
'--share-url',
|
||||
'https://expo.dev/simulator-preview/abc',
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends the launch flags after the streaming policy', () => {
|
||||
const args = createServeSimArgs({
|
||||
port: 4321,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
});
|
||||
expect(args.slice(-8)).toEqual([
|
||||
'--launch-app-identifier',
|
||||
'host.exp.Exponent',
|
||||
'--launch-arg',
|
||||
'-EXDevMenuIsOnboardingFinished',
|
||||
'--launch-arg',
|
||||
'1',
|
||||
'--open-url',
|
||||
'exp://127.0.0.1:8081',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the launch flags when there is no application to launch', () => {
|
||||
const args = createServeSimArgs({ port: 4321 });
|
||||
expect(args.some(argument => argument.startsWith('--launch'))).toBe(false);
|
||||
expect(args).not.toContain('--open-url');
|
||||
});
|
||||
});
|
||||
|
||||
describe(createExpoDeviceHubArgs, () => {
|
||||
@@ -191,7 +389,6 @@ describe(createExpoDeviceHubArgs, () => {
|
||||
turnArgs: ['--turn-url', 'turns:turn.example.test:443'],
|
||||
})
|
||||
).toEqual([
|
||||
'--yes',
|
||||
'expo-device-hub@latest',
|
||||
'--port',
|
||||
'4321',
|
||||
@@ -219,37 +416,51 @@ describe(createExpoDeviceHubArgs, () => {
|
||||
});
|
||||
|
||||
it('pins the requested package version', () => {
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' }).slice(0, 2)).toEqual([
|
||||
'--yes',
|
||||
'[email protected]',
|
||||
]);
|
||||
expect(createExpoDeviceHubArgs({ port: 4321, packageVersion: '0.7.0' })[0]).toBe(
|
||||
'[email protected]'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(metricsCorsOriginToServeSimArgs, () => {
|
||||
it('returns no args when the origin is unset or empty', () => {
|
||||
expect(metricsCorsOriginToServeSimArgs({} as BuildStepEnv)).toEqual([]);
|
||||
expect(
|
||||
metricsCorsOriginToServeSimArgs({ EAS_SIMULATOR_METRICS_CORS_ORIGIN: '' } as BuildStepEnv)
|
||||
).toEqual([]);
|
||||
describe(websiteOriginServeSimArgs, () => {
|
||||
it('names only the production website by default', () => {
|
||||
expect(websiteOriginServeSimArgs({} as BuildStepEnv)).toEqual([
|
||||
'--cors-origin',
|
||||
'https://expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://expo.dev',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds one flag per comma-separated origin', () => {
|
||||
expect(
|
||||
metricsCorsOriginToServeSimArgs({
|
||||
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://expo.dev',
|
||||
} as BuildStepEnv)
|
||||
).toEqual(['--metrics-cors-origin', 'https://expo.dev']);
|
||||
expect(
|
||||
metricsCorsOriginToServeSimArgs({
|
||||
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://expo.dev, https://staging.expo.dev',
|
||||
} as BuildStepEnv)
|
||||
).toEqual([
|
||||
'--metrics-cors-origin',
|
||||
'https://expo.dev',
|
||||
'--metrics-cors-origin',
|
||||
it('names staging, its deploy previews and each website dev port on staging', () => {
|
||||
const args = websiteOriginServeSimArgs({ EXPO_STAGING: '1' } as BuildStepEnv);
|
||||
expect(args.slice(0, 4)).toEqual([
|
||||
'--cors-origin',
|
||||
'https://staging.expo.dev',
|
||||
'--frame-ancestor',
|
||||
'https://staging.expo.dev',
|
||||
]);
|
||||
expect(args).toContain('https://*.expo.dev');
|
||||
expect(args).toContain('https://expo.test:13001');
|
||||
expect(args).toContain('https://expo.test:13215');
|
||||
expect(args).not.toContain('https://expo.test:13216');
|
||||
expect(args).not.toContain('https://expo.dev');
|
||||
});
|
||||
|
||||
it('names only https origins', () => {
|
||||
for (const env of [{}, { EXPO_STAGING: '1' }, { EXPO_LOCAL: '1' }]) {
|
||||
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
|
||||
expect(args.filter(value => value.startsWith('http://'))).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('names the website dev ports on local, without the deploy-preview wildcard', () => {
|
||||
for (const env of [{ EXPO_LOCAL: '1' }, { EXPO_LOCAL: '1', EXPO_STAGING: '1' }]) {
|
||||
const args = websiteOriginServeSimArgs(env as BuildStepEnv);
|
||||
expect(args).toContain('https://expo.test:13001');
|
||||
expect(args).not.toContain('https://*.expo.dev');
|
||||
expect(args.filter(value => value === 'https://expo.test')).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,6 +520,11 @@ describe(startNgrokTunnelAsync, () => {
|
||||
})
|
||||
);
|
||||
expect(tunnel.url).toBe('https://web-preview.example.test');
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
domain: `web-preview-${tunnel.subdomainId}.eas-simulator.ngrok.dev`,
|
||||
})
|
||||
);
|
||||
await tunnel.stopAsync();
|
||||
await tunnel.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
@@ -325,10 +541,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
'--turn-credential',
|
||||
'turn-credential',
|
||||
];
|
||||
const metricsCorsArgs = ['--metrics-cors-origin', 'https://metrics.expo.test'];
|
||||
const websiteArgs = ['--cors-origin', 'https://expo.dev', '--frame-ancestor', 'https://expo.dev'];
|
||||
const env = {
|
||||
DEVICE_RUN_SESSION_ID: 'drs-id',
|
||||
EAS_SIMULATOR_METRICS_CORS_ORIGIN: 'https://metrics.expo.test',
|
||||
NGROK_AUTHTOKEN: 'ngrok-token',
|
||||
} as unknown as BuildStepEnv;
|
||||
|
||||
@@ -419,9 +634,9 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(createExpoDeviceHubArgs({ port, turnArgs, packageVersion }));
|
||||
expect(args).toEqual(['--yes', ...createExpoDeviceHubArgs({ port, turnArgs, packageVersion })]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.previewUrl).toBe('https://android-preview.example.test');
|
||||
expect(preview.apiUrl).toBe('https://android-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
@@ -442,7 +657,41 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
});
|
||||
|
||||
expect(preview.previewToken).toBe('tok-1');
|
||||
expect(preview.previewUrl).toBe('https://preview.example.test');
|
||||
expect(preview.apiUrl).toBe('https://preview.example.test');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}, 'https://expo.dev'],
|
||||
[{ EXPO_STAGING: '1' }, 'https://staging.expo.dev'],
|
||||
[{ EXPO_LOCAL: '1' }, 'https://expo.test'],
|
||||
])('points the preview page at the website for the stage the worker runs in', (stage, origin) => {
|
||||
expect(websiteOrigin({ ...env, ...stage })).toBe(origin);
|
||||
expect(simulatorPreviewPageUrl({ ...env, ...stage }, 'abc')).toBe(
|
||||
`${origin}/simulator-preview/abc`
|
||||
);
|
||||
});
|
||||
|
||||
it('points the preview URL at the website page for the tunnel', async () => {
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: { ...env, EXPO_STAGING: '1' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(preview.previewPageUrl).toMatch(
|
||||
/^https:\/\/staging\.expo\.dev\/simulator-preview\/[a-f0-9]{32}$/
|
||||
);
|
||||
const previewId = preview.previewPageUrl.split('/').at(-1);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ domain: `web-preview-${previewId}.${baseDomain}` })
|
||||
);
|
||||
});
|
||||
|
||||
// serve-sim is always launched with --require-token, so a missing token means it is running
|
||||
@@ -505,9 +754,18 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(port).toBeGreaterThan(0);
|
||||
expect(command).toBe('npx');
|
||||
expect(args).toEqual(createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }));
|
||||
expect(args).toEqual([
|
||||
'--yes',
|
||||
...createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
websiteArgs,
|
||||
shareUrl: preview.previewPageUrl,
|
||||
packageVersion,
|
||||
}),
|
||||
]);
|
||||
expect(ngrok.forward).toHaveBeenCalledWith(expect.objectContaining({ addr: port }));
|
||||
expect(preview.previewUrl).toBe('https://ios-preview.example.test');
|
||||
expect(preview.apiUrl).toBe('https://ios-preview.example.test');
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
@@ -538,6 +796,117 @@ describe(startDeviceWebPreviewWithTunnelAsync, () => {
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('launches serve-sim with bun x when EAS_OVERRIDE_PACKAGE_MANAGER is bun', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://ios-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const bunEnv = { ...env, EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' };
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: bunEnv,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
packageVersion: '4.5.6',
|
||||
});
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(command).toBe('bun');
|
||||
expect(args).toEqual([
|
||||
'x',
|
||||
...createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
websiteArgs,
|
||||
shareUrl: preview.previewPageUrl,
|
||||
packageVersion: '4.5.6',
|
||||
}),
|
||||
]);
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('launches serve-sim with bun x when EAS_FALLBACK_PACKAGE_MANAGER is bun', async () => {
|
||||
const close = jest.fn().mockResolvedValue(undefined);
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://ios-preview.example.test',
|
||||
close,
|
||||
} as never);
|
||||
|
||||
const preview = await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env: { ...env, EAS_FALLBACK_PACKAGE_MANAGER: 'bun' },
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
const [command, args] = jest.mocked(spawn).mock.calls[0];
|
||||
const port = Number(args[args.indexOf('--port') + 1]);
|
||||
expect(command).toBe('bun');
|
||||
expect(args).toEqual([
|
||||
'x',
|
||||
...createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
websiteArgs,
|
||||
shareUrl: preview.previewPageUrl,
|
||||
}),
|
||||
]);
|
||||
|
||||
await preview.stopAsync();
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hands the launch options to serve-sim on Darwin', async () => {
|
||||
jest.mocked(ngrok.forward).mockResolvedValue({
|
||||
url: () => 'https://ios-preview.example.test',
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
} as never);
|
||||
|
||||
await startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.DARWIN,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
launchArgs: ['-EXDevMenuIsOnboardingFinished', '1'],
|
||||
openUrl: 'exp://127.0.0.1:8081',
|
||||
});
|
||||
|
||||
const [, args] = jest.mocked(spawn).mock.calls[0];
|
||||
expect(args.slice(-8)).toEqual([
|
||||
'--launch-app-identifier',
|
||||
'host.exp.Exponent',
|
||||
'--launch-arg',
|
||||
'-EXDevMenuIsOnboardingFinished',
|
||||
'--launch-arg',
|
||||
'1',
|
||||
'--open-url',
|
||||
'exp://127.0.0.1:8081',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refuses to launch an application on Linux, where expo-device-hub cannot', async () => {
|
||||
await expect(
|
||||
startDeviceWebPreviewWithTunnelAsync(createCtxMock(), {
|
||||
runtimePlatform: BuildRuntimePlatform.LINUX,
|
||||
baseDomain,
|
||||
env,
|
||||
logger: createLoggerMock(),
|
||||
timeoutMs: 10_000,
|
||||
launchAppIdentifier: 'host.exp.Exponent',
|
||||
})
|
||||
).rejects.toThrow('Cannot launch host.exp.Exponent');
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(turnIceServersToWebPreviewArgs, () => {
|
||||
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
import { createLogger } from '@expo/logger';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { distributionCertificate } from '../__tests__/fixtures';
|
||||
import Keychain from '../keychain';
|
||||
|
||||
const mockLogger = createLogger({ name: 'mock-logger' });
|
||||
|
||||
jest.setTimeout(60 * 1000);
|
||||
|
||||
// Those are in fact "real" tests in that they really execute the `fastlane` commands.
|
||||
// We need the JS code to modify the file system, so we need to mock it.
|
||||
jest.unmock('fs');
|
||||
|
||||
describe('Keychain class', () => {
|
||||
describe('ensureCertificateImported method', () => {
|
||||
let keychain: Keychain;
|
||||
const certificatePath = path.join(os.tmpdir(), `cert-${uuid()}.p12`);
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.writeFile(
|
||||
certificatePath,
|
||||
new Uint8Array(Buffer.from(distributionCertificate.dataBase64, 'base64'))
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.remove(certificatePath);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
keychain = new Keychain();
|
||||
await keychain.create(mockLogger);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await keychain.destroy(mockLogger);
|
||||
});
|
||||
|
||||
it("should throw an error if the certificate hasn't been imported", async () => {
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
distributionCertificate.fingerprint
|
||||
)
|
||||
).rejects.toThrowError(/hasn't been imported successfully/);
|
||||
});
|
||||
|
||||
it("shouldn't throw any error if the certificate has been imported successfully", async () => {
|
||||
await keychain.importCertificate(
|
||||
mockLogger,
|
||||
certificatePath,
|
||||
distributionCertificate.password
|
||||
);
|
||||
await expect(
|
||||
keychain.ensureCertificateImported(
|
||||
distributionCertificate.teamId,
|
||||
distributionCertificate.fingerprint
|
||||
)
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { createLogger } from '@expo/logger';
|
||||
|
||||
import { provisioningProfile } from './fixtures';
|
||||
import Keychain from '../keychain';
|
||||
import Keychain from '../../../../../ios/credentials/keychain';
|
||||
import ProvisioningProfile from '../provisioningProfile';
|
||||
|
||||
const mockLogger = createLogger({ name: 'mock-logger' });
|
||||
@@ -18,11 +18,11 @@ describe('ProvisioningProfile class', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
keychain = new Keychain();
|
||||
await keychain.create(mockLogger);
|
||||
await keychain.create({ logger: mockLogger });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await keychain.destroy(mockLogger);
|
||||
await keychain.destroy({ logger: mockLogger });
|
||||
});
|
||||
|
||||
it("shouldn't throw any error if the provisioning profile and distribution certificate match", async () => {
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { bunyan } from '@expo/logger';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { runFastlane } from '../fastlane';
|
||||
|
||||
export default class Keychain {
|
||||
private readonly keychainPath: string;
|
||||
private readonly keychainPassword: string;
|
||||
private created = false;
|
||||
private destroyed = false;
|
||||
|
||||
constructor() {
|
||||
this.keychainPath = path.join(os.tmpdir(), `eas-build-${uuid()}.keychain`);
|
||||
this.keychainPassword = uuid();
|
||||
}
|
||||
|
||||
get data(): { path: string; password: string } {
|
||||
return {
|
||||
path: this.keychainPath,
|
||||
password: this.keychainPassword,
|
||||
};
|
||||
}
|
||||
|
||||
public async create(logger: bunyan): Promise<void> {
|
||||
logger.debug(`Creating keychain - ${this.keychainPath}`);
|
||||
await runFastlane([
|
||||
'run',
|
||||
'create_keychain',
|
||||
`path:${this.keychainPath}`,
|
||||
`password:${this.keychainPassword}`,
|
||||
'unlock:true',
|
||||
'timeout:360000',
|
||||
]);
|
||||
this.created = true;
|
||||
}
|
||||
|
||||
public async importCertificate(
|
||||
logger: bunyan,
|
||||
certPath: string,
|
||||
certPassword: string
|
||||
): Promise<void> {
|
||||
if (!this.created) {
|
||||
throw new Error('You must create a keychain first.');
|
||||
}
|
||||
|
||||
logger.debug(`Importing certificate ${certPath} into keychain ${this.keychainPath}`);
|
||||
await runFastlane([
|
||||
'run',
|
||||
'import_certificate',
|
||||
`certificate_path:${certPath}`,
|
||||
`certificate_password:${certPassword}`,
|
||||
`keychain_path:${this.keychainPath}`,
|
||||
`keychain_password:${this.keychainPassword}`,
|
||||
]);
|
||||
}
|
||||
|
||||
public async ensureCertificateImported(teamId: string, fingerprint: string): Promise<void> {
|
||||
const identities = await this.findIdentitiesByTeamId(teamId);
|
||||
if (!identities.includes(fingerprint)) {
|
||||
throw new Error(
|
||||
`Distribution certificate with fingerprint ${fingerprint} hasn't been imported successfully`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(logger: bunyan, keychainPath?: string): Promise<void> {
|
||||
if (!keychainPath && !this.created) {
|
||||
logger.warn("There is nothing to destroy, a keychain hasn't been created yet.");
|
||||
return;
|
||||
}
|
||||
if (this.destroyed) {
|
||||
logger.warn('The keychain has been already destroyed');
|
||||
return;
|
||||
}
|
||||
const keychainToDeletePath = keychainPath ?? this.keychainPath;
|
||||
logger.info(`Destroying keychain - ${keychainToDeletePath}`);
|
||||
try {
|
||||
await runFastlane(['run', 'delete_keychain', `keychain_path:${keychainToDeletePath}`]);
|
||||
this.destroyed = true;
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to delete the keychain\n');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanUpKeychains(logger: bunyan): Promise<void> {
|
||||
const { stdout } = await spawn('security', ['list-keychains'], { stdio: 'pipe' });
|
||||
const keychainList = (/"(.*)"/g.exec(stdout) ?? ([] as string[])).map(i =>
|
||||
i.slice(1, i.length - 1)
|
||||
);
|
||||
const turtleKeychainList = keychainList.filter(keychain =>
|
||||
/eas-build-[\w-]+\.keychain$/.exec(keychain)
|
||||
);
|
||||
for (const turtleKeychainPath of turtleKeychainList) {
|
||||
await this.destroy(logger, turtleKeychainPath);
|
||||
}
|
||||
}
|
||||
|
||||
private async findIdentitiesByTeamId(teamId: string): Promise<string> {
|
||||
const { output } = await spawn(
|
||||
'security',
|
||||
['find-identity', '-v', '-s', `(${teamId})`, this.keychainPath],
|
||||
{
|
||||
stdio: 'pipe',
|
||||
}
|
||||
);
|
||||
return output.join('');
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import path from 'path';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import * as distributionCertificateUtils from './distributionCertificate';
|
||||
import Keychain from './keychain';
|
||||
import Keychain from '../../../../ios/credentials/keychain';
|
||||
import ProvisioningProfile, {
|
||||
DistributionType,
|
||||
ProvisioningProfileData,
|
||||
@@ -36,7 +36,7 @@ export default class IosCredentialsManager {
|
||||
|
||||
logger.info('Creating keychain');
|
||||
this.keychain = new Keychain();
|
||||
await this.keychain.create(logger);
|
||||
await this.keychain.create({ logger });
|
||||
|
||||
const targets = Object.keys(this.buildCredentials);
|
||||
const targetProvisioningProfiles: TargetProvisioningProfiles = {};
|
||||
@@ -70,7 +70,7 @@ export default class IosCredentialsManager {
|
||||
}
|
||||
|
||||
if (this.keychain) {
|
||||
await this.keychain.destroy(logger);
|
||||
await this.keychain.destroy({ logger });
|
||||
}
|
||||
if (this.provisioningProfiles) {
|
||||
for (const provisioningProfile of this.provisioningProfiles) {
|
||||
@@ -109,11 +109,11 @@ export default class IosCredentialsManager {
|
||||
);
|
||||
|
||||
logger.info('Importing distribution certificate into the keychain');
|
||||
await this.keychain.importCertificate(
|
||||
await this.keychain.importCertificate({
|
||||
logger,
|
||||
distCertPath,
|
||||
targetCredentials.distributionCertificate.password
|
||||
);
|
||||
certPath: distCertPath,
|
||||
certPassword: targetCredentials.distributionCertificate.password,
|
||||
});
|
||||
|
||||
logger.info('Initializing provisioning profile');
|
||||
const provisioningProfile = new ProvisioningProfile(
|
||||
@@ -125,10 +125,10 @@ export default class IosCredentialsManager {
|
||||
await provisioningProfile.init(logger);
|
||||
|
||||
logger.info('Validating whether distribution certificate has been imported successfully');
|
||||
await this.keychain.ensureCertificateImported(
|
||||
provisioningProfile.data.teamId,
|
||||
certificateFingerprint
|
||||
);
|
||||
await this.keychain.ensureCertificateImported({
|
||||
teamId: provisioningProfile.data.teamId,
|
||||
fingerprint: certificateFingerprint,
|
||||
});
|
||||
|
||||
logger.info('Verifying whether the distribution certificate and provisioning profile match');
|
||||
provisioningProfile.verifyCertificate(certificateFingerprint);
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { SystemError, UserError } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import { BuildRuntimePlatform, BuildStepEnv, spawnAsync } from '@expo/steps';
|
||||
import {
|
||||
BuildRuntimePlatform,
|
||||
BuildStepEnv,
|
||||
BuildStepInput,
|
||||
BuildStepInputValueTypeName,
|
||||
spawnAsync,
|
||||
} from '@expo/steps';
|
||||
import spawn from '@expo/turtle-spawn';
|
||||
import * as ngrok from '@ngrok/ngrok';
|
||||
import { graphql } from 'gql.tada';
|
||||
@@ -14,7 +20,17 @@ import { clearTimeout, setTimeout } from 'node:timers';
|
||||
import { setTimeout as setTimeoutAsync } from 'node:timers/promises';
|
||||
|
||||
import { CustomBuildContext } from '../../customBuildContext';
|
||||
import {
|
||||
parseLaunchArgsInput,
|
||||
parseNonEmptyStringInput,
|
||||
parseOpenUrlInput,
|
||||
} from '../functions/launchApplication';
|
||||
import { Sentry } from '../../sentry';
|
||||
import {
|
||||
PackageManager,
|
||||
resolveConfiguredPackageManager,
|
||||
resolvePackageExec,
|
||||
} from '../../utils/packageManager';
|
||||
import { sleepAsync } from '../../utils/retry';
|
||||
import { turtleFetch } from '../../utils/turtleFetch';
|
||||
import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetricsRecorder';
|
||||
@@ -22,9 +38,9 @@ import { SERVE_SIM_STATE_DIR, readServeSimServersAsync } from './serveSimMetrics
|
||||
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
|
||||
const WEB_PREVIEW_HOST = '127.0.0.1';
|
||||
const SERVE_SIM_PACKAGE_NAME = '@expo/serve-sim';
|
||||
const SERVE_SIM_MAX_DIMENSION = '960';
|
||||
const SERVE_SIM_MAX_DIMENSION = '1600';
|
||||
const SERVE_SIM_MJPEG_QUALITY = '0.55';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '6000000';
|
||||
const SERVE_SIM_VIDEO_BITRATE = '10000000';
|
||||
const SERVE_SIM_VIDEO_FPS = '60';
|
||||
const EXPO_DEVICE_HUB_PACKAGE_NAME = 'expo-device-hub';
|
||||
const EXPO_DEVICE_HUB_MAX_DIMENSION = '960';
|
||||
@@ -549,7 +565,7 @@ async function stopDetachedProcessAsync(pid: number | undefined): Promise<void>
|
||||
}
|
||||
try {
|
||||
// spawnDetached creates a dedicated process group. Signaling the group also
|
||||
// terminates npx/bunx descendants instead of leaving the actual daemon alive.
|
||||
// terminates npx/bun descendants instead of leaving the actual daemon alive.
|
||||
process.kill(-pid, 'SIGTERM');
|
||||
} catch {
|
||||
try {
|
||||
@@ -612,19 +628,36 @@ export function spawnDetached({
|
||||
};
|
||||
}
|
||||
|
||||
export function metricsCorsOriginToServeSimArgs(env: BuildStepEnv): string[] {
|
||||
const origin = env.EAS_SIMULATOR_METRICS_CORS_ORIGIN;
|
||||
if (!origin) {
|
||||
return [];
|
||||
export function websiteOrigin(env: BuildStepEnv): string {
|
||||
return env.EXPO_LOCAL
|
||||
? 'https://expo.test'
|
||||
: env.EXPO_STAGING
|
||||
? 'https://staging.expo.dev'
|
||||
: 'https://expo.dev';
|
||||
}
|
||||
|
||||
export function simulatorPreviewPageUrl(env: BuildStepEnv, subdomainId: string): string {
|
||||
return new URL(`/simulator-preview/${subdomainId}`, websiteOrigin(env)).toString();
|
||||
}
|
||||
|
||||
// Website dev servers on expo.test use staging sessions, and CORS matches ports exactly.
|
||||
const WEBSITE_DEV_ORIGINS = [
|
||||
'https://expo.test',
|
||||
'https://expo.test:13001',
|
||||
...Array.from({ length: 16 }, (_, index) => `https://expo.test:${13200 + index}`),
|
||||
];
|
||||
|
||||
export function websiteOriginServeSimArgs(env: BuildStepEnv): string[] {
|
||||
const origins = new Set([websiteOrigin(env)]);
|
||||
if (!env.EXPO_LOCAL && env.EXPO_STAGING) {
|
||||
origins.add('https://*.expo.dev');
|
||||
}
|
||||
const args: string[] = [];
|
||||
for (const value of origin.split(',')) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed) {
|
||||
args.push('--metrics-cors-origin', trimmed);
|
||||
if (env.EXPO_LOCAL || env.EXPO_STAGING) {
|
||||
for (const origin of WEBSITE_DEV_ORIGINS) {
|
||||
origins.add(origin);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
return [...origins].flatMap(origin => ['--cors-origin', origin, '--frame-ancestor', origin]);
|
||||
}
|
||||
|
||||
function createServeSimPackageSpec(packageVersion: string | undefined): string {
|
||||
@@ -635,19 +668,97 @@ function createExpoDeviceHubPackageSpec(packageVersion: string | undefined): str
|
||||
return `${EXPO_DEVICE_HUB_PACKAGE_NAME}@${packageVersion ?? 'latest'}`;
|
||||
}
|
||||
|
||||
export interface ServeSimLaunchOptions {
|
||||
launchAppIdentifier?: string;
|
||||
launchArgs?: string[];
|
||||
openUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* serve-sim performs the launch so the application starts under its instrumentation.
|
||||
*/
|
||||
export function createServeSimLaunchInputProviders(): ReturnType<
|
||||
typeof BuildStepInput.createProvider
|
||||
>[] {
|
||||
return [
|
||||
BuildStepInput.createProvider({
|
||||
id: 'launch_app_identifier',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'launch_args',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
|
||||
}),
|
||||
BuildStepInput.createProvider({
|
||||
id: 'open_url',
|
||||
required: false,
|
||||
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function parseServeSimLaunchInputs(
|
||||
{
|
||||
launchAppIdentifier: rawLaunchAppIdentifier,
|
||||
launchArgs: rawLaunchArgs,
|
||||
openUrl: rawOpenUrl,
|
||||
}: { launchAppIdentifier?: unknown; launchArgs?: unknown; openUrl?: unknown },
|
||||
{ runtimePlatform }: { runtimePlatform: BuildRuntimePlatform }
|
||||
): ServeSimLaunchOptions {
|
||||
const launchAppIdentifier =
|
||||
rawLaunchAppIdentifier === undefined
|
||||
? undefined
|
||||
: parseNonEmptyStringInput(rawLaunchAppIdentifier, 'launch_app_identifier');
|
||||
const launchArgs = parseLaunchArgsInput(rawLaunchArgs);
|
||||
const openUrl = rawOpenUrl === undefined ? undefined : parseOpenUrlInput(rawOpenUrl);
|
||||
if (!launchAppIdentifier && (launchArgs.length > 0 || openUrl)) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
'Inputs "launch_args" and "open_url" only work with an application launch. Pass "launch_app_identifier", or remove them.'
|
||||
);
|
||||
}
|
||||
if (launchAppIdentifier && runtimePlatform !== BuildRuntimePlatform.DARWIN) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Input "launch_app_identifier" launches an application on an iOS simulator, and this session runs on ${runtimePlatform}. Run the session on an iOS simulator, or drop the launch inputs.`
|
||||
);
|
||||
}
|
||||
return { launchAppIdentifier, launchArgs, openUrl };
|
||||
}
|
||||
|
||||
export function describeServeSimLaunch({
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: ServeSimLaunchOptions): string | null {
|
||||
if (!launchAppIdentifier) {
|
||||
return null;
|
||||
}
|
||||
const withArguments =
|
||||
launchArgs.length > 0 ? ` with arguments ${JSON.stringify(launchArgs)}` : '';
|
||||
const thenOpen = openUrl ? `, then open ${openUrl}` : '';
|
||||
return `serve-sim will launch ${launchAppIdentifier}${withArguments}${thenOpen}.`;
|
||||
}
|
||||
|
||||
export function createServeSimArgs({
|
||||
port,
|
||||
turnArgs = [],
|
||||
metricsCorsArgs = [],
|
||||
websiteArgs = [],
|
||||
shareUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs = [],
|
||||
openUrl,
|
||||
}: {
|
||||
port: number;
|
||||
turnArgs?: string[];
|
||||
metricsCorsArgs?: string[];
|
||||
websiteArgs?: string[];
|
||||
shareUrl?: string;
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
} & ServeSimLaunchOptions): string[] {
|
||||
return [
|
||||
'--yes',
|
||||
createServeSimPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
@@ -657,7 +768,7 @@ export function createServeSimArgs({
|
||||
'--transport',
|
||||
'webrtc',
|
||||
'--webrtc-codec',
|
||||
'vp8',
|
||||
'h264',
|
||||
'--max-dimension',
|
||||
SERVE_SIM_MAX_DIMENSION,
|
||||
'--mjpeg-quality',
|
||||
@@ -667,7 +778,11 @@ export function createServeSimArgs({
|
||||
'--video-fps',
|
||||
SERVE_SIM_VIDEO_FPS,
|
||||
...turnArgs,
|
||||
...metricsCorsArgs,
|
||||
...websiteArgs,
|
||||
...(shareUrl ? ['--share-url', shareUrl] : []),
|
||||
...(launchAppIdentifier ? ['--launch-app-identifier', launchAppIdentifier] : []),
|
||||
...launchArgs.flatMap(argument => ['--launch-arg', argument]),
|
||||
...(openUrl ? ['--open-url', openUrl] : []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -681,7 +796,6 @@ export function createExpoDeviceHubArgs({
|
||||
packageVersion?: string;
|
||||
}): string[] {
|
||||
return [
|
||||
'--yes',
|
||||
createExpoDeviceHubPackageSpec(packageVersion),
|
||||
'--port',
|
||||
String(port),
|
||||
@@ -771,7 +885,8 @@ export async function waitForWebPreviewReadyAsync({
|
||||
}
|
||||
|
||||
export type DeviceWebPreviewHandle = {
|
||||
previewUrl: string;
|
||||
previewPageUrl: string;
|
||||
apiUrl: string;
|
||||
/** Session token gating the preview. Only serve-sim mints one. */
|
||||
previewToken?: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
@@ -797,16 +912,24 @@ async function startWebPreviewWithTunnelAsync(
|
||||
timeoutMs: number;
|
||||
serverName: string;
|
||||
packageSpec: string;
|
||||
createArgs: (port: number, turnArgs: string[]) => string[];
|
||||
createArgs: (port: number, turnArgs: string[], previewPageUrl: string) => string[];
|
||||
readPreviewTokenAsync?: (device: string) => Promise<string>;
|
||||
}
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
const subdomainId = randomBytes(16).toString('hex');
|
||||
const previewPageUrl = simulatorPreviewPageUrl(env, subdomainId);
|
||||
const port = await findAvailablePortAsync();
|
||||
logger.info(`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port}.`);
|
||||
const turnArgs = await fetchWebPreviewTurnArgsAsync(ctx, { env, logger });
|
||||
const previewExec = resolvePackageExec(
|
||||
resolveConfiguredPackageManager(env, PackageManager.NPM),
|
||||
createArgs(port, turnArgs, previewPageUrl)
|
||||
);
|
||||
logger.info(
|
||||
`Launching ${packageSpec} on ${WEB_PREVIEW_HOST}:${port} via ${previewExec.command}.`
|
||||
);
|
||||
const previewServer = spawnDetached({
|
||||
command: 'npx',
|
||||
args: createArgs(port, turnArgs),
|
||||
command: previewExec.command,
|
||||
args: previewExec.args,
|
||||
env,
|
||||
});
|
||||
|
||||
@@ -822,12 +945,14 @@ async function startWebPreviewWithTunnelAsync(
|
||||
const tunnel = await startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix: 'web-preview',
|
||||
subdomainId,
|
||||
baseDomain,
|
||||
authtoken: getNgrokAuthtokenOrThrow(env),
|
||||
logger,
|
||||
});
|
||||
return {
|
||||
previewUrl: tunnel.url,
|
||||
previewPageUrl,
|
||||
apiUrl: tunnel.url,
|
||||
previewToken,
|
||||
stopAsync: async () => {
|
||||
const results = await Promise.allSettled([tunnel.stopAsync(), previewServer.stopAsync()]);
|
||||
@@ -860,15 +985,18 @@ export async function startServeSimWithTunnelAsync(
|
||||
logger,
|
||||
timeoutMs,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
}: {
|
||||
baseDomain: string;
|
||||
env: BuildStepEnv;
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<ServeSimPreviewHandle> {
|
||||
const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
|
||||
const websiteArgs = websiteOriginServeSimArgs(env);
|
||||
return await startWebPreviewWithTunnelAsync(ctx, {
|
||||
baseDomain,
|
||||
env,
|
||||
@@ -876,8 +1004,17 @@ export async function startServeSimWithTunnelAsync(
|
||||
timeoutMs,
|
||||
serverName: 'serve-sim',
|
||||
packageSpec: createServeSimPackageSpec(packageVersion),
|
||||
createArgs: (port, turnArgs) =>
|
||||
createServeSimArgs({ port, turnArgs, metricsCorsArgs, packageVersion }),
|
||||
createArgs: (port, turnArgs, previewPageUrl) =>
|
||||
createServeSimArgs({
|
||||
port,
|
||||
turnArgs,
|
||||
websiteArgs,
|
||||
shareUrl: previewPageUrl,
|
||||
packageVersion,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
}),
|
||||
readPreviewTokenAsync: async device => {
|
||||
const previewToken = await readServeSimPreviewTokenAsync(device);
|
||||
if (!previewToken) {
|
||||
@@ -931,6 +1068,9 @@ export async function startDeviceWebPreviewWithTunnelAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{
|
||||
runtimePlatform,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
...options
|
||||
}: {
|
||||
runtimePlatform: BuildRuntimePlatform;
|
||||
@@ -939,24 +1079,39 @@ export async function startDeviceWebPreviewWithTunnelAsync(
|
||||
logger: bunyan;
|
||||
timeoutMs: number;
|
||||
packageVersion?: string;
|
||||
}
|
||||
} & ServeSimLaunchOptions
|
||||
): Promise<DeviceWebPreviewHandle> {
|
||||
switch (runtimePlatform) {
|
||||
case BuildRuntimePlatform.DARWIN:
|
||||
return await startServeSimWithTunnelAsync(ctx, options);
|
||||
return await startServeSimWithTunnelAsync(ctx, {
|
||||
...options,
|
||||
launchAppIdentifier,
|
||||
launchArgs,
|
||||
openUrl,
|
||||
});
|
||||
case BuildRuntimePlatform.LINUX:
|
||||
// Unreachable from the three step functions, which reject a non-Darwin launch while
|
||||
// parsing. Kept because this function is exported and expo-device-hub cannot launch.
|
||||
if (launchAppIdentifier) {
|
||||
throw new UserError(
|
||||
'EAS_LAUNCH_APPLICATION_INVALID_INPUT',
|
||||
`Cannot launch ${launchAppIdentifier}: an application launch runs through serve-sim on an iOS simulator, and this session runs expo-device-hub on ${runtimePlatform}.`
|
||||
);
|
||||
}
|
||||
return await startExpoDeviceHubWithTunnelAsync(ctx, { ...options, runtimePlatform });
|
||||
}
|
||||
}
|
||||
|
||||
export type NgrokTunnelHandle = {
|
||||
url: string;
|
||||
subdomainId: string;
|
||||
stopAsync: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function startNgrokTunnelAsync({
|
||||
port,
|
||||
subdomainPrefix,
|
||||
subdomainId: subdomainIdArg,
|
||||
baseDomain,
|
||||
authtoken,
|
||||
rewriteHostHeader,
|
||||
@@ -964,12 +1119,14 @@ export async function startNgrokTunnelAsync({
|
||||
}: {
|
||||
port: number;
|
||||
subdomainPrefix: string;
|
||||
subdomainId?: string;
|
||||
baseDomain: string;
|
||||
authtoken: string;
|
||||
rewriteHostHeader?: boolean;
|
||||
logger: bunyan;
|
||||
}): Promise<NgrokTunnelHandle> {
|
||||
const domain = `${subdomainPrefix}-${randomBytes(16).toString('hex')}.${baseDomain}`;
|
||||
const subdomainId = subdomainIdArg ?? randomBytes(16).toString('hex');
|
||||
const domain = `${subdomainPrefix}-${subdomainId}.${baseDomain}`;
|
||||
logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
|
||||
// Run the ngrok agent in-process via the SDK; it keeps the session alive until
|
||||
// the process exits, and the step blocks forever to hold it open.
|
||||
@@ -987,6 +1144,7 @@ export async function startNgrokTunnelAsync({
|
||||
let stopped = false;
|
||||
return {
|
||||
url,
|
||||
subdomainId,
|
||||
stopAsync: async () => {
|
||||
if (stopped) {
|
||||
return;
|
||||
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
PackageManager,
|
||||
findPackagerRootDir,
|
||||
getPackageVersionFromPackageJson,
|
||||
resolveConfiguredPackageManager,
|
||||
resolveFallbackPackageManager,
|
||||
resolveOverridePackageManager,
|
||||
resolvePackageAdd,
|
||||
resolvePackageExec,
|
||||
resolvePackageInstall,
|
||||
resolvePackageManager,
|
||||
resolvePackageVersionAsync,
|
||||
shouldUseFrozenLockfile,
|
||||
@@ -117,6 +123,176 @@ describe(resolvePackageManager, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveFallbackPackageManager, () => {
|
||||
it('returns undefined when unset or empty', () => {
|
||||
expect(resolveFallbackPackageManager({})).toBeUndefined();
|
||||
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: '' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns bun when set', () => {
|
||||
expect(resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' })).toBe(
|
||||
PackageManager.BUN
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a UserError on an unsupported value', () => {
|
||||
expect(() => resolveFallbackPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' })).toThrow(
|
||||
errors.UserError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveOverridePackageManager, () => {
|
||||
it('returns undefined when unset or empty', () => {
|
||||
expect(resolveOverridePackageManager({})).toBeUndefined();
|
||||
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: '' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns bun when set', () => {
|
||||
expect(resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bun' })).toBe(
|
||||
PackageManager.BUN
|
||||
);
|
||||
});
|
||||
|
||||
it('throws a UserError on an unsupported value', () => {
|
||||
expect(() => resolveOverridePackageManager({ EAS_OVERRIDE_PACKAGE_MANAGER: 'bunn' })).toThrow(
|
||||
errors.UserError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolveConfiguredPackageManager, () => {
|
||||
it('uses the unset default', () => {
|
||||
expect(resolveConfiguredPackageManager({}, PackageManager.NPM)).toBe(PackageManager.NPM);
|
||||
});
|
||||
|
||||
it('uses EAS_FALLBACK_PACKAGE_MANAGER when no override is set', () => {
|
||||
expect(
|
||||
resolveConfiguredPackageManager({ EAS_FALLBACK_PACKAGE_MANAGER: 'bun' }, PackageManager.NPM)
|
||||
).toBe(PackageManager.BUN);
|
||||
});
|
||||
|
||||
it('prefers EAS_OVERRIDE_PACKAGE_MANAGER over the fallback', () => {
|
||||
expect(
|
||||
resolveConfiguredPackageManager(
|
||||
{
|
||||
EAS_OVERRIDE_PACKAGE_MANAGER: 'npm',
|
||||
EAS_FALLBACK_PACKAGE_MANAGER: 'bun',
|
||||
},
|
||||
PackageManager.BUN
|
||||
)
|
||||
).toBe(PackageManager.NPM);
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageExec, () => {
|
||||
const packageArgs = ['@expo/serve-sim@latest', '--port', '1'];
|
||||
|
||||
it('maps npm to npx --yes', () => {
|
||||
expect(resolvePackageExec(PackageManager.NPM, packageArgs)).toEqual({
|
||||
command: 'npx',
|
||||
args: ['--yes', ...packageArgs],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps bun to bun x', () => {
|
||||
expect(resolvePackageExec(PackageManager.BUN, packageArgs)).toEqual({
|
||||
command: 'bun',
|
||||
args: ['x', ...packageArgs],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps yarn to npx --yes', () => {
|
||||
expect(resolvePackageExec(PackageManager.YARN, packageArgs)).toEqual({
|
||||
command: 'npx',
|
||||
args: ['--yes', ...packageArgs],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps pnpm to pnpm dlx', () => {
|
||||
expect(resolvePackageExec(PackageManager.PNPM, packageArgs)).toEqual({
|
||||
command: 'pnpm',
|
||||
args: ['dlx', ...packageArgs],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageAdd, () => {
|
||||
it('maps npm to npm install --no-audit', () => {
|
||||
expect(resolvePackageAdd(PackageManager.NPM, 'appium@^3')).toEqual({
|
||||
command: 'npm',
|
||||
args: ['install', '--no-audit', 'appium@^3'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps bun to bun add', () => {
|
||||
expect(resolvePackageAdd(PackageManager.BUN, 'agent-device@latest')).toEqual({
|
||||
command: 'bun',
|
||||
args: ['add', 'agent-device@latest'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps yarn to yarn add', () => {
|
||||
expect(resolvePackageAdd(PackageManager.YARN, 'appium@^3')).toEqual({
|
||||
command: 'yarn',
|
||||
args: ['add', 'appium@^3'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps pnpm to pnpm add', () => {
|
||||
expect(resolvePackageAdd(PackageManager.PNPM, 'appium@^3')).toEqual({
|
||||
command: 'pnpm',
|
||||
args: ['add', 'appium@^3'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageInstall, () => {
|
||||
it('maps bun production install', () => {
|
||||
expect(resolvePackageInstall(PackageManager.BUN, { production: true })).toEqual({
|
||||
command: 'bun',
|
||||
args: ['install', '--production'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps npm production install without audit', () => {
|
||||
expect(resolvePackageInstall(PackageManager.NPM, { production: true })).toEqual({
|
||||
command: 'npm',
|
||||
args: ['install', '--no-audit', '--omit=dev'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps yarn and pnpm production installs', () => {
|
||||
expect(resolvePackageInstall(PackageManager.YARN, { production: true })).toEqual({
|
||||
command: 'yarn',
|
||||
args: ['install', '--production'],
|
||||
});
|
||||
expect(resolvePackageInstall(PackageManager.PNPM, { production: true })).toEqual({
|
||||
command: 'pnpm',
|
||||
args: ['install', '--prod'],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps non-production installs', () => {
|
||||
expect(resolvePackageInstall(PackageManager.NPM)).toEqual({
|
||||
command: 'npm',
|
||||
args: ['install', '--no-audit'],
|
||||
});
|
||||
expect(resolvePackageInstall(PackageManager.BUN)).toEqual({
|
||||
command: 'bun',
|
||||
args: ['install'],
|
||||
});
|
||||
expect(resolvePackageInstall(PackageManager.YARN)).toEqual({
|
||||
command: 'yarn',
|
||||
args: ['install'],
|
||||
});
|
||||
expect(resolvePackageInstall(PackageManager.PNPM)).toEqual({
|
||||
command: 'pnpm',
|
||||
args: ['install'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(resolvePackageVersionAsync, () => {
|
||||
const logger = {
|
||||
debug: jest.fn(),
|
||||
|
||||
@@ -30,19 +30,100 @@ export function resolvePackageManager(
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER;
|
||||
if (fallback) {
|
||||
const parsed = z.enum(PackageManager).safeParse(fallback);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
const allowed = Object.values(PackageManager).join(', ');
|
||||
throw new errors.UserError(
|
||||
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER',
|
||||
`Invalid EAS_FALLBACK_PACKAGE_MANAGER value "${fallback}" (expected one of: ${allowed}).`
|
||||
);
|
||||
return resolveFallbackPackageManager(env) ?? PackageManager.YARN;
|
||||
}
|
||||
|
||||
function parsePackageManagerEnvValue(
|
||||
value: string | undefined,
|
||||
envName: string,
|
||||
errorCode: string
|
||||
): PackageManager | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = z.enum(PackageManager).safeParse(value);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
const allowed = Object.values(PackageManager).join(', ');
|
||||
throw new errors.UserError(
|
||||
errorCode,
|
||||
`Invalid ${envName} value "${value}" (expected one of: ${allowed}).`
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveFallbackPackageManager(env: BuildStepEnv): PackageManager | undefined {
|
||||
return parsePackageManagerEnvValue(
|
||||
env.EAS_FALLBACK_PACKAGE_MANAGER,
|
||||
'EAS_FALLBACK_PACKAGE_MANAGER',
|
||||
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER'
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveOverridePackageManager(env: BuildStepEnv): PackageManager | undefined {
|
||||
return parsePackageManagerEnvValue(
|
||||
env.EAS_OVERRIDE_PACKAGE_MANAGER,
|
||||
'EAS_OVERRIDE_PACKAGE_MANAGER',
|
||||
'EAS_INVALID_OVERRIDE_PACKAGE_MANAGER'
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveConfiguredPackageManager(
|
||||
env: BuildStepEnv,
|
||||
whenUnset: PackageManager
|
||||
): PackageManager {
|
||||
return resolveOverridePackageManager(env) ?? resolveFallbackPackageManager(env) ?? whenUnset;
|
||||
}
|
||||
|
||||
export function resolvePackageExec(
|
||||
manager: PackageManager,
|
||||
args: string[]
|
||||
): { command: string; args: string[] } {
|
||||
switch (manager) {
|
||||
case PackageManager.NPM:
|
||||
return { command: 'npx', args: ['--yes', ...args] };
|
||||
case PackageManager.BUN:
|
||||
return { command: 'bun', args: ['x', ...args] };
|
||||
case PackageManager.YARN:
|
||||
return { command: 'npx', args: ['--yes', ...args] };
|
||||
case PackageManager.PNPM:
|
||||
return { command: 'pnpm', args: ['dlx', ...args] };
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackageAdd(
|
||||
manager: PackageManager,
|
||||
packageSpec: string
|
||||
): { command: string; args: string[] } {
|
||||
switch (manager) {
|
||||
case PackageManager.NPM:
|
||||
return { command: 'npm', args: ['install', '--no-audit', packageSpec] };
|
||||
case PackageManager.BUN:
|
||||
return { command: 'bun', args: ['add', packageSpec] };
|
||||
case PackageManager.YARN:
|
||||
return { command: 'yarn', args: ['add', packageSpec] };
|
||||
case PackageManager.PNPM:
|
||||
return { command: 'pnpm', args: ['add', packageSpec] };
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePackageInstall(
|
||||
manager: PackageManager,
|
||||
{ production }: { production?: boolean } = {}
|
||||
): { command: string; args: string[] } {
|
||||
switch (manager) {
|
||||
case PackageManager.NPM:
|
||||
return {
|
||||
command: 'npm',
|
||||
args: production ? ['install', '--no-audit', '--omit=dev'] : ['install', '--no-audit'],
|
||||
};
|
||||
case PackageManager.BUN:
|
||||
return { command: 'bun', args: production ? ['install', '--production'] : ['install'] };
|
||||
case PackageManager.YARN:
|
||||
return { command: 'yarn', args: production ? ['install', '--production'] : ['install'] };
|
||||
case PackageManager.PNPM:
|
||||
return { command: 'pnpm', args: production ? ['install', '--prod'] : ['install'] };
|
||||
}
|
||||
return PackageManager.YARN;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-eas-build-function",
|
||||
"version": "24.5.0",
|
||||
"version": "24.8.0",
|
||||
"description": "Create functions for use in EAS Build custom builds.",
|
||||
"keywords": [
|
||||
"expo",
|
||||
@@ -35,13 +35,13 @@
|
||||
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/steps": "24.5.0"
|
||||
"@expo/steps": "24.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/package-manager": "1.9.10",
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"arg": "^5.0.2",
|
||||
@@ -52,7 +52,7 @@
|
||||
"prompts": "^2.4.2",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript": "^6.0.3",
|
||||
"update-check": "^1.5.4"
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/steps": "^1.0.119",
|
||||
"@types/node": "20.14.11",
|
||||
"@types/node": "20.19.43",
|
||||
"@vercel/ncc": "^0.38.1",
|
||||
"typescript": "^5.5.3"
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"module": "node16",
|
||||
"sourceMap": true,
|
||||
"inlineSources": true,
|
||||
"strict": true,
|
||||
@@ -16,7 +16,7 @@
|
||||
"outDir": "build",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "node16",
|
||||
"rootDir": "src",
|
||||
"declaration": false,
|
||||
"composite": false
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"include": ["src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/downloader",
|
||||
"version": "24.0.0",
|
||||
"version": "24.8.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "BUSL-1.1",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -31,10 +31,10 @@
|
||||
"devDependencies": {
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node": "20.19.43",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.5.4"
|
||||
"ts-jest": "^29.4.12",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
@@ -8,6 +9,7 @@
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"types": ["node", "jest"],
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-build-cache-provider",
|
||||
"version": "24.0.0",
|
||||
"version": "24.8.0",
|
||||
"description": "A build cache provider plugin for the Expo CLI",
|
||||
"homepage": "https://github.com/expo/eas-cli",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
@@ -51,7 +51,7 @@
|
||||
"jest": "29.7.0",
|
||||
"memfs": "3.4.13",
|
||||
"rimraf": "3.0.2",
|
||||
"typescript": "5.5.4"
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"importHelpers": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@expo/eas-build-job",
|
||||
"version": "24.5.0",
|
||||
"version": "24.8.0",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
@@ -25,19 +25,19 @@
|
||||
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/logger": "24.8.0",
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/turtle-spawn": "24.0.0",
|
||||
"@expo/turtle-spawn": "24.8.0",
|
||||
"joi": "^17.13.1",
|
||||
"semver": "^7.6.2",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/node": "20.19.43",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.5.4"
|
||||
"ts-jest": "^29.4.12",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
@@ -8,6 +9,7 @@
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"types": ["node", "jest"],
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
|
||||
+455
-246
File diff suppressed because it is too large.
Load diff
Generated
+991
-14
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eas-cli",
|
||||
"version": "24.6.0",
|
||||
"version": "24.8.0",
|
||||
"description": "EAS command line tool",
|
||||
"keywords": [
|
||||
"cli",
|
||||
@@ -48,11 +48,11 @@
|
||||
"@expo/code-signing-certificates": "0.0.5",
|
||||
"@expo/config": "55.0.10",
|
||||
"@expo/config-plugins": "55.0.7",
|
||||
"@expo/eas-build-job": "24.5.0",
|
||||
"@expo/eas-json": "24.5.0",
|
||||
"@expo/eas-build-job": "24.8.0",
|
||||
"@expo/eas-json": "24.8.0",
|
||||
"@expo/env": "^1.0.0",
|
||||
"@expo/json-file": "8.3.3",
|
||||
"@expo/logger": "24.0.0",
|
||||
"@expo/logger": "24.8.0",
|
||||
"@expo/multipart-body-parser": "2.0.0",
|
||||
"@expo/osascript": "2.1.4",
|
||||
"@expo/package-manager": "1.9.10",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@expo/results": "1.0.0",
|
||||
"@expo/rudder-sdk-node": "1.1.1",
|
||||
"@expo/spawn-async": "1.7.2",
|
||||
"@expo/steps": "24.5.0",
|
||||
"@expo/steps": "24.8.0",
|
||||
"@expo/timeago.js": "1.0.0",
|
||||
"@oclif/core": "^4.8.3",
|
||||
"@oclif/plugin-autocomplete": "^3.2.40",
|
||||
@@ -95,7 +95,7 @@
|
||||
"gradle-to-js": "2.0.1",
|
||||
"graphql": "16.8.1",
|
||||
"graphql-tag": "2.12.6",
|
||||
"https-proxy-agent": "5.0.1",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"ignore": "5.3.0",
|
||||
"indent-string": "4.0.0",
|
||||
"invariant": "^2.2.2",
|
||||
@@ -152,6 +152,7 @@
|
||||
"@types/getenv": "^1.0.0",
|
||||
"@types/invariant": "^2.2.37",
|
||||
"@types/mime": "3.0.1",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/node-fetch": "2.6.12",
|
||||
"@types/node-forge": "1.3.1",
|
||||
"@types/pngjs": "6.0.4",
|
||||
@@ -172,7 +173,7 @@
|
||||
"rimraf": "3.0.2",
|
||||
"ts-mockito": "2.6.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.5.4"
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"oclif": {
|
||||
"additionalHelpFlags": [
|
||||
|
||||
@@ -3,7 +3,7 @@ const path = require('path');
|
||||
|
||||
const LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL =
|
||||
'https://raw.githubusercontent.com/expo/llm-configs/main/expo-app';
|
||||
const AGENT_TEMPLATE_FILE_NAMES = ['AGENTS.md', 'CLAUDE.md'];
|
||||
const AGENT_TEMPLATE_FILE_NAMES = ['AGENTS.md'];
|
||||
|
||||
async function fetchTemplateAsync(fileName) {
|
||||
const url = `${LLM_CONFIGS_EXPO_APP_TEMPLATE_BASE_URL}/${fileName}`;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
jest.unmock('ora');
|
||||
|
||||
import { ora } from '../ora';
|
||||
|
||||
function createFakeTtyStream(columns: number): NodeJS.WriteStream & { clearedLines: number } {
|
||||
return {
|
||||
isTTY: true,
|
||||
columns,
|
||||
clearedLines: 0,
|
||||
write: jest.fn(),
|
||||
moveCursor: jest.fn(),
|
||||
cursorTo: jest.fn(),
|
||||
clearLine(this: { clearedLines: number }) {
|
||||
this.clearedLines++;
|
||||
},
|
||||
} as unknown as NodeJS.WriteStream & { clearedLines: number };
|
||||
}
|
||||
|
||||
describe(ora, () => {
|
||||
it('clears all wrapped lines after the terminal gets narrower', () => {
|
||||
const stream = createFakeTtyStream(200);
|
||||
const spinner = ora({ text: 'x'.repeat(100), stream, isEnabled: true });
|
||||
|
||||
spinner.render();
|
||||
spinner.render();
|
||||
expect(stream.clearedLines).toBe(1);
|
||||
|
||||
stream.columns = 40;
|
||||
// The frame on screen was written at the old width, so it is cleared with the old line count.
|
||||
spinner.render();
|
||||
stream.clearedLines = 0;
|
||||
spinner.render();
|
||||
// "- " + 100 characters wraps to 3 lines at 40 columns.
|
||||
expect(stream.clearedLines).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -87,8 +87,7 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
buildProfile.developmentClient ??
|
||||
(platform === Platform.ANDROID
|
||||
? (buildProfile as BuildProfile<Platform.ANDROID>)?.gradleCommand === ':app:assembleDebug'
|
||||
: (buildProfile as BuildProfile<Platform.IOS>)?.buildConfiguration === 'Debug') ??
|
||||
false;
|
||||
: (buildProfile as BuildProfile<Platform.IOS>)?.buildConfiguration === 'Debug');
|
||||
|
||||
const requiredPackageManager = resolvePackageManager(projectDir);
|
||||
|
||||
|
||||
@@ -504,7 +504,10 @@ async function prepareAndStartBuildAsync({
|
||||
if (easJsonCliConfig?.appVersionSource === AppVersionSource.REMOTE) {
|
||||
validateAppConfigForRemoteVersionSource(buildCtx.exp, buildProfile.platform);
|
||||
}
|
||||
if (buildCtx.workflow === Workflow.MANAGED) {
|
||||
// Custom builds run the steps from the profile's config file instead of the
|
||||
// standard managed pipeline, so the managed-workflow checks do not apply.
|
||||
const isCustomBuild = !!buildProfile.profile.config;
|
||||
if (buildCtx.workflow === Workflow.MANAGED && !isCustomBuild) {
|
||||
if (!sdkVersionChecked) {
|
||||
await checkExpoSdkIsSupportedAsync(buildCtx);
|
||||
sdkVersionChecked = true;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
@AGENTS.md
|
||||
@@ -1,12 +1,19 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import {
|
||||
SupabaseAdvisorLintData,
|
||||
SupabaseAdvisorLintLevel,
|
||||
SupabaseAdvisorType,
|
||||
SupabaseConnectionData,
|
||||
SupabaseOrganizationData,
|
||||
SupabaseProjectData,
|
||||
} from '../graphql/types/SupabaseConnection';
|
||||
import Log, { link } from '../log';
|
||||
|
||||
export const SUPABASE_REAUTHORIZATION_REQUIRED_ERROR_CODE =
|
||||
'SUPABASE_REAUTHORIZATION_REQUIRED_ERROR';
|
||||
|
||||
export function getSupabaseProjectDashboardUrl(
|
||||
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>
|
||||
): string {
|
||||
@@ -94,3 +101,72 @@ export function formatSupabaseProject(project: SupabaseProjectData): string {
|
||||
export function logNoSupabaseProject(projectName: string): void {
|
||||
Log.warn(`No Supabase project is linked to Expo app ${chalk.bold(projectName)} on EAS.`);
|
||||
}
|
||||
|
||||
export function getSupabaseAdvisorsDashboardUrl(
|
||||
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>,
|
||||
type: SupabaseAdvisorType
|
||||
): string {
|
||||
return `${getSupabaseProjectDashboardUrl(project)}/advisors/${type.toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function isSupabaseReauthorizationRequiredError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof CombinedError &&
|
||||
error.graphQLErrors.some(
|
||||
graphQLError =>
|
||||
graphQLError.extensions?.errorCode === SUPABASE_REAUTHORIZATION_REQUIRED_ERROR_CODE
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const ADVISOR_LINT_LEVEL_LABELS: Record<
|
||||
SupabaseAdvisorLintLevel,
|
||||
[singular: string, plural: string]
|
||||
> = {
|
||||
[SupabaseAdvisorLintLevel.Error]: ['error', 'errors'],
|
||||
[SupabaseAdvisorLintLevel.Warn]: ['warning', 'warnings'],
|
||||
[SupabaseAdvisorLintLevel.Info]: ['info', 'info'],
|
||||
};
|
||||
|
||||
const ADVISOR_LINT_LEVEL_MARKERS: Record<SupabaseAdvisorLintLevel, string> = {
|
||||
[SupabaseAdvisorLintLevel.Error]: chalk.red('✖'),
|
||||
[SupabaseAdvisorLintLevel.Warn]: chalk.yellow('▲'),
|
||||
[SupabaseAdvisorLintLevel.Info]: chalk.blue('●'),
|
||||
};
|
||||
|
||||
export function summarizeSupabaseAdvisorLints(lints: readonly SupabaseAdvisorLintData[]): string {
|
||||
if (lints.length === 0) {
|
||||
return 'No unresolved findings';
|
||||
}
|
||||
return (Object.keys(ADVISOR_LINT_LEVEL_LABELS) as SupabaseAdvisorLintLevel[])
|
||||
.map(level => [level, lints.filter(lint => lint.level === level).length] as const)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([level, count]) => `${count} ${ADVISOR_LINT_LEVEL_LABELS[level][count === 1 ? 0 : 1]}`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function formatInlineCode(text: string): string {
|
||||
return text
|
||||
.replaceAll('\\`', '`')
|
||||
.split('`')
|
||||
.map((part, index) => (index % 2 === 1 ? chalk.cyan(part) : part))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function formatSupabaseAdvisorLints(
|
||||
project: Pick<SupabaseProjectData, 'supabaseProjectRef'>,
|
||||
type: SupabaseAdvisorType,
|
||||
lints: readonly SupabaseAdvisorLintData[]
|
||||
): string {
|
||||
const heading = `${chalk.bold(type === SupabaseAdvisorType.Security ? 'Security' : 'Performance')} · ${summarizeSupabaseAdvisorLints(lints)}`;
|
||||
const dashboardUrl = getSupabaseAdvisorsDashboardUrl(project, type);
|
||||
const rows = lints.map(lint =>
|
||||
[
|
||||
` ${ADVISOR_LINT_LEVEL_MARKERS[lint.level]} ${lint.level === SupabaseAdvisorLintLevel.Warn ? 'WARNING' : lint.level} ${chalk.bold(lint.title)}`,
|
||||
...(lint.entity ? [` ${chalk.dim(lint.entity)}`] : []),
|
||||
` ${formatInlineCode(lint.detail)}`,
|
||||
` ${link(`${dashboardUrl}?id=${encodeURIComponent(lint.cacheKey)}`, { text: chalk.underline('View in Supabase ↗'), dim: false })}`,
|
||||
].join('\n')
|
||||
);
|
||||
return [heading, ...rows].join('\n\n');
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { validateWorkflowStructure } from '../validation';
|
||||
|
||||
const workflowSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
jobs: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', const: 'slack' },
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
webhook_url: { type: 'string', format: 'uri' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
required: ['webhook_url', 'message'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
required: ['type', 'params'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['jobs'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const workflowWithWebhookUrl = (webhookUrl: string): object => ({
|
||||
jobs: {
|
||||
notify: {
|
||||
type: 'slack',
|
||||
params: {
|
||||
webhook_url: webhookUrl,
|
||||
message: 'Build finished',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe(validateWorkflowStructure, () => {
|
||||
it('allows interpolated values for URI fields', () => {
|
||||
expect(() => {
|
||||
validateWorkflowStructure(
|
||||
workflowWithWebhookUrl('${{ env.SLACK_WEBHOOK_URL }}'),
|
||||
workflowSchema
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('still rejects invalid literal values for URI fields', () => {
|
||||
expect(() => {
|
||||
validateWorkflowStructure(workflowWithWebhookUrl('not a URL'), workflowSchema);
|
||||
}).toThrow('must be a valid URI string');
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { InvalidEasJsonError, MissingEasJsonError } from '@expo/eas-json/build/e
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import addFormats from 'ajv-formats';
|
||||
import * as YAML from 'yaml';
|
||||
|
||||
import { validateWorkflowLocalCompositeFunctionsAsync } from './compositeFunctions';
|
||||
@@ -15,9 +16,10 @@ import { ExpoGraphqlClient } from '../context/contextUtils/createGraphqlClient';
|
||||
import { parsedYamlFromWorkflowContents } from './parse';
|
||||
|
||||
const jobTypesWithBuildProfile = new Set(['build', 'repack']);
|
||||
const validateUri = addFormats.get('uri') as (value: string) => boolean;
|
||||
|
||||
const buildProfileIsInterpolated = (profileName: string): boolean => {
|
||||
return profileName.includes('${{') && profileName.includes('}}');
|
||||
const stringIsInterpolated = (value: string): boolean => {
|
||||
return value.includes('${{') && value.includes('}}');
|
||||
};
|
||||
|
||||
export async function validateWorkflowFileAsync(
|
||||
@@ -113,7 +115,7 @@ async function validateWorkflowBuildJobsAsync(parsedYaml: any, projectDir: strin
|
||||
job =>
|
||||
!buildProfileNames.has(job.value.params.profile) &&
|
||||
// If a profile name is interpolated, we can't check if it's valid until the workflow actually runs
|
||||
!buildProfileIsInterpolated(job.value.params.profile)
|
||||
!stringIsInterpolated(job.value.params.profile)
|
||||
);
|
||||
|
||||
if (invalidBuildJobs.length > 0) {
|
||||
@@ -139,10 +141,12 @@ function validateWorkflowJobTypes(parsedYaml: any, workflowJsonSchema: any): voi
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): void {
|
||||
export function validateWorkflowStructure(parsedYaml: any, workflowJsonSchema: any): void {
|
||||
delete workflowJsonSchema['$schema'];
|
||||
|
||||
const ajv = createValidator();
|
||||
// Interpolated values cannot be format-checked until the workflow runs.
|
||||
ajv.addFormat('uri', value => stringIsInterpolated(value) || validateUri(value));
|
||||
const validate = ajv.compile(workflowJsonSchema);
|
||||
const result = validate(parsedYaml);
|
||||
|
||||
|
||||
@@ -62,12 +62,24 @@ export default class AccountView extends EasCommand {
|
||||
return 'Admin';
|
||||
case Role.Developer:
|
||||
return 'Developer';
|
||||
case Role.ReleaseManager:
|
||||
return 'Release Manager';
|
||||
case Role.ViewOnly:
|
||||
return 'Viewer';
|
||||
case Role.Custom:
|
||||
case Role.HasAdmin:
|
||||
case Role.NotAdmin:
|
||||
return 'Custom';
|
||||
// A role added to the API after this version of the CLI was published. TypeScript narrows
|
||||
// `role` to `never` here, but the server can still send one, so render its name rather than
|
||||
// returning undefined.
|
||||
default: {
|
||||
const unknownRole: string = role;
|
||||
return unknownRole
|
||||
.split('_')
|
||||
.map(word => word.charAt(0) + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,7 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
@@ -139,6 +140,7 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'eas-key-uuid',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
@@ -207,6 +209,7 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
@@ -234,6 +237,7 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'eas-key-uuid',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
@@ -255,6 +259,35 @@ describe(IntegrationsAscConnect, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('fails when --api-key-id points at an individual key', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
.mockResolvedValueOnce(mockMetadataDisconnected);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getAllForAccountAsync).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: null,
|
||||
},
|
||||
] as any);
|
||||
|
||||
const command = new IntegrationsAscConnect(
|
||||
['--api-key-id', 'FAKEKEY000', '--asc-app-id', '9876543210', '--non-interactive'],
|
||||
mockConfig
|
||||
);
|
||||
// @ts-expect-error
|
||||
jest.spyOn(command, 'getContextAsync').mockReturnValue({
|
||||
projectId: testProjectId,
|
||||
projectDir: '/test/project',
|
||||
analytics,
|
||||
vcsClient,
|
||||
loggedIn: { graphqlClient, actor },
|
||||
});
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow('is an individual key');
|
||||
expect(AscAppLinkQuery.discoverAccessibleAppsAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails when multiple keys match Apple key identifier', async () => {
|
||||
jest
|
||||
.mocked(AscAppLinkQuery.getAppMetadataAsync)
|
||||
@@ -263,10 +296,12 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'eas-key-uuid-1',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
{
|
||||
id: 'eas-key-uuid-2',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
@@ -297,6 +332,7 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
@@ -329,6 +365,7 @@ describe(IntegrationsAscConnect, () => {
|
||||
{
|
||||
id: 'key-id',
|
||||
keyIdentifier: 'FAKEKEY000',
|
||||
issuerIdentifier: 'FAKEISSUER',
|
||||
},
|
||||
] as any);
|
||||
|
||||
|
||||
@@ -113,6 +113,11 @@ export default class IntegrationsAscConnect extends EasCommand {
|
||||
`Multiple App Store Connect API keys match Apple key identifier "${apiKeyId}".`
|
||||
);
|
||||
} else if (keysByAppleId.length === 1) {
|
||||
if (!keysByAppleId[0].issuerIdentifier) {
|
||||
throw new EasCommandError(
|
||||
`App Store Connect API key "${apiKeyId}" is an individual key (it has no Issuer ID). Individual keys are not supported for the App Store Connect connection. Use a team API key.`
|
||||
);
|
||||
}
|
||||
apiKeyId = keysByAppleId[0].id;
|
||||
} else {
|
||||
throw new EasCommandError(
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { CombinedError } from '@urql/core';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
|
||||
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../../../../commandUtils/errors';
|
||||
import { testProjectId } from '../../../../credentials/__tests__/fixtures-constants';
|
||||
import { SupabaseMutation } from '../../../../graphql/mutations/SupabaseMutation';
|
||||
import { SupabaseQuery } from '../../../../graphql/queries/SupabaseQuery';
|
||||
import {
|
||||
SupabaseAdvisorLintData,
|
||||
SupabaseAdvisorLintLevel,
|
||||
SupabaseAdvisorLintsData,
|
||||
SupabaseAdvisorType,
|
||||
} from '../../../../graphql/types/SupabaseConnection';
|
||||
import { authorizeViaBrowserAsync } from '../../../../integrations/supabase/provision';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../../project/projectUtils';
|
||||
import Log from '../../../../log';
|
||||
import { ora } from '../../../../ora';
|
||||
import { confirmAsync } from '../../../../prompts';
|
||||
import { printJsonOnlyOutput } from '../../../../utils/json';
|
||||
import IntegrationsSupabaseAdvisors from '../advisors';
|
||||
|
||||
jest.mock('supports-hyperlinks', () => ({ stdout: false, stderr: false }));
|
||||
jest.mock('../../../../graphql/queries/SupabaseQuery');
|
||||
jest.mock('../../../../graphql/mutations/SupabaseMutation');
|
||||
jest.mock('../../../../log', () => {
|
||||
const actual = jest.requireActual('../../../../log');
|
||||
return {
|
||||
__esModule: true,
|
||||
...actual,
|
||||
default: { ...actual.default, log: jest.fn(), warn: jest.fn(), newLine: jest.fn() },
|
||||
};
|
||||
});
|
||||
jest.mock('../../../../utils/json');
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('../../../../integrations/supabase/provision');
|
||||
jest.mock('../../../../project/projectUtils');
|
||||
jest.mock('../../../../ora', () => ({
|
||||
ora: jest.fn(() => ({
|
||||
start: jest.fn().mockReturnThis(),
|
||||
succeed: jest.fn().mockReturnThis(),
|
||||
fail: jest.fn().mockReturnThis(),
|
||||
stop: jest.fn().mockReturnThis(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe(IntegrationsSupabaseAdvisors, () => {
|
||||
const graphqlClient = {} as ExpoGraphqlClient;
|
||||
const mockConfig = getMockOclifConfig();
|
||||
const runCommand = jest.fn();
|
||||
Object.assign(mockConfig, { runCommand });
|
||||
|
||||
const rlsLint: SupabaseAdvisorLintData = {
|
||||
name: 'rls_disabled_in_public',
|
||||
title: 'RLS Disabled in Public',
|
||||
level: SupabaseAdvisorLintLevel.Error,
|
||||
description: 'Detects cases where row level security (RLS) has not been enabled on a table.',
|
||||
detail: 'Table \\`public.todos\\` is public, but RLS has not been enabled.',
|
||||
entity: 'public.todos',
|
||||
remediation: 'https://supabase.com/docs/guides/database/database-linter?lint=0013',
|
||||
cacheKey: 'rls_disabled_in_public_public_todos',
|
||||
};
|
||||
const unindexedForeignKeyLint: SupabaseAdvisorLintData = {
|
||||
name: 'unindexed_foreign_keys',
|
||||
title: 'Unindexed foreign keys',
|
||||
level: SupabaseAdvisorLintLevel.Info,
|
||||
description: 'Identifies foreign key constraints without a covering index.',
|
||||
detail: 'Table `public.todos` has a foreign key without a covering index.',
|
||||
entity: 'public.todos',
|
||||
remediation: null,
|
||||
cacheKey: 'unindexed_foreign_keys_public_todos',
|
||||
};
|
||||
const mockResult: SupabaseAdvisorLintsData = {
|
||||
project: {
|
||||
id: 'project-1',
|
||||
supabaseProjectRef: 'abcdefghijklmnop',
|
||||
supabaseProjectName: 'Test App',
|
||||
supabaseProjectUrl: 'https://abcdefghijklmnop.supabase.co',
|
||||
supabaseRegion: 'us-east-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
security: [rlsLint],
|
||||
performance: [unindexedForeignKeyLint],
|
||||
};
|
||||
|
||||
function createCommand(argv: string[]): IntegrationsSupabaseAdvisors {
|
||||
const command = new IntegrationsSupabaseAdvisors(argv, mockConfig);
|
||||
jest.spyOn(command as any, 'getContextAsync').mockReturnValue({
|
||||
privateProjectConfig: {
|
||||
projectId: testProjectId,
|
||||
exp: { slug: 'testapp' },
|
||||
},
|
||||
loggedIn: { graphqlClient },
|
||||
} as never);
|
||||
return command;
|
||||
}
|
||||
|
||||
function loggedOutput(): string {
|
||||
return jest
|
||||
.mocked(Log.log)
|
||||
.mock.calls.map(([line]) => stripAnsi(String(line)))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.requireMock('supports-hyperlinks').stdout = false;
|
||||
jest
|
||||
.mocked(authorizeViaBrowserAsync)
|
||||
.mockResolvedValue({ supabaseOrganizationSlug: 'original-org' } as never);
|
||||
jest
|
||||
.mocked(getOwnerAccountForProjectIdAsync)
|
||||
.mockResolvedValue({ id: 'account-1', name: 'test' } as never);
|
||||
jest.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync).mockResolvedValue({
|
||||
updatedAt: '2024-01-01',
|
||||
supabaseOrganizationSlug: 'original-org',
|
||||
} as never);
|
||||
jest.spyOn(Log, 'log').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(Log, 'newLine').mockImplementation(() => {});
|
||||
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(mockResult.project);
|
||||
jest.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).mockResolvedValue(mockResult);
|
||||
});
|
||||
|
||||
function mockReauthorizationRequiredOnce(): void {
|
||||
jest.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).mockRejectedValueOnce(
|
||||
new CombinedError({
|
||||
graphQLErrors: [
|
||||
new GraphQLError('Expo is not allowed to read this Supabase project’s advisors.', {
|
||||
extensions: { errorCode: 'SUPABASE_REAUTHORIZATION_REQUIRED_ERROR', errorType: 'USER' },
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
it('prints both advisors with severity summaries, details, and dashboard links', async () => {
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
testProjectId,
|
||||
[SupabaseAdvisorType.Security, SupabaseAdvisorType.Performance]
|
||||
);
|
||||
const output = loggedOutput();
|
||||
expect(output).toContain('Security · 1 error');
|
||||
expect(output).toContain('View in Supabase ↗: https://supabase.com/dashboard/project/');
|
||||
expect(output).not.toContain('How to fix');
|
||||
expect(output).toContain('Table public.todos is public, but RLS has not been enabled.');
|
||||
expect(output).not.toContain(rlsLint.remediation);
|
||||
expect(output).toContain(
|
||||
'https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/security?id=rls_disabled_in_public_public_todos'
|
||||
);
|
||||
expect(output).toContain('Performance · 1 info');
|
||||
expect(output).toContain(
|
||||
'https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/performance'
|
||||
);
|
||||
});
|
||||
|
||||
it('links below issue descriptions without visible URLs when hyperlinks are supported', async () => {
|
||||
jest.requireMock('supports-hyperlinks').stdout = true;
|
||||
await createCommand([]).runAsync();
|
||||
const rawOutput = jest
|
||||
.mocked(Log.log)
|
||||
.mock.calls.map(([line]) => String(line))
|
||||
.join('\n');
|
||||
const output = loggedOutput();
|
||||
expect(rawOutput).toContain(
|
||||
']8;;https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/security?id=rls_disabled_in_public_public_todos'
|
||||
);
|
||||
expect(rawOutput).not.toContain(rlsLint.remediation);
|
||||
expect(output).toContain('✖ ERROR RLS Disabled in Public\n public.todos');
|
||||
expect(output).not.toContain('How to fix');
|
||||
expect(output).toContain(
|
||||
'✖ ERROR RLS Disabled in Public\n public.todos\n Table public.todos is public, but RLS has not been enabled.\n View in Supabase ↗'
|
||||
);
|
||||
expect(output).not.toContain('RLS Disabled in Public ↗');
|
||||
expect(output.split('\n').filter(line => line.includes('https://'))).toEqual([
|
||||
'Dashboard: https://supabase.com/dashboard/project/abcdefghijklmnop',
|
||||
]);
|
||||
expect(output).toMatch(
|
||||
/^Dashboard: https:\/\/supabase.com\/dashboard\/project\/abcdefghijklmnop\nSecurity/
|
||||
);
|
||||
});
|
||||
|
||||
it('separates multiple findings and keeps clean sections compact', async () => {
|
||||
jest.requireMock('supports-hyperlinks').stdout = true;
|
||||
jest.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).mockResolvedValue({
|
||||
...mockResult,
|
||||
security: [rlsLint, { ...unindexedForeignKeyLint, level: SupabaseAdvisorLintLevel.Warn }],
|
||||
performance: [],
|
||||
});
|
||||
await createCommand([]).runAsync();
|
||||
const output = loggedOutput();
|
||||
expect(output).toContain('Security · 1 error, 1 warning');
|
||||
expect(output).toContain('View in Supabase ↗\n\n ▲ WARNING Unindexed foreign keys');
|
||||
expect(output).toContain('Performance · No unresolved findings');
|
||||
expect(output).toMatch(
|
||||
/^Dashboard: https:\/\/supabase.com\/dashboard\/project\/abcdefghijklmnop\nSecurity/
|
||||
);
|
||||
});
|
||||
|
||||
it('limits the output to one advisor with --type', async () => {
|
||||
await createCommand(['--type', 'security']).runAsync();
|
||||
|
||||
const output = loggedOutput();
|
||||
expect(output).toContain('Security · 1 error');
|
||||
expect(output).not.toContain('Performance');
|
||||
expect(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
testProjectId,
|
||||
[SupabaseAdvisorType.Security]
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['--json', '--non-interactive'])('does not start a spinner with %s', async flag => {
|
||||
await createCommand([flag]).runAsync();
|
||||
expect(ora).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unsupported advisor type before making requests', async () => {
|
||||
await expect(createCommand(['--type', 'other']).runAsync()).rejects.toThrow();
|
||||
expect(SupabaseQuery.getSupabaseProjectByAppIdAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints structured findings with --json', async () => {
|
||||
await createCommand(['--json']).runAsync();
|
||||
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
project: {
|
||||
ref: 'abcdefghijklmnop',
|
||||
name: 'Test App',
|
||||
dashboardUrls: {
|
||||
security: 'https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/security',
|
||||
performance:
|
||||
'https://supabase.com/dashboard/project/abcdefghijklmnop/advisors/performance',
|
||||
},
|
||||
},
|
||||
security: [rlsLint],
|
||||
performance: [unindexedForeignKeyLint],
|
||||
});
|
||||
expect(Log.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('warns when no Supabase project is linked', async () => {
|
||||
jest.mocked(SupabaseQuery.getSupabaseProjectByAppIdAsync).mockResolvedValue(null);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('No Supabase project'));
|
||||
});
|
||||
|
||||
it('offers to re-authorize, refreshes OAuth in place, and retries when accepted', async () => {
|
||||
mockReauthorizationRequiredOnce();
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
|
||||
await createCommand([]).runAsync();
|
||||
|
||||
expect(confirmAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining('Re-authorize Supabase') })
|
||||
);
|
||||
expect(authorizeViaBrowserAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{ id: 'account-1', name: 'test' },
|
||||
false,
|
||||
'2024-01-01'
|
||||
);
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
|
||||
expect(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).toHaveBeenCalledTimes(2);
|
||||
expect(loggedOutput()).toContain('Security · 1 error');
|
||||
});
|
||||
|
||||
it('preserves the selected organization when OAuth defaults to a different one', async () => {
|
||||
mockReauthorizationRequiredOnce();
|
||||
jest.mocked(confirmAsync).mockResolvedValue(true);
|
||||
jest
|
||||
.mocked(authorizeViaBrowserAsync)
|
||||
.mockResolvedValue({ id: 'connection-1', supabaseOrganizationSlug: 'other-org' } as never);
|
||||
await createCommand([]).runAsync();
|
||||
expect(SupabaseMutation.setSupabaseConnectionOrganizationAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
{
|
||||
supabaseConnectionId: 'connection-1',
|
||||
organizationSlug: 'original-org',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and returns when the user declines to re-authorize', async () => {
|
||||
mockReauthorizationRequiredOnce();
|
||||
jest.mocked(confirmAsync).mockResolvedValue(false);
|
||||
|
||||
await expect(createCommand([]).runAsync()).resolves.toBeUndefined();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('eas integrations:supabase:advisors')
|
||||
);
|
||||
expect(authorizeViaBrowserAsync).not.toHaveBeenCalled();
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails with the reauth command in non-interactive mode', async () => {
|
||||
mockReauthorizationRequiredOnce();
|
||||
|
||||
await expect(createCommand(['--non-interactive']).runAsync()).rejects.toThrow(EasCommandError);
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
it('distinguishes unavailable advisors from clean results', async () => {
|
||||
jest
|
||||
.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync)
|
||||
.mockResolvedValue({ ...mockResult, security: null, performance: [] });
|
||||
await createCommand([]).runAsync();
|
||||
expect(Log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Security advisors are unavailable')
|
||||
);
|
||||
expect(loggedOutput()).toContain('Performance · No unresolved findings');
|
||||
expect(loggedOutput()).not.toContain('Security · No unresolved findings');
|
||||
});
|
||||
|
||||
it('does not reauthorize when the project is unlinked during the request', async () => {
|
||||
jest.mocked(SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync).mockResolvedValue(null);
|
||||
await createCommand(['--json']).runAsync();
|
||||
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
|
||||
project: null,
|
||||
security: null,
|
||||
performance: null,
|
||||
});
|
||||
expect(confirmAsync).not.toHaveBeenCalled();
|
||||
expect(authorizeViaBrowserAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Flags } from '@oclif/core';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import EasCommand from '../../../commandUtils/EasCommand';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { EasCommandError } from '../../../commandUtils/errors';
|
||||
import {
|
||||
EasNonInteractiveAndJsonFlags,
|
||||
resolveNonInteractiveAndJsonFlags,
|
||||
} from '../../../commandUtils/flags';
|
||||
import {
|
||||
formatSupabaseAdvisorLints,
|
||||
formatSupabaseProjectLabel,
|
||||
getSupabaseAdvisorsDashboardUrl,
|
||||
getSupabaseProjectDashboardUrl,
|
||||
isSupabaseReauthorizationRequiredError,
|
||||
logNoSupabaseProject,
|
||||
} from '../../../commandUtils/supabase';
|
||||
import { SupabaseMutation } from '../../../graphql/mutations/SupabaseMutation';
|
||||
import { SupabaseQuery } from '../../../graphql/queries/SupabaseQuery';
|
||||
import {
|
||||
SupabaseAdvisorLintsData,
|
||||
SupabaseAdvisorType,
|
||||
} from '../../../graphql/types/SupabaseConnection';
|
||||
import { authorizeViaBrowserAsync } from '../../../integrations/supabase/provision';
|
||||
import Log, { link } from '../../../log';
|
||||
import { ora } from '../../../ora';
|
||||
import { getOwnerAccountForProjectIdAsync } from '../../../project/projectUtils';
|
||||
import { confirmAsync } from '../../../prompts';
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
|
||||
|
||||
const ADVISOR_TYPES = [SupabaseAdvisorType.Security, SupabaseAdvisorType.Performance];
|
||||
const REAUTH_COMMAND = 'eas integrations:supabase:advisors';
|
||||
|
||||
export default class IntegrationsSupabaseAdvisors extends EasCommand {
|
||||
static override description =
|
||||
"list unresolved findings from the Supabase Security and Performance Advisors for this app's primary Supabase project";
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> <%= command.id %>',
|
||||
'<%= config.bin %> <%= command.id %> --type security',
|
||||
'<%= config.bin %> <%= command.id %> --json --non-interactive',
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
type: Flags.option({
|
||||
description: 'Only show one advisor',
|
||||
options: ['security', 'performance'] as const,
|
||||
})(),
|
||||
...EasNonInteractiveAndJsonFlags,
|
||||
};
|
||||
|
||||
static override contextDefinition = {
|
||||
...this.ContextOptions.ProjectConfig,
|
||||
};
|
||||
|
||||
async runAsync(): Promise<void> {
|
||||
const { flags } = await this.parse(IntegrationsSupabaseAdvisors);
|
||||
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
|
||||
if (jsonFlag) {
|
||||
enableJsonOutput();
|
||||
}
|
||||
const types =
|
||||
flags.type === 'security'
|
||||
? [SupabaseAdvisorType.Security]
|
||||
: flags.type === 'performance'
|
||||
? [SupabaseAdvisorType.Performance]
|
||||
: ADVISOR_TYPES;
|
||||
|
||||
const {
|
||||
privateProjectConfig: { projectId, exp },
|
||||
loggedIn: { graphqlClient },
|
||||
} = await this.getContextAsync(IntegrationsSupabaseAdvisors, {
|
||||
nonInteractive,
|
||||
withServerSideEnvironment: null,
|
||||
});
|
||||
|
||||
const project = await SupabaseQuery.getSupabaseProjectByAppIdAsync(graphqlClient, projectId);
|
||||
if (!project) {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ project: null, security: null, performance: null });
|
||||
} else {
|
||||
logNoSupabaseProject(exp.slug);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let result = await this.fetchLintsAsync(graphqlClient, projectId, types, nonInteractive);
|
||||
if (result === undefined) {
|
||||
if (nonInteractive) {
|
||||
throw new EasCommandError(
|
||||
`Expo cannot read this project's advisors until the Supabase connection grants the database read permission. Run ${chalk.bold(REAUTH_COMMAND)} interactively to re-authorize.`
|
||||
);
|
||||
}
|
||||
Log.warn(
|
||||
'Expo cannot read the Supabase advisors until the Supabase connection is re-authorized with the database read permission.'
|
||||
);
|
||||
const confirmed = await confirmAsync({
|
||||
message: 'Re-authorize Supabase in your browser now?',
|
||||
});
|
||||
if (!confirmed) {
|
||||
Log.warn(`Run ${chalk.bold(REAUTH_COMMAND)} when you are ready to re-authorize.`);
|
||||
return;
|
||||
}
|
||||
const account = await getOwnerAccountForProjectIdAsync(graphqlClient, projectId);
|
||||
const connection = await SupabaseQuery.getSupabaseConnectionByAccountIdAsync(
|
||||
graphqlClient,
|
||||
account.id,
|
||||
{ useCache: false }
|
||||
);
|
||||
const authorized = await authorizeViaBrowserAsync(
|
||||
graphqlClient,
|
||||
account,
|
||||
false,
|
||||
connection?.updatedAt
|
||||
);
|
||||
if (
|
||||
connection &&
|
||||
authorized.supabaseOrganizationSlug !== connection.supabaseOrganizationSlug
|
||||
) {
|
||||
await SupabaseMutation.setSupabaseConnectionOrganizationAsync(graphqlClient, {
|
||||
supabaseConnectionId: authorized.id,
|
||||
organizationSlug: connection.supabaseOrganizationSlug,
|
||||
});
|
||||
}
|
||||
Log.newLine();
|
||||
result = await this.fetchLintsAsync(graphqlClient, projectId, types, nonInteractive);
|
||||
if (result === undefined) {
|
||||
throw new EasCommandError(
|
||||
`Supabase still denies access to the advisors after re-authorizing. Check that the Expo integration is allowed to read the database for ${chalk.bold(formatSupabaseProjectLabel(project))}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (result === null) {
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({ project: null, security: null, performance: null });
|
||||
} else {
|
||||
logNoSupabaseProject(exp.slug);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (jsonFlag) {
|
||||
printJsonOnlyOutput({
|
||||
project: {
|
||||
ref: result.project.supabaseProjectRef,
|
||||
name: result.project.supabaseProjectName,
|
||||
dashboardUrls: Object.fromEntries(
|
||||
types.map(type => [
|
||||
type.toLowerCase(),
|
||||
getSupabaseAdvisorsDashboardUrl(result.project, type),
|
||||
])
|
||||
),
|
||||
},
|
||||
security: types.includes(SupabaseAdvisorType.Security) ? result.security : undefined,
|
||||
performance: types.includes(SupabaseAdvisorType.Performance)
|
||||
? result.performance
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Log.newLine();
|
||||
Log.log(
|
||||
`${chalk.bold('Dashboard')}: ${link(getSupabaseProjectDashboardUrl(result.project), { dim: false })}`
|
||||
);
|
||||
|
||||
for (const type of types) {
|
||||
const lints = type === SupabaseAdvisorType.Security ? result.security : result.performance;
|
||||
Log.newLine();
|
||||
if (lints === null) {
|
||||
Log.warn(
|
||||
`${type === SupabaseAdvisorType.Security ? 'Security' : 'Performance'} advisors are unavailable. Try again or open ${getSupabaseAdvisorsDashboardUrl(result.project, type)}.`
|
||||
);
|
||||
} else {
|
||||
Log.log(formatSupabaseAdvisorLints(result.project, type, lints));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchLintsAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
projectId: string,
|
||||
types: SupabaseAdvisorType[],
|
||||
nonInteractive: boolean
|
||||
): Promise<SupabaseAdvisorLintsData | null | undefined> {
|
||||
const spinner = nonInteractive ? null : ora('Fetching Supabase advisor findings').start();
|
||||
try {
|
||||
const result = await SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync(
|
||||
graphqlClient,
|
||||
projectId,
|
||||
types
|
||||
);
|
||||
if (result) {
|
||||
spinner?.succeed(
|
||||
`Fetched Supabase advisor findings for ${formatSupabaseProjectLabel(result.project)}`
|
||||
);
|
||||
} else {
|
||||
spinner?.stop();
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isSupabaseReauthorizationRequiredError(error)) {
|
||||
spinner?.fail('Supabase needs to be re-authorized');
|
||||
return undefined;
|
||||
}
|
||||
spinner?.fail('Failed to fetch Supabase advisor findings');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
resetSimulatorEnvAsync,
|
||||
} from '../../../simulator/env';
|
||||
import { resolveExpoGoSdkVersionAsync } from '../../../simulator/expoGo';
|
||||
import * as promiseUtils from '../../../utils/promise';
|
||||
import Simulator from '../index';
|
||||
|
||||
jest.mock('fs-extra');
|
||||
@@ -617,6 +618,40 @@ describe(Simulator, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --build-fingerprint to the create mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--build-fingerprint',
|
||||
' 4b1f2c9e7a3d ',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ buildFingerprint: '4b1f2c9e7a3d' })
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts launch options with --build-fingerprint', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--build-fingerprint',
|
||||
'4b1f2c9e7a3d',
|
||||
'--open-url',
|
||||
'exp://example.test',
|
||||
]);
|
||||
await command.runAsync();
|
||||
|
||||
expect(mockCreateDeviceRunSessionAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
expect.objectContaining({ buildFingerprint: '4b1f2c9e7a3d', openUrl: 'exp://example.test' })
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --application-archive-url to the create mutation', async () => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
@@ -813,6 +848,25 @@ describe(Simulator, () => {
|
||||
it.each([
|
||||
['--build-id', '8d8b713c-1834-4bd3-91e6-46f895422cbc'],
|
||||
['--application-archive-url', 'https://example.test/builds/app.tar.gz'],
|
||||
])('rejects passing --build-fingerprint with %s', async (sourceFlag, sourceValue) => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
'ios',
|
||||
'--non-interactive',
|
||||
'--build-fingerprint',
|
||||
'4b1f2c9e7a3d',
|
||||
sourceFlag,
|
||||
sourceValue,
|
||||
]);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow();
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['--build-id', '8d8b713c-1834-4bd3-91e6-46f895422cbc'],
|
||||
['--build-fingerprint', '4b1f2c9e7a3d'],
|
||||
['--application-archive-url', 'https://example.test/builds/app.tar.gz'],
|
||||
])('rejects passing --expo-go with %s', async (sourceFlag, sourceValue) => {
|
||||
const { command } = createCommand([
|
||||
'--platform',
|
||||
@@ -828,6 +882,100 @@ describe(Simulator, () => {
|
||||
expect(mockCreateDeviceRunSessionAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('waiting for concurrency', () => {
|
||||
let elapsedMs: number;
|
||||
|
||||
beforeEach(() => {
|
||||
elapsedMs = 0;
|
||||
jest.spyOn(Date, 'now').mockImplementation(() => elapsedMs);
|
||||
jest.spyOn(promiseUtils, 'sleepAsync').mockImplementation(async () => {
|
||||
elapsedMs += 5 * 60 * 1_000;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([JobRunStatus.New, JobRunStatus.InQueue])(
|
||||
'allows more than 15 minutes in %s and then a full startup window',
|
||||
async status => {
|
||||
mockByIdAsync.mockImplementation(async () => {
|
||||
if (elapsedMs < 20 * 60 * 1_000) {
|
||||
return makeDeviceRunSession({
|
||||
remoteConfig: null,
|
||||
turtleJobRun: { id: 'job-123', status },
|
||||
});
|
||||
}
|
||||
expect(mockOra.mock.results[1].value.text).toContain(
|
||||
elapsedMs === 20 * 60 * 1_000
|
||||
? '⏳ Simulator session queued or waiting for available concurrency'
|
||||
: 'session to start'
|
||||
);
|
||||
return makeDeviceRunSession({
|
||||
...(elapsedMs < 30 * 60 * 1_000 ? { remoteConfig: null } : {}),
|
||||
});
|
||||
});
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await command.runAsync();
|
||||
|
||||
expect(elapsedMs).toBe(30 * 60 * 1_000);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it('times out and stops a session that does not start within 15 minutes after queuing', async () => {
|
||||
mockByIdAsync.mockImplementation(async () =>
|
||||
makeDeviceRunSession({
|
||||
remoteConfig: null,
|
||||
turtleJobRun: {
|
||||
id: 'job-123',
|
||||
status: elapsedMs < 20 * 60 * 1_000 ? JobRunStatus.InQueue : JobRunStatus.InProgress,
|
||||
},
|
||||
})
|
||||
);
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(
|
||||
'session to start (excluding time in the queue)'
|
||||
);
|
||||
|
||||
expect(elapsedMs).toBe(35 * 60 * 1_000);
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([JobRunStatus.Canceled, JobRunStatus.Errored, JobRunStatus.Finished])(
|
||||
'reports %s while queued without waiting for a timeout',
|
||||
async status => {
|
||||
mockByIdAsync
|
||||
.mockResolvedValueOnce(
|
||||
makeDeviceRunSession({
|
||||
remoteConfig: null,
|
||||
turtleJobRun: { id: 'job-123', status: JobRunStatus.InQueue },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
makeDeviceRunSession({
|
||||
remoteConfig: null,
|
||||
turtleJobRun: { id: 'job-123', status },
|
||||
})
|
||||
);
|
||||
const { command } = createCommand(['--platform', 'ios', '--non-interactive']);
|
||||
|
||||
await expect(command.runAsync()).rejects.toThrow(status.toLowerCase());
|
||||
|
||||
expect(mockEnsureDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
|
||||
graphqlClient,
|
||||
'session-123'
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('stops the simulator session when interrupted before the session is ready', async () => {
|
||||
const processExitSpy = jest.spyOn(process, 'exit').mockImplementation(code => {
|
||||
throw new Error(`process.exit(${code})`);
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class SimulatorAvailability extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:availability'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] check whether EAS Simulator is enabled for the current project account';
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from '../../simulator/env';
|
||||
|
||||
export default class SimulatorEgress extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:egress'];
|
||||
static override description = `[EXPERIMENTAL] run the local egress client for a simulator session to route proxied HTTP(S) requests through this machine`;
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ const POLL_INTERVAL_MS = 5_000;
|
||||
const POST_STOP_REFRESH_COUNT = 2;
|
||||
|
||||
export default class SimulatorEvents extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:events'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] show activity events from a remote simulator session';
|
||||
|
||||
@@ -4,7 +4,6 @@ import EasCommand from '../../commandUtils/EasCommand';
|
||||
import { SIMULATOR_DOTENV_FILE_NAME, loadSimulatorEnvAsync } from '../../simulator/env';
|
||||
|
||||
export default class SimulatorExec extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:exec'];
|
||||
static override description = `[EXPERIMENTAL] execute a simulator command with ${SIMULATOR_DOTENV_FILE_NAME} environment loaded`;
|
||||
static override strict = false;
|
||||
|
||||
@@ -28,7 +28,6 @@ type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId
|
||||
type DeviceRunSessionArtifact = DeviceRunSessionById['artifacts'][number];
|
||||
|
||||
export default class SimulatorGet extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:get'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID';
|
||||
|
||||
@@ -47,7 +47,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
import { sleepAsync } from '../../utils/promise';
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000; // 5 seconds
|
||||
const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes
|
||||
const STARTUP_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes, excluding time in the queue
|
||||
const OUT_CONFIG_TYPE_VALUES = {
|
||||
Env: 'env',
|
||||
Dotenv: 'dotenv',
|
||||
@@ -61,8 +61,8 @@ const APP_PLATFORM_BY_FLAG_VALUE: Record<PlatformFlagValue, AppPlatform> = {
|
||||
};
|
||||
|
||||
export default class Simulator extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['simulator:start', 'sim', 'sim:start'];
|
||||
static override aliases = ['simulator:start', 'sim:start'];
|
||||
static override hiddenAliases = ['sim'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it';
|
||||
|
||||
@@ -87,17 +87,22 @@ export default class Simulator extends EasCommand {
|
||||
}),
|
||||
'build-id': Flags.string({
|
||||
description: 'EAS Build to install and launch before the simulator session is ready.',
|
||||
exclusive: ['application-archive-url', 'expo-go'],
|
||||
exclusive: ['build-fingerprint', 'application-archive-url', 'expo-go'],
|
||||
}),
|
||||
'build-fingerprint': Flags.string({
|
||||
description:
|
||||
'Fingerprint hash of an EAS Build to install and launch before the simulator session is ready. Uses the most recent finished build with this fingerprint that can be installed on the simulator.',
|
||||
exclusive: ['build-id', 'application-archive-url', 'expo-go'],
|
||||
}),
|
||||
'application-archive-url': Flags.string({
|
||||
description:
|
||||
'Application archive URL to download, install, and launch before the simulator session is ready.',
|
||||
exclusive: ['build-id', 'expo-go'],
|
||||
exclusive: ['build-id', 'build-fingerprint', 'expo-go'],
|
||||
}),
|
||||
'expo-go': Flags.boolean({
|
||||
description:
|
||||
"Install and launch Expo Go matching the current project's Expo SDK before the simulator session is ready.",
|
||||
exclusive: ['build-id', 'application-archive-url'],
|
||||
exclusive: ['build-id', 'build-fingerprint', 'application-archive-url'],
|
||||
}),
|
||||
'sdk-version': Flags.string({
|
||||
description:
|
||||
@@ -203,6 +208,7 @@ export default class Simulator extends EasCommand {
|
||||
const tags = flags.tag?.map(tag => tag.trim()).filter(tag => tag.length > 0);
|
||||
const deviceIdentifier = flags.device?.trim() || undefined;
|
||||
const buildId = flags['build-id']?.trim() || undefined;
|
||||
const buildFingerprint = flags['build-fingerprint']?.trim() || undefined;
|
||||
const applicationArchiveUrlFromFlag = flags['application-archive-url']?.trim() || undefined;
|
||||
const sdkVersionFromFlag = flags['sdk-version']?.trim() || undefined;
|
||||
const launchArgs = flags['launch-arg'];
|
||||
@@ -217,11 +223,12 @@ export default class Simulator extends EasCommand {
|
||||
if (
|
||||
(launchArgs?.length || openUrl) &&
|
||||
!buildId &&
|
||||
!buildFingerprint &&
|
||||
!applicationArchiveUrlFromFlag &&
|
||||
!flags['expo-go']
|
||||
) {
|
||||
throw new EasCommandError(
|
||||
'Launch options require an application source. Pass --build-id, --application-archive-url, or --expo-go.'
|
||||
'Launch options require an application source. Pass --build-id, --build-fingerprint, --application-archive-url, or --expo-go.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,6 +288,7 @@ export default class Simulator extends EasCommand {
|
||||
: { android: { deviceIdentifier } }
|
||||
: {}),
|
||||
...(buildId ? { buildId } : {}),
|
||||
...(buildFingerprint ? { buildFingerprint } : {}),
|
||||
...(applicationArchiveUrlFromFlag
|
||||
? { applicationArchiveUrl: applicationArchiveUrlFromFlag }
|
||||
: {}),
|
||||
@@ -318,11 +326,11 @@ export default class Simulator extends EasCommand {
|
||||
}
|
||||
|
||||
const pollSpinner = ora(`⏳ Waiting for ${flags.type} session to be ready`).start();
|
||||
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
||||
let startupDeadline: number | undefined = Date.now() + STARTUP_TIMEOUT_MS;
|
||||
let remoteConfig: DeviceRunSessionRemoteConfig | undefined;
|
||||
|
||||
try {
|
||||
while (!sessionInterrupt.signal.aborted && Date.now() < deadline) {
|
||||
while (!sessionInterrupt.signal.aborted) {
|
||||
const session = await Promise.race([
|
||||
DeviceRunSessionQuery.byIdAsync(graphqlClient, deviceRunSessionId),
|
||||
sessionInterrupt.abortPromise,
|
||||
@@ -358,6 +366,17 @@ export default class Simulator extends EasCommand {
|
||||
break;
|
||||
}
|
||||
|
||||
if (jobRunStatus === JobRunStatus.New || jobRunStatus === JobRunStatus.InQueue) {
|
||||
startupDeadline = undefined;
|
||||
pollSpinner.text = '⏳ Simulator session queued or waiting for available concurrency';
|
||||
} else {
|
||||
startupDeadline ??= Date.now() + STARTUP_TIMEOUT_MS;
|
||||
pollSpinner.text = `⏳ Waiting for ${flags.type} session to start`;
|
||||
if (Date.now() >= startupDeadline) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await sleepAsync(POLL_INTERVAL_MS, sessionInterrupt.signal);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -379,11 +398,11 @@ export default class Simulator extends EasCommand {
|
||||
}
|
||||
|
||||
if (!remoteConfig) {
|
||||
pollSpinner.fail(`Timed out waiting for ${flags.type} session to be ready`);
|
||||
pollSpinner.fail(`Timed out waiting for ${flags.type} session to start`);
|
||||
await ensureDeviceRunSessionStoppedSafelyAsync(graphqlClient, deviceRunSessionId);
|
||||
sessionInterrupt.dispose();
|
||||
throw new Error(
|
||||
`Timed out after ${Math.round(POLL_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to be ready. ${link(deviceRunSessionUrl)}`
|
||||
`Timed out after ${Math.round(STARTUP_TIMEOUT_MS / 1000)}s waiting for ${flags.type} session to start (excluding time in the queue). ${link(deviceRunSessionUrl)}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ const PLATFORM_BY_FLAG_VALUE = Object.fromEntries(
|
||||
);
|
||||
|
||||
export default class SimulatorList extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:list'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] list remote simulator sessions for the current project';
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
|
||||
|
||||
export default class SimulatorStop extends EasCommand {
|
||||
static override hidden = true;
|
||||
static override aliases = ['sim:stop'];
|
||||
static override description =
|
||||
'[EXPERIMENTAL] stop a remote simulator session on EAS by its simulator session ID';
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getAppStoreConnectApiKeyJsonAsync } from '../internal';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
|
||||
jest.mock('../../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({
|
||||
AppStoreConnectApiKeyQuery: {
|
||||
getByIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe(getAppStoreConnectApiKeyJsonAsync, () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('writes issuer_id for an inline team key', async () => {
|
||||
const json = await getAppStoreConnectApiKeyJsonAsync({
|
||||
iosConfig: {
|
||||
ascApiKey: {
|
||||
keyIdentifier: 'KEY123',
|
||||
issuerIdentifier: 'ISSUER456',
|
||||
keyP8: 'p8-content',
|
||||
},
|
||||
} as any,
|
||||
graphqlClient: {} as any,
|
||||
});
|
||||
|
||||
expect(JSON.parse(json!)).toEqual({
|
||||
key_id: 'KEY123',
|
||||
issuer_id: 'ISSUER456',
|
||||
key: 'p8-content',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits issuer_id for an inline individual key', async () => {
|
||||
const json = await getAppStoreConnectApiKeyJsonAsync({
|
||||
iosConfig: {
|
||||
ascApiKey: {
|
||||
keyIdentifier: 'KEY123',
|
||||
keyP8: 'p8-content',
|
||||
},
|
||||
} as any,
|
||||
graphqlClient: {} as any,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(json!);
|
||||
expect(parsed).toEqual({ key_id: 'KEY123', key: 'p8-content' });
|
||||
expect('issuer_id' in parsed).toBe(false);
|
||||
});
|
||||
|
||||
it('writes issuer_id for a stored team key', async () => {
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockResolvedValue({
|
||||
keyIdentifier: 'KEY123',
|
||||
issuerIdentifier: 'ISSUER456',
|
||||
keyP8: 'p8-content',
|
||||
});
|
||||
|
||||
const json = await getAppStoreConnectApiKeyJsonAsync({
|
||||
iosConfig: { ascApiKeyId: 'stored-key-id' } as any,
|
||||
graphqlClient: {} as any,
|
||||
});
|
||||
|
||||
expect(JSON.parse(json!)).toEqual({
|
||||
key_id: 'KEY123',
|
||||
issuer_id: 'ISSUER456',
|
||||
key: 'p8-content',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits issuer_id for a stored individual key', async () => {
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockResolvedValue({
|
||||
keyIdentifier: 'KEY123',
|
||||
issuerIdentifier: undefined,
|
||||
keyP8: 'p8-content',
|
||||
});
|
||||
|
||||
const json = await getAppStoreConnectApiKeyJsonAsync({
|
||||
iosConfig: { ascApiKeyId: 'stored-key-id' } as any,
|
||||
graphqlClient: {} as any,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(json!);
|
||||
expect(parsed).toEqual({ key_id: 'KEY123', key: 'p8-content' });
|
||||
expect('issuer_id' in parsed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null without key input', async () => {
|
||||
const json = await getAppStoreConnectApiKeyJsonAsync({
|
||||
iosConfig: {} as any,
|
||||
graphqlClient: {} as any,
|
||||
});
|
||||
|
||||
expect(json).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -183,17 +183,20 @@ async function getGoogleServiceAccountKeyJsonAsync({
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getAppStoreConnectApiKeyJsonAsync({
|
||||
export async function getAppStoreConnectApiKeyJsonAsync({
|
||||
iosConfig,
|
||||
graphqlClient,
|
||||
}: {
|
||||
iosConfig: IosSubmissionConfigInput;
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
}): Promise<string | null> {
|
||||
// Individual API keys have no issuer. fastlane detects them by the absence of the issuer_id field.
|
||||
if (iosConfig.ascApiKey) {
|
||||
return JSON.stringify({
|
||||
key_id: iosConfig.ascApiKey.keyIdentifier,
|
||||
issuer_id: iosConfig.ascApiKey.issuerIdentifier,
|
||||
...(iosConfig.ascApiKey.issuerIdentifier
|
||||
? { issuer_id: iosConfig.ascApiKey.issuerIdentifier }
|
||||
: null),
|
||||
key: iosConfig.ascApiKey.keyP8,
|
||||
});
|
||||
} else if (iosConfig.ascApiKeyId) {
|
||||
@@ -201,7 +204,7 @@ async function getAppStoreConnectApiKeyJsonAsync({
|
||||
|
||||
return JSON.stringify({
|
||||
key_id: key.keyIdentifier,
|
||||
issuer_id: key.issuerIdentifier,
|
||||
...(key.issuerIdentifier ? { issuer_id: key.issuerIdentifier } : null),
|
||||
key: key.keyP8,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function getAppstoreMock(): AppStoreApi {
|
||||
return {
|
||||
defaultAuthenticationMode: AuthenticationMode.USER,
|
||||
ensureAuthenticatedAsync: jest.fn(),
|
||||
ensureProvisioningAuthenticatedAsync: jest.fn(),
|
||||
ensureBundleIdExistsAsync: jest.fn(),
|
||||
listDistributionCertificatesAsync: jest.fn(),
|
||||
createDistributionCertificateAsync: jest.fn(),
|
||||
|
||||
@@ -105,6 +105,17 @@ export class CredentialsContext {
|
||||
}
|
||||
|
||||
if (this.appStore.defaultAuthenticationMode === AuthenticationMode.API_KEY) {
|
||||
if (
|
||||
process.env.EXPO_ASC_API_KEY_PATH &&
|
||||
process.env.EXPO_ASC_KEY_ID &&
|
||||
!process.env.EXPO_ASC_ISSUER_ID
|
||||
) {
|
||||
Log.debug(
|
||||
`App Store Connect API key ${process.env.EXPO_ASC_KEY_ID} has no Issuer ID (individual key). Skipping authentication with Apple. Set EXPO_ASC_ISSUER_ID if this is a team key.`
|
||||
);
|
||||
this.shouldAskAuthenticateAppStore = false;
|
||||
return;
|
||||
}
|
||||
await this.appStore.ensureAuthenticatedAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { UserRole } from '@expo/apple-utils';
|
||||
|
||||
import { formatAppleTeam } from './AppleTeamFormatting';
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import env from '../../../env';
|
||||
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
@@ -34,9 +35,30 @@ export enum AppStoreApiKeyPurpose {
|
||||
ASC_APP_CONNECTION = 'EAS Connect',
|
||||
}
|
||||
|
||||
export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Promise<AscApiKeyPath> {
|
||||
export async function promptForAscApiKeyPathAsync(
|
||||
ctx: CredentialsContext,
|
||||
purpose: AppStoreApiKeyPurpose
|
||||
): Promise<AscApiKeyPath> {
|
||||
// Individual keys are valid only as submission keys. Every other purpose
|
||||
// requires a team key, so the key type question is not asked there.
|
||||
// The option stays behind an env var until every server-side submission
|
||||
// path runs a fastlane version that accepts keys without an issuer ID.
|
||||
const isIndividualKey =
|
||||
!!env.enableIndividualAscApiKeys &&
|
||||
purpose === AppStoreApiKeyPurpose.SUBMISSION_SERVICE &&
|
||||
!ctx.nonInteractive &&
|
||||
(await promptForAscApiKeyTypeIsIndividualAsync());
|
||||
|
||||
const { keyId, keyP8Path } = await promptForKeyP8AndIdAsync();
|
||||
|
||||
if (isIndividualKey) {
|
||||
Log.log(
|
||||
'Individual API keys can be used for submissions, TestFlight setup, and metadata only. ' +
|
||||
'They cannot manage certificates or provisioning profiles.'
|
||||
);
|
||||
return { keyId, keyP8Path };
|
||||
}
|
||||
|
||||
const bestEffortIssuerId = await getBestEffortIssuerIdAsync(ctx, keyId);
|
||||
if (bestEffortIssuerId) {
|
||||
Log.log(`Detected Issuer ID: ${bestEffortIssuerId}`);
|
||||
@@ -46,6 +68,13 @@ export async function promptForAscApiKeyPathAsync(ctx: CredentialsContext): Prom
|
||||
return { keyId, issuerId, keyP8Path };
|
||||
}
|
||||
|
||||
async function promptForAscApiKeyTypeIsIndividualAsync(): Promise<boolean> {
|
||||
return await selectAsync<boolean>('Which type of App Store Connect API key do you want to use?', [
|
||||
{ title: 'Team key (recommended)', value: false },
|
||||
{ title: 'Individual key (submissions only)', value: true },
|
||||
]);
|
||||
}
|
||||
|
||||
export async function promptForIssuerIdAsync(): Promise<string> {
|
||||
Log.log(chalk.bold('An App Store Connect Issuer ID is required'));
|
||||
Log.log(
|
||||
@@ -79,7 +108,7 @@ export async function provideOrGenerateAscApiKeyAsync(
|
||||
return await generateAscApiKeyAsync(ctx, purpose);
|
||||
}
|
||||
|
||||
const userProvided = await promptForAscApiKeyAsync(ctx);
|
||||
const userProvided = await promptForAscApiKeyAsync(ctx, purpose);
|
||||
if (!userProvided) {
|
||||
return await generateAscApiKeyAsync(ctx, purpose);
|
||||
}
|
||||
@@ -133,12 +162,15 @@ export function getAscApiKeyName(purpose: AppStoreApiKeyPurpose): string {
|
||||
return nameParts.join(' ');
|
||||
}
|
||||
|
||||
async function promptForAscApiKeyAsync(ctx: CredentialsContext): Promise<MinimalAscApiKey | null> {
|
||||
async function promptForAscApiKeyAsync(
|
||||
ctx: CredentialsContext,
|
||||
purpose: AppStoreApiKeyPurpose
|
||||
): Promise<MinimalAscApiKey | null> {
|
||||
const shouldAutoGenerateCredentials = await shouldAutoGenerateCredentialsAsync(ascApiKeyIdSchema);
|
||||
if (shouldAutoGenerateCredentials) {
|
||||
return null;
|
||||
}
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx);
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx, purpose);
|
||||
const { keyP8Path, keyId, issuerId } = ascApiKeyPath;
|
||||
return { keyP8: await fs.readFile(keyP8Path, 'utf-8'), keyId, issuerId };
|
||||
}
|
||||
@@ -260,6 +292,23 @@ function filterKeysFromDifferentAppleTeam(
|
||||
return keys.filter(key => !key.appleTeam || key.appleTeam?.appleTeamIdentifier === teamId);
|
||||
}
|
||||
|
||||
export function filterOutIndividualAscApiKeys(
|
||||
keys: AppStoreConnectApiKeyFragment[]
|
||||
): AppStoreConnectApiKeyFragment[] {
|
||||
const teamKeys = keys.filter(key => !!key.issuerIdentifier);
|
||||
const hiddenCount = keys.length - teamKeys.length;
|
||||
if (hiddenCount > 0) {
|
||||
Log.log(
|
||||
chalk.gray(
|
||||
`${hiddenCount} individual API ${
|
||||
hiddenCount === 1 ? 'key' : 'keys'
|
||||
} hidden: individual keys are valid only for submissions.`
|
||||
)
|
||||
);
|
||||
}
|
||||
return teamKeys;
|
||||
}
|
||||
|
||||
export function sortAscApiKeysByUpdatedAtDesc(
|
||||
keys: AppStoreConnectApiKeyFragment[]
|
||||
): AppStoreConnectApiKeyFragment[] {
|
||||
@@ -329,6 +378,16 @@ export async function tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
}
|
||||
try {
|
||||
if (hasAscEnvVars()) {
|
||||
if (
|
||||
process.env.EXPO_ASC_API_KEY_PATH &&
|
||||
process.env.EXPO_ASC_KEY_ID &&
|
||||
!process.env.EXPO_ASC_ISSUER_ID
|
||||
) {
|
||||
Log.debug(
|
||||
`App Store Connect API key ${process.env.EXPO_ASC_KEY_ID} has no Issuer ID (individual key). Skipping authentication with Apple. Set EXPO_ASC_ISSUER_ID if this is a team key.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
await ctx.appStore.ensureAuthenticatedAsync({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
teamType,
|
||||
@@ -342,6 +401,12 @@ export async function tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
if (!resolvedKey) {
|
||||
return false;
|
||||
}
|
||||
if (!resolvedKey.ascApiKey.issuerId) {
|
||||
Log.debug(
|
||||
`App Store Connect API key ${resolvedKey.ascApiKey.keyId} has no Issuer ID (individual key). Skipping authentication with Apple. Run 'eas credentials' and upload the key again with its Issuer ID if this is a team key.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
Log.log('Using App Store Connect API Key from EAS credentials service.');
|
||||
await ctx.appStore.ensureAuthenticatedAsync({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { UserRole } from '@expo/apple-utils';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
import env from '../../../../env';
|
||||
import { AppStoreConnectApiKeyQuery } from '../../../../graphql/queries/AppStoreConnectApiKeyQuery';
|
||||
import Log from '../../../../log';
|
||||
import { promptAsync, selectAsync } from '../../../../prompts';
|
||||
import { getAppstoreMock, testAuthCtx } from '../../../__tests__/fixtures-appstore';
|
||||
import { createCtxMock } from '../../../__tests__/fixtures-context';
|
||||
@@ -9,16 +12,33 @@ import {
|
||||
getCredentialsFromUserAsync,
|
||||
shouldAutoGenerateCredentialsAsync,
|
||||
} from '../../../utils/promptForCredentials';
|
||||
import { getAscApiKeyForAppSubmissionsAsync } from '../../api/GraphqlClient';
|
||||
import { AppleTeamType } from '../../appstore/authenticateTypes';
|
||||
import { hasAscEnvVars } from '../../appstore/resolveCredentials';
|
||||
import {
|
||||
AppStoreApiKeyPurpose,
|
||||
filterOutIndividualAscApiKeys,
|
||||
getAscApiKeyName,
|
||||
promptForAscApiKeyPathAsync,
|
||||
provideOrGenerateAscApiKeyAsync,
|
||||
tryAuthenticateAppStoreWithEasAscApiKeyAsync,
|
||||
} from '../AscApiKeyUtils';
|
||||
|
||||
jest.mock('../../../../prompts');
|
||||
jest.mock('../../../utils/promptForCredentials');
|
||||
jest.mock('fs-extra');
|
||||
jest.mock('../../api/GraphqlClient', () => ({
|
||||
...jest.requireActual('../../api/GraphqlClient'),
|
||||
getAscApiKeyForAppSubmissionsAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../appstore/resolveCredentials', () => ({
|
||||
hasAscEnvVars: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({
|
||||
AppStoreConnectApiKeyQuery: {
|
||||
getByIdAsync: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function enumKeys<O extends object, K extends keyof O = keyof O>(obj: O): K[] {
|
||||
return Object.keys(obj) as K[];
|
||||
@@ -30,6 +50,27 @@ afterEach(() => {
|
||||
jest.mocked(getCredentialsFromUserAsync).mockClear();
|
||||
jest.mocked(shouldAutoGenerateCredentialsAsync).mockClear();
|
||||
jest.mocked(fs.readFile).mockClear();
|
||||
jest.mocked(hasAscEnvVars).mockReset();
|
||||
jest.mocked(getAscApiKeyForAppSubmissionsAsync).mockReset();
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockReset();
|
||||
});
|
||||
|
||||
describe(filterOutIndividualAscApiKeys, () => {
|
||||
it('removes keys without an issuer identifier and keeps the rest', () => {
|
||||
const teamKey = { id: 'team', issuerIdentifier: 'issuer-id' } as any;
|
||||
const individualKey = { id: 'individual', issuerIdentifier: null } as any;
|
||||
|
||||
expect(filterOutIndividualAscApiKeys([teamKey, individualKey])).toEqual([teamKey]);
|
||||
});
|
||||
|
||||
it('returns all keys when none is individual', () => {
|
||||
const keys = [
|
||||
{ id: 'a', issuerIdentifier: 'issuer-a' },
|
||||
{ id: 'b', issuerIdentifier: 'issuer-b' },
|
||||
] as any[];
|
||||
|
||||
expect(filterOutIndividualAscApiKeys(keys)).toEqual(keys);
|
||||
});
|
||||
});
|
||||
|
||||
describe(getAscApiKeyName, () => {
|
||||
@@ -44,7 +85,15 @@ describe(getAscApiKeyName, () => {
|
||||
});
|
||||
|
||||
describe(promptForAscApiKeyPathAsync, () => {
|
||||
beforeEach(() => {
|
||||
env.enableIndividualAscApiKeys = '1';
|
||||
});
|
||||
afterEach(() => {
|
||||
env.enableIndividualAscApiKeys = undefined;
|
||||
});
|
||||
|
||||
it('prompts for keyId, keyP8Path and issuerId when user is not authenticated to Apple', async () => {
|
||||
jest.mocked(selectAsync).mockResolvedValueOnce(false); // team key
|
||||
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
}));
|
||||
@@ -62,16 +111,21 @@ describe(promptForAscApiKeyPathAsync, () => {
|
||||
authCtx: null,
|
||||
},
|
||||
});
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx);
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
|
||||
ctx,
|
||||
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
|
||||
);
|
||||
expect(ascApiKeyPath).toEqual({
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id',
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
});
|
||||
expect(selectAsync).toHaveBeenCalledTimes(1); // key type
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1); // keyP8Path
|
||||
expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(2); // keyId, issuerId
|
||||
});
|
||||
it('prompts for keyId, keyP8Path and detects issuerId when user is authenticated to Apple', async () => {
|
||||
jest.mocked(selectAsync).mockResolvedValueOnce(false); // team key
|
||||
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
}));
|
||||
@@ -87,7 +141,10 @@ describe(promptForAscApiKeyPathAsync, () => {
|
||||
getAscApiKeyAsync: jest.fn(() => testAscApiKey),
|
||||
},
|
||||
});
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(ctx);
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
|
||||
ctx,
|
||||
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
|
||||
);
|
||||
expect(ascApiKeyPath).toEqual({
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id-from-apple',
|
||||
@@ -97,6 +154,89 @@ describe(promptForAscApiKeyPathAsync, () => {
|
||||
expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(1); // keyId
|
||||
expect(jest.mocked(ctx.appStore.getAscApiKeyAsync).mock.calls.length).toBe(1); // issuerId
|
||||
});
|
||||
it('skips the issuer prompt for an individual key in the submission flow', async () => {
|
||||
jest.mocked(selectAsync).mockResolvedValueOnce(true); // individual key
|
||||
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
}));
|
||||
jest.mocked(getCredentialsFromUserAsync).mockImplementation(async () => ({
|
||||
keyId: 'test-key-id',
|
||||
}));
|
||||
const getAscApiKeyAsync = jest.fn(() => testAscApiKey);
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: testAuthCtx,
|
||||
getAscApiKeyAsync,
|
||||
},
|
||||
});
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
|
||||
ctx,
|
||||
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
|
||||
);
|
||||
expect(ascApiKeyPath).toEqual({
|
||||
keyId: 'test-key-id',
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
});
|
||||
expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(1); // keyId only
|
||||
expect(getAscApiKeyAsync).not.toHaveBeenCalled(); // no issuer detection
|
||||
});
|
||||
it('does not ask the key type outside the submission flow', async () => {
|
||||
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
}));
|
||||
jest.mocked(getCredentialsFromUserAsync).mockImplementation(async () => ({
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id',
|
||||
}));
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: null,
|
||||
},
|
||||
});
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
|
||||
ctx,
|
||||
AppStoreApiKeyPurpose.ASC_APP_CONNECTION
|
||||
);
|
||||
expect(ascApiKeyPath).toEqual({
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id',
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
});
|
||||
expect(selectAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
it('does not ask the key type in the submission flow when individual keys are not enabled', async () => {
|
||||
env.enableIndividualAscApiKeys = undefined;
|
||||
jest.mocked(promptAsync).mockImplementationOnce(async () => ({
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
}));
|
||||
jest.mocked(getCredentialsFromUserAsync).mockImplementation(async () => ({
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id',
|
||||
}));
|
||||
const ctx = createCtxMock({
|
||||
nonInteractive: false,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: null,
|
||||
},
|
||||
});
|
||||
const ascApiKeyPath = await promptForAscApiKeyPathAsync(
|
||||
ctx,
|
||||
AppStoreApiKeyPurpose.SUBMISSION_SERVICE
|
||||
);
|
||||
expect(ascApiKeyPath).toEqual({
|
||||
keyId: 'test-key-id',
|
||||
issuerId: 'test-issuer-id',
|
||||
keyP8Path: '/asc-api-key.p8',
|
||||
});
|
||||
expect(selectAsync).not.toHaveBeenCalled();
|
||||
expect(promptAsync).toHaveBeenCalledTimes(1); // keyP8Path
|
||||
expect(getCredentialsFromUserAsync).toHaveBeenCalledTimes(2); // keyId, issuerId
|
||||
});
|
||||
});
|
||||
|
||||
describe(provideOrGenerateAscApiKeyAsync, () => {
|
||||
@@ -179,3 +319,95 @@ describe(provideOrGenerateAscApiKeyAsync, () => {
|
||||
expect(createAscApiKeyAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe(tryAuthenticateAppStoreWithEasAscApiKeyAsync, () => {
|
||||
const app = {
|
||||
account: { name: 'test-account' },
|
||||
projectName: 'test-project',
|
||||
bundleIdentifier: 'com.test.app',
|
||||
} as any;
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.EXPO_ASC_API_KEY_PATH;
|
||||
delete process.env.EXPO_ASC_KEY_ID;
|
||||
delete process.env.EXPO_ASC_ISSUER_ID;
|
||||
});
|
||||
|
||||
function createNonInteractiveCtx(): ReturnType<typeof createCtxMock> {
|
||||
return createCtxMock({
|
||||
nonInteractive: true,
|
||||
appStore: {
|
||||
...getAppstoreMock(),
|
||||
authCtx: null,
|
||||
ensureAuthenticatedAsync: jest.fn(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('skips authentication without a warning when the environment key has no issuer ID', async () => {
|
||||
const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
process.env.EXPO_ASC_API_KEY_PATH = '/asc-api-key.p8';
|
||||
process.env.EXPO_ASC_KEY_ID = 'env-key-id';
|
||||
jest.mocked(hasAscEnvVars).mockReturnValue(true);
|
||||
const ctx = createNonInteractiveCtx();
|
||||
|
||||
const result = await tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
ctx,
|
||||
app,
|
||||
AppleTeamType.COMPANY_OR_ORGANIZATION
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(ctx.appStore.ensureAuthenticatedAsync).not.toHaveBeenCalled();
|
||||
expect(getAscApiKeyForAppSubmissionsAsync).not.toHaveBeenCalled();
|
||||
expect(logWarnSpy).not.toHaveBeenCalled();
|
||||
logWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('authenticates with the environment key when it has an issuer ID', async () => {
|
||||
process.env.EXPO_ASC_API_KEY_PATH = '/asc-api-key.p8';
|
||||
process.env.EXPO_ASC_KEY_ID = 'env-key-id';
|
||||
process.env.EXPO_ASC_ISSUER_ID = 'env-issuer-id';
|
||||
jest.mocked(hasAscEnvVars).mockReturnValue(true);
|
||||
const ctx = createNonInteractiveCtx();
|
||||
jest.mocked(ctx.appStore.ensureAuthenticatedAsync).mockImplementation(async () => {
|
||||
(ctx.appStore as any).authCtx = testAuthCtx;
|
||||
return testAuthCtx;
|
||||
});
|
||||
|
||||
const result = await tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
ctx,
|
||||
app,
|
||||
AppleTeamType.COMPANY_OR_ORGANIZATION
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(ctx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('skips authentication without a warning when the stored submission key has no issuer ID', async () => {
|
||||
const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
jest.mocked(hasAscEnvVars).mockReturnValue(false);
|
||||
jest.mocked(getAscApiKeyForAppSubmissionsAsync).mockResolvedValue({
|
||||
id: 'asc-key-id',
|
||||
appleTeam: { appleTeamIdentifier: 'TEAM123', appleTeamName: 'Team Name' },
|
||||
} as any);
|
||||
jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockResolvedValue({
|
||||
keyP8: 'key-p8',
|
||||
keyIdentifier: 'stored-key-id',
|
||||
issuerIdentifier: null,
|
||||
} as any);
|
||||
const ctx = createNonInteractiveCtx();
|
||||
|
||||
const result = await tryAuthenticateAppStoreWithEasAscApiKeyAsync(
|
||||
ctx,
|
||||
app,
|
||||
AppleTeamType.COMPANY_OR_ORGANIZATION
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(ctx.appStore.ensureAuthenticatedAsync).not.toHaveBeenCalled();
|
||||
expect(logWarnSpy).not.toHaveBeenCalled();
|
||||
logWarnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './ascApiKey';
|
||||
import {
|
||||
Options as AuthenticateOptions,
|
||||
assertProvisioningAuthCtx,
|
||||
assertUserAuthCtx,
|
||||
authenticateAsync,
|
||||
isUserAuthCtx,
|
||||
@@ -80,26 +81,38 @@ export default class AppStoreApi {
|
||||
return this.authCtx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use for operations against Apple's Provisioning endpoints (bundle IDs, certificates,
|
||||
* profiles, devices).
|
||||
*/
|
||||
public async ensureProvisioningAuthenticatedAsync(
|
||||
options?: AuthenticateOptions
|
||||
): Promise<AuthCtx> {
|
||||
const authCtx = await this.ensureAuthenticatedAsync(options);
|
||||
assertProvisioningAuthCtx(authCtx);
|
||||
return authCtx;
|
||||
}
|
||||
|
||||
public async ensureBundleIdExistsAsync(
|
||||
app: AppLookupParams,
|
||||
options?: IosCapabilitiesOptions
|
||||
): Promise<void> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
await ensureBundleIdExistsAsync(ctx, app, options);
|
||||
}
|
||||
|
||||
public async listDistributionCertificatesAsync(): Promise<DistributionCertificateStoreInfo[]> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
return await listDistributionCertificatesAsync(ctx);
|
||||
}
|
||||
|
||||
public async createDistributionCertificateAsync(): Promise<DistributionCertificate> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
return await createDistributionCertificateAsync(ctx);
|
||||
}
|
||||
|
||||
public async revokeDistributionCertificateAsync(ids: string[]): Promise<void> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
await revokeDistributionCertificateAsync(ctx, ids);
|
||||
}
|
||||
|
||||
@@ -123,7 +136,7 @@ export default class AppStoreApi {
|
||||
provisioningProfile: ProvisioningProfile,
|
||||
distCert: DistributionCertificate
|
||||
): Promise<ProvisioningProfile> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
return await useExistingProvisioningProfileAsync(
|
||||
ctx,
|
||||
bundleIdentifier,
|
||||
@@ -137,7 +150,7 @@ export default class AppStoreApi {
|
||||
applePlatform: ApplePlatform,
|
||||
profileClass?: ProfileClass
|
||||
): Promise<ProvisioningProfileStoreInfo[]> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
return await listProvisioningProfilesAsync(ctx, bundleIdentifier, applePlatform, profileClass);
|
||||
}
|
||||
|
||||
@@ -148,7 +161,7 @@ export default class AppStoreApi {
|
||||
applePlatform: ApplePlatform,
|
||||
profileClass?: ProfileClass
|
||||
): Promise<ProvisioningProfile> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
return await createProvisioningProfileAsync(
|
||||
ctx,
|
||||
bundleIdentifier,
|
||||
@@ -164,7 +177,7 @@ export default class AppStoreApi {
|
||||
applePlatform: ApplePlatform,
|
||||
profileClass?: ProfileClass
|
||||
): Promise<void> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
await revokeProvisioningProfileAsync(ctx, bundleIdentifier, applePlatform, profileClass);
|
||||
}
|
||||
|
||||
@@ -174,7 +187,7 @@ export default class AppStoreApi {
|
||||
distCertSerialNumber: string,
|
||||
profileType: ProfileType
|
||||
): Promise<ProvisioningProfile> {
|
||||
const ctx = await this.ensureAuthenticatedAsync();
|
||||
const ctx = await this.ensureProvisioningAuthenticatedAsync();
|
||||
return await createOrReuseAdhocProvisioningProfileAsync(
|
||||
ctx,
|
||||
udids,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import AppStoreApi from '../AppStoreApi';
|
||||
import { authenticateAsync } from '../authenticate';
|
||||
import { AuthCtx } from '../authenticateTypes';
|
||||
import { listDistributionCertificatesAsync } from '../distributionCertificate';
|
||||
|
||||
jest.mock('../authenticate', () => ({
|
||||
...jest.requireActual('../authenticate'),
|
||||
authenticateAsync: jest.fn(),
|
||||
}));
|
||||
jest.mock('../distributionCertificate');
|
||||
|
||||
const individualKeyAuthCtx = {
|
||||
ascApiKey: { keyP8: 'p8-content', keyId: 'key-id' },
|
||||
team: { id: 'team-id' },
|
||||
} as AuthCtx;
|
||||
const teamKeyAuthCtx = {
|
||||
ascApiKey: { keyP8: 'p8-content', keyId: 'key-id', issuerId: 'issuer-id' },
|
||||
team: { id: 'team-id' },
|
||||
} as AuthCtx;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.mocked(authenticateAsync).mockReset();
|
||||
jest.mocked(listDistributionCertificatesAsync).mockReset();
|
||||
});
|
||||
|
||||
describe(AppStoreApi, () => {
|
||||
it('runs provisioning operations with a team API key', async () => {
|
||||
jest.mocked(authenticateAsync).mockResolvedValue(teamKeyAuthCtx);
|
||||
jest.mocked(listDistributionCertificatesAsync).mockResolvedValue([]);
|
||||
const appStoreApi = new AppStoreApi();
|
||||
|
||||
await expect(appStoreApi.listDistributionCertificatesAsync()).resolves.toEqual([]);
|
||||
expect(listDistributionCertificatesAsync).toHaveBeenCalledWith(teamKeyAuthCtx);
|
||||
});
|
||||
|
||||
it('rejects provisioning operations with an individual API key before calling Apple', async () => {
|
||||
jest.mocked(authenticateAsync).mockResolvedValue(individualKeyAuthCtx);
|
||||
const appStoreApi = new AppStoreApi();
|
||||
|
||||
await expect(appStoreApi.listDistributionCertificatesAsync()).rejects.toThrow(
|
||||
'individual API key'
|
||||
);
|
||||
expect(listDistributionCertificatesAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects provisioning operations with an individual API key cached by an earlier flow', async () => {
|
||||
jest.mocked(authenticateAsync).mockResolvedValue(individualKeyAuthCtx);
|
||||
const appStoreApi = new AppStoreApi();
|
||||
await appStoreApi.ensureAuthenticatedAsync();
|
||||
|
||||
await expect(appStoreApi.ensureProvisioningAuthenticatedAsync()).rejects.toThrow(
|
||||
'individual API key'
|
||||
);
|
||||
expect(authenticateAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Token } from '@expo/apple-utils';
|
||||
|
||||
import {
|
||||
assertProvisioningAuthCtx,
|
||||
authenticateAsync,
|
||||
isIndividualAscApiKeyAuthCtx,
|
||||
} from '../authenticate';
|
||||
import { ApiKeyAuthCtx, AppleTeamType, AuthCtx, AuthenticationMode } from '../authenticateTypes';
|
||||
|
||||
jest.mock('@expo/apple-utils', () => ({
|
||||
...jest.requireActual('@expo/apple-utils'),
|
||||
Token: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
const apiKeyAuthOptions = {
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
teamId: 'team-id',
|
||||
teamType: AppleTeamType.COMPANY_OR_ORGANIZATION,
|
||||
};
|
||||
|
||||
describe(authenticateAsync, () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.EXPO_ASC_API_KEY_PATH;
|
||||
delete process.env.EXPO_ASC_KEY_ID;
|
||||
delete process.env.EXPO_ASC_ISSUER_ID;
|
||||
jest.mocked(Token).mockClear();
|
||||
});
|
||||
|
||||
it('authenticates with a team API key', async () => {
|
||||
const authCtx = (await authenticateAsync({
|
||||
...apiKeyAuthOptions,
|
||||
ascApiKey: { keyP8: 'p8-content', keyId: 'key-id', issuerId: 'issuer-id' },
|
||||
})) as ApiKeyAuthCtx;
|
||||
|
||||
expect(authCtx.ascApiKey.issuerId).toBe('issuer-id');
|
||||
expect(Token).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ keyId: 'key-id', issuerId: 'issuer-id' })
|
||||
);
|
||||
});
|
||||
|
||||
it('authenticates with an individual (issuer-less) API key', async () => {
|
||||
const authCtx = (await authenticateAsync({
|
||||
...apiKeyAuthOptions,
|
||||
ascApiKey: { keyP8: 'p8-content', keyId: 'key-id' },
|
||||
})) as ApiKeyAuthCtx;
|
||||
|
||||
expect(authCtx.ascApiKey.issuerId).toBeUndefined();
|
||||
expect(Token).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ keyId: 'key-id', issuerId: undefined })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const individualKeyAuthCtx = {
|
||||
ascApiKey: { keyP8: 'p8-content', keyId: 'key-id' },
|
||||
team: { id: 'team-id' },
|
||||
} as AuthCtx;
|
||||
const teamKeyAuthCtx = {
|
||||
ascApiKey: { keyP8: 'p8-content', keyId: 'key-id', issuerId: 'issuer-id' },
|
||||
team: { id: 'team-id' },
|
||||
} as AuthCtx;
|
||||
const userAuthCtx = {
|
||||
appleId: '[email protected]',
|
||||
team: { id: 'team-id' },
|
||||
} as AuthCtx;
|
||||
|
||||
describe(isIndividualAscApiKeyAuthCtx, () => {
|
||||
it('detects an individual API key auth context', () => {
|
||||
expect(isIndividualAscApiKeyAuthCtx(individualKeyAuthCtx)).toBe(true);
|
||||
expect(isIndividualAscApiKeyAuthCtx(teamKeyAuthCtx)).toBe(false);
|
||||
expect(isIndividualAscApiKeyAuthCtx(userAuthCtx)).toBe(false);
|
||||
expect(isIndividualAscApiKeyAuthCtx(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe(assertProvisioningAuthCtx, () => {
|
||||
it('rejects an individual API key auth context', () => {
|
||||
expect(() => {
|
||||
assertProvisioningAuthCtx(individualKeyAuthCtx);
|
||||
}).toThrow('individual API key');
|
||||
expect(() => {
|
||||
assertProvisioningAuthCtx(individualKeyAuthCtx);
|
||||
}).toThrow('EXPO_ASC_ISSUER_ID');
|
||||
});
|
||||
|
||||
it('accepts team API key and user auth contexts', () => {
|
||||
expect(() => {
|
||||
assertProvisioningAuthCtx(teamKeyAuthCtx);
|
||||
}).not.toThrow();
|
||||
expect(() => {
|
||||
assertProvisioningAuthCtx(userAuthCtx);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { JsonFileCache } from '@expo/apple-utils';
|
||||
import * as fs from 'fs-extra';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import Log from '../../../../log';
|
||||
import { promptAsync } from '../../../../prompts';
|
||||
import { AppleTeamType } from '../authenticateTypes';
|
||||
import * as Keychain from '../keychain';
|
||||
@@ -67,6 +68,104 @@ describe(resolveAscApiKeyAsync, () => {
|
||||
const ascApiKey = await resolveAscApiKeyAsync();
|
||||
expect(ascApiKey).toMatchObject(testAscApiKey);
|
||||
});
|
||||
it(`fills a missing issuer ID on a key passed in options from EXPO_ASC_ISSUER_ID and warns`, async () => {
|
||||
process.env.EXPO_ASC_ISSUER_ID = testAscApiKey.issuerId;
|
||||
const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
|
||||
const ascApiKey = await resolveAscApiKeyAsync({
|
||||
keyP8: testAscApiKey.keyP8,
|
||||
keyId: testAscApiKey.keyId,
|
||||
});
|
||||
expect(ascApiKey).toEqual({
|
||||
keyP8: testAscApiKey.keyP8,
|
||||
keyId: testAscApiKey.keyId,
|
||||
issuerId: testAscApiKey.issuerId,
|
||||
});
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(logWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unset EXPO_ASC_ISSUER_ID if test-key-id is an individual API key')
|
||||
);
|
||||
logWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it(`does not warn about EXPO_ASC_ISSUER_ID when the key comes from the environment too`, async () => {
|
||||
vol.fromJSON({
|
||||
'/test-asc-key.p8': testAscApiKey.keyP8,
|
||||
});
|
||||
process.env.EXPO_ASC_API_KEY_PATH = '/test-asc-key.p8';
|
||||
process.env.EXPO_ASC_KEY_ID = testAscApiKey.keyId;
|
||||
process.env.EXPO_ASC_ISSUER_ID = testAscApiKey.issuerId;
|
||||
const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
|
||||
await expect(resolveAscApiKeyAsync()).resolves.toEqual(testAscApiKey);
|
||||
expect(logWarnSpy).not.toHaveBeenCalled();
|
||||
logWarnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it(`treats a key passed in options without an issuer ID as individual without prompting`, async () => {
|
||||
const ascApiKey = await resolveAscApiKeyAsync({
|
||||
keyP8: testAscApiKey.keyP8,
|
||||
keyId: testAscApiKey.keyId,
|
||||
});
|
||||
expect(ascApiKey).toEqual({
|
||||
keyP8: testAscApiKey.keyP8,
|
||||
keyId: testAscApiKey.keyId,
|
||||
issuerId: undefined,
|
||||
});
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`treats an environment key without EXPO_ASC_ISSUER_ID as individual without prompting`, async () => {
|
||||
vol.fromJSON({
|
||||
'/test-asc-key.p8': testAscApiKey.keyP8,
|
||||
});
|
||||
process.env.EXPO_ASC_API_KEY_PATH = '/test-asc-key.p8';
|
||||
process.env.EXPO_ASC_KEY_ID = testAscApiKey.keyId;
|
||||
const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug').mockImplementation(() => {});
|
||||
|
||||
const ascApiKey = await resolveAscApiKeyAsync();
|
||||
expect(ascApiKey).toEqual({
|
||||
keyP8: testAscApiKey.keyP8,
|
||||
keyId: testAscApiKey.keyId,
|
||||
issuerId: undefined,
|
||||
});
|
||||
expect(promptAsync).not.toHaveBeenCalled();
|
||||
expect(logWarnSpy).not.toHaveBeenCalled();
|
||||
expect(logDebugSpy).toHaveBeenCalledWith(expect.stringContaining('individual API key'));
|
||||
logWarnSpy.mockRestore();
|
||||
logDebugSpy.mockRestore();
|
||||
});
|
||||
it(`accepts an empty issuer from the prompt as an individual key`, async () => {
|
||||
vol.fromJSON({
|
||||
'/test-asc-key.p8': testAscApiKey.keyP8,
|
||||
});
|
||||
process.env.EXPO_ASC_API_KEY_PATH = '/test-asc-key.p8';
|
||||
const logWarnSpy = jest.spyOn(Log, 'warn').mockImplementation(() => {});
|
||||
const logDebugSpy = jest.spyOn(Log, 'debug').mockImplementation(() => {});
|
||||
jest
|
||||
.mocked(promptAsync)
|
||||
.mockResolvedValueOnce({ ascApiKeyId: testAscApiKey.keyId })
|
||||
.mockResolvedValueOnce({ ascIssuerId: '' });
|
||||
|
||||
const ascApiKey = await resolveAscApiKeyAsync();
|
||||
expect(ascApiKey).toEqual({
|
||||
keyP8: testAscApiKey.keyP8,
|
||||
keyId: testAscApiKey.keyId,
|
||||
issuerId: undefined,
|
||||
});
|
||||
expect(promptAsync).toHaveBeenCalledTimes(2);
|
||||
const question = jest.mocked(promptAsync).mock.calls[1][0] as unknown as {
|
||||
message: string;
|
||||
validate?: unknown;
|
||||
};
|
||||
expect(question.message).toContain('leave empty');
|
||||
expect(question.validate).toBeUndefined();
|
||||
expect(logWarnSpy).not.toHaveBeenCalled();
|
||||
expect(logDebugSpy).toHaveBeenCalledWith(expect.stringContaining('individual API key'));
|
||||
logWarnSpy.mockRestore();
|
||||
logDebugSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
const testTeam = {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
resolveAscApiKeyAsync,
|
||||
resolveUserCredentialsAsync,
|
||||
} from './resolveCredentials';
|
||||
import Log from '../../../log';
|
||||
import Log, { learnMore } from '../../../log';
|
||||
import { toggleConfirmAsync } from '../../../prompts';
|
||||
import { MinimalAscApiKey } from '../credentials';
|
||||
|
||||
@@ -56,6 +56,33 @@ export function assertUserAuthCtx(authCtx: AuthCtx | undefined): UserAuthCtx {
|
||||
throw new Error('Expected user authentication context (login/password).');
|
||||
}
|
||||
|
||||
export function isIndividualAscApiKeyAuthCtx(authCtx: AuthCtx | undefined): boolean {
|
||||
return !!authCtx && 'ascApiKey' in authCtx && !!authCtx.ascApiKey && !authCtx.ascApiKey.issuerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple blocks individual (issuer-less) ASC API keys from the Provisioning endpoints
|
||||
* (certificates, profiles, bundle IDs, devices).
|
||||
*/
|
||||
export function assertProvisioningAuthCtx(authCtx: AuthCtx): void {
|
||||
if (!isIndividualAscApiKeyAuthCtx(authCtx)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
'The App Store Connect API key in use has no Issuer ID, so it is an individual API key. ' +
|
||||
'Apple blocks individual API keys from managing certificates, provisioning profiles, and devices (Provisioning endpoints). ' +
|
||||
'If this is a team key, provide its Issuer ID: set EXPO_ASC_ISSUER_ID when the key comes from environment variables, ' +
|
||||
"or run 'eas credentials' and upload the key again with its Issuer ID when it is stored on EAS.\n" +
|
||||
'To continue, do one of the following:\n' +
|
||||
` - Use a team API key (one with an Issuer ID). Run 'eas credentials' to set one up, or set EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID and EXPO_ASC_ISSUER_ID. ${learnMore(
|
||||
'https://expo.fyi/creating-asc-api-key'
|
||||
)}\n` +
|
||||
` - Provide a distribution certificate and provisioning profile via credentials.json. ${learnMore(
|
||||
'https://docs.expo.dev/app-signing/local-credentials/'
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
export function getRequestContext(authCtx: AuthCtx): RequestContext {
|
||||
assert(authCtx.authState?.context, 'Apple request context must be defined');
|
||||
return authCtx.authState.context;
|
||||
|
||||
@@ -87,19 +87,40 @@ async function getAscKeyIdFromEnvironmentOrOptionsAsync(
|
||||
|
||||
async function getAscIssuerIdFromEnvironmentOrOptionsAsync(
|
||||
ascApiKey?: MinimalAscApiKey
|
||||
): Promise<string> {
|
||||
): Promise<string | undefined> {
|
||||
if (ascApiKey?.issuerId) {
|
||||
return ascApiKey?.issuerId;
|
||||
} else if (process.env.EXPO_ASC_ISSUER_ID) {
|
||||
return ascApiKey.issuerId;
|
||||
}
|
||||
|
||||
if (process.env.EXPO_ASC_ISSUER_ID) {
|
||||
if (ascApiKey?.keyId) {
|
||||
Log.warn(
|
||||
`App Store Connect API key ${ascApiKey.keyId} was configured without an Issuer ID, so EXPO_ASC_ISSUER_ID is used for it instead. App Store Connect rejects requests when the Issuer ID belongs to a different key. Unset EXPO_ASC_ISSUER_ID if ${ascApiKey.keyId} is an individual API key, or configure the Issuer ID that belongs to it.`
|
||||
);
|
||||
}
|
||||
return process.env.EXPO_ASC_ISSUER_ID;
|
||||
}
|
||||
|
||||
const keyId = ascApiKey?.keyId ?? process.env.EXPO_ASC_KEY_ID;
|
||||
const keyP8IsResolvable = !!ascApiKey?.keyP8 || !!process.env.EXPO_ASC_API_KEY_PATH;
|
||||
if (keyId && keyP8IsResolvable) {
|
||||
Log.debug(
|
||||
`App Store Connect API key ${keyId} has no Issuer ID, treating it as an individual API key.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { ascIssuerId } = await promptAsync({
|
||||
type: 'text',
|
||||
name: 'ascIssuerId',
|
||||
message: `ASC Issuer ID:`,
|
||||
validate: (val: string) => val !== '',
|
||||
message: `ASC Issuer ID (leave empty for an individual API key):`,
|
||||
});
|
||||
if (!ascIssuerId) {
|
||||
Log.debug(
|
||||
'No Issuer ID provided, treating the App Store Connect API key as an individual API key.'
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
return ascIssuerId;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,4 +54,41 @@ describe('print credentials', () => {
|
||||
.mock.calls.reduce((acc, mockValue) => acc + mockValue, '');
|
||||
expect(loggedSoFar).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('prints the key type for an individual (issuer-less) ASC API key', async () => {
|
||||
jest.mocked(Log.log).mockClear();
|
||||
const graphqlClient = instance(mock<ExpoGraphqlClient>());
|
||||
const app: App = {
|
||||
account: {
|
||||
id: 'account-id',
|
||||
name: 'quinlanj',
|
||||
viewerUserPermission: { role: Role.Owner },
|
||||
},
|
||||
projectName: 'test52',
|
||||
};
|
||||
const testIosAppCredentialsData = JSON.parse(
|
||||
JSON.stringify(
|
||||
nullthrows(
|
||||
await IosAppCredentialsQuery.withCommonFieldsByAppIdentifierIdAsync(
|
||||
graphqlClient,
|
||||
'@quinlanj/test52',
|
||||
{
|
||||
appleAppIdentifierId: 'test-id',
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
testIosAppCredentialsData.appStoreConnectApiKeyForSubmissions.issuerIdentifier = null;
|
||||
const targets: Target[] = [
|
||||
{ targetName: 'test52', bundleIdentifier: 'com.quinlanj.test52', entitlements: {} },
|
||||
];
|
||||
displayIosCredentials(app, { test52: testIosAppCredentialsData }, targets);
|
||||
const loggedSoFar = jest
|
||||
.mocked(Log.log)
|
||||
.mock.calls.reduce((acc, mockValue) => acc + mockValue, '');
|
||||
expect(loggedSoFar).toContain('Key Type');
|
||||
expect(loggedSoFar).toContain('Individual (submissions only)');
|
||||
expect(loggedSoFar).not.toContain('Issuer ID');
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,7 @@ export async function runDeveloperPortalMethodAsync(
|
||||
account: AccountFragment,
|
||||
appleTeam: Pick<AppleTeam, 'appleTeamIdentifier' | 'appleTeamName' | 'id'>
|
||||
): Promise<void> {
|
||||
const appleAuthCtx = await appStoreApi.ensureAuthenticatedAsync();
|
||||
const appleAuthCtx = await appStoreApi.ensureProvisioningAuthenticatedAsync();
|
||||
const unregisteredPortalDevices = await findUnregisteredPortalDevicesAsync(
|
||||
graphqlClient,
|
||||
appleAuthCtx,
|
||||
|
||||
@@ -21,4 +21,8 @@ export default {
|
||||
* Comma separated list of feature gate keys of feature gates to evaluate override to false.
|
||||
*/
|
||||
featureGateDisable: process.env.EAS_FG_DISABLE,
|
||||
/**
|
||||
* Enables the individual App Store Connect API key option in the interactive submission key prompt.
|
||||
*/
|
||||
enableIndividualAscApiKeys: process.env.EAS_ENABLE_INDIVIDUAL_ASC_API_KEYS,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Agent } from 'https';
|
||||
import createHttpsProxyAgent from 'https-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import fetch, { RequestInfo, RequestInit, Response } from 'node-fetch';
|
||||
|
||||
export { Headers, RequestInfo, RequestInit, Response } from 'node-fetch';
|
||||
@@ -18,7 +18,7 @@ function createHttpsAgent(): Agent | null {
|
||||
if (!httpsProxyUrl) {
|
||||
return null;
|
||||
}
|
||||
return createHttpsProxyAgent(httpsProxyUrl);
|
||||
return new HttpsProxyAgent(httpsProxyUrl);
|
||||
}
|
||||
|
||||
export const httpsProxyAgent: Agent | null = createHttpsAgent();
|
||||
|
||||
+156
-8
@@ -1101,6 +1101,8 @@ export type AgentDeviceRunSessionRemoteConfig = {
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
@@ -2947,6 +2949,8 @@ export type AppObserveErrorOccurrencesFilter = {
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
countryCode?: InputMaybe<Scalars['String']['input']>;
|
||||
deviceModel?: InputMaybe<Scalars['String']['input']>;
|
||||
easClientId?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime?: InputMaybe<Scalars['DateTime']['input']>;
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -3129,6 +3133,8 @@ export type AppObserveErrorsBreakdownInput = {
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
countryCode?: InputMaybe<Scalars['String']['input']>;
|
||||
deviceModel?: InputMaybe<Scalars['String']['input']>;
|
||||
dimension: AppObserveErrorBreakdownDimension;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
@@ -3148,6 +3154,8 @@ export type AppObserveErrorsGroupsInput = {
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Bucket size for each group's timeSeries. Defaults to daily. */
|
||||
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
|
||||
countryCode?: InputMaybe<Scalars['String']['input']>;
|
||||
deviceModel?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Restrict to one error group. */
|
||||
@@ -3168,6 +3176,8 @@ export type AppObserveErrorsStatsInput = {
|
||||
appEasBuildId?: InputMaybe<Scalars['String']['input']>;
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
countryCode?: InputMaybe<Scalars['String']['input']>;
|
||||
deviceModel?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
isEmbeddedUpdate?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
@@ -3184,6 +3194,8 @@ export type AppObserveErrorsTimeSeriesInput = {
|
||||
appUpdateId?: InputMaybe<Scalars['String']['input']>;
|
||||
appVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
bucketIntervalMinutes?: InputMaybe<Scalars['Int']['input']>;
|
||||
countryCode?: InputMaybe<Scalars['String']['input']>;
|
||||
deviceModel?: InputMaybe<Scalars['String']['input']>;
|
||||
endTime: Scalars['DateTime']['input'];
|
||||
environment?: InputMaybe<Scalars['String']['input']>;
|
||||
/** Restrict to one error group. */
|
||||
@@ -4719,6 +4731,8 @@ export type AppiumRunSessionRemoteConfig = {
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
/**
|
||||
@@ -5147,6 +5161,8 @@ export type ArgentRunSessionRemoteConfig = {
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
toolsAuthToken?: Maybe<Scalars['String']['output']>;
|
||||
toolsUrl: Scalars['String']['output'];
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
@@ -5683,12 +5699,14 @@ export type BuildFilterInput = {
|
||||
channel?: InputMaybe<Scalars['String']['input']>;
|
||||
developmentClient?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
distributions?: InputMaybe<Array<DistributionType>>;
|
||||
expired?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
fingerprintHash?: InputMaybe<Scalars['String']['input']>;
|
||||
hasFingerprint?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
platforms?: InputMaybe<Array<AppPlatform>>;
|
||||
releaseChannel?: InputMaybe<Scalars['String']['input']>;
|
||||
runtimeVersion?: InputMaybe<Scalars['String']['input']>;
|
||||
simulator?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
statuses?: InputMaybe<Array<BuildStatus>>;
|
||||
};
|
||||
|
||||
export enum BuildIosEnterpriseProvisioning {
|
||||
@@ -6589,6 +6607,14 @@ export type CreatePostHogDeepLinkInput = {
|
||||
purpose?: InputMaybe<PostHogDeepLinkPurpose>;
|
||||
};
|
||||
|
||||
export type CreateSandboxInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
image?: InputMaybe<Scalars['String']['input']>;
|
||||
operatingSystem: SandboxOperatingSystem;
|
||||
projectArchive?: InputMaybe<SandboxProjectArchiveInput>;
|
||||
resourceClass: SandboxResourceClass;
|
||||
};
|
||||
|
||||
export type CreateSentryProjectInput = {
|
||||
appId: Scalars['ID']['input'];
|
||||
sentryProjectId: Scalars['String']['input'];
|
||||
@@ -10121,6 +10147,7 @@ export enum Permission {
|
||||
Admin = 'ADMIN',
|
||||
Own = 'OWN',
|
||||
Publish = 'PUBLISH',
|
||||
PublishProtected = 'PUBLISH_PROTECTED',
|
||||
View = 'VIEW'
|
||||
}
|
||||
|
||||
@@ -10274,7 +10301,6 @@ export type ProjectArchiveSourceInput = {
|
||||
bucketKey?: InputMaybe<Scalars['String']['input']>;
|
||||
gitRef?: InputMaybe<Scalars['String']['input']>;
|
||||
metadataLocation?: InputMaybe<Scalars['String']['input']>;
|
||||
repositoryUrl?: InputMaybe<Scalars['String']['input']>;
|
||||
type: ProjectArchiveSourceType;
|
||||
url?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
@@ -10329,6 +10355,7 @@ export type PublishUpdateGroupInput = {
|
||||
isGitWorkingTreeDirty?: InputMaybe<Scalars['Boolean']['input']>;
|
||||
manifestHostOverride?: InputMaybe<Scalars['String']['input']>;
|
||||
message?: InputMaybe<Scalars['String']['input']>;
|
||||
previousRolloutUpdateToClobberIdGroup?: InputMaybe<UpdateIdGroup>;
|
||||
rollBackToEmbeddedInfoGroup?: InputMaybe<UpdateRollBackToEmbeddedGroup>;
|
||||
rolloutInfoGroup?: InputMaybe<UpdateRolloutInfoGroup>;
|
||||
runtimeVersion: Scalars['String']['input'];
|
||||
@@ -10528,6 +10555,7 @@ export enum Role {
|
||||
HasAdmin = 'HAS_ADMIN',
|
||||
NotAdmin = 'NOT_ADMIN',
|
||||
Owner = 'OWNER',
|
||||
ReleaseManager = 'RELEASE_MANAGER',
|
||||
ViewOnly = 'VIEW_ONLY'
|
||||
}
|
||||
|
||||
@@ -10654,6 +10682,7 @@ export type RootMutation = {
|
||||
realtimeLogs: RealtimeLogsMutation;
|
||||
/** Mutations that create, update, and delete Robots */
|
||||
robot: RobotMutation;
|
||||
sandbox: SandboxMutation;
|
||||
/** Mutations for Sentry installations */
|
||||
sentryInstallation: SentryInstallationMutation;
|
||||
/** Mutations for Sentry projects */
|
||||
@@ -10669,6 +10698,7 @@ export type RootMutation = {
|
||||
updateBranch: UpdateBranchMutation;
|
||||
updateChannel: UpdateChannelMutation;
|
||||
uploadSession: UploadSession;
|
||||
usageBudget: UsageBudgetMutation;
|
||||
/** Mutations that create, update, and delete pinned apps */
|
||||
userAppPins: UserAppPinMutation;
|
||||
userAuditLog: UserAuditLogMutation;
|
||||
@@ -11057,16 +11087,87 @@ export type Sandbox = {
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export type SandboxConnection = {
|
||||
__typename?: 'SandboxConnection';
|
||||
edges: Array<SandboxEdge>;
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type SandboxEdge = {
|
||||
__typename?: 'SandboxEdge';
|
||||
cursor: Scalars['String']['output'];
|
||||
node: Sandbox;
|
||||
};
|
||||
|
||||
export type SandboxFilterInput = {
|
||||
statuses?: InputMaybe<Array<SandboxStatus>>;
|
||||
};
|
||||
|
||||
export type SandboxMutation = {
|
||||
__typename?: 'SandboxMutation';
|
||||
createSandbox: Sandbox;
|
||||
markSandboxReady: Sandbox;
|
||||
stopSandbox: Sandbox;
|
||||
touchSandbox: Sandbox;
|
||||
};
|
||||
|
||||
|
||||
export type SandboxMutation_CreateSandboxArgs = {
|
||||
input: CreateSandboxInput;
|
||||
};
|
||||
|
||||
|
||||
export type SandboxMutation_MarkSandboxReadyArgs = {
|
||||
sandboxId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type SandboxMutation_StopSandboxArgs = {
|
||||
sandboxId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type SandboxMutation_TouchSandboxArgs = {
|
||||
sandboxId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum SandboxOperatingSystem {
|
||||
Linux = 'LINUX',
|
||||
Macos = 'MACOS'
|
||||
}
|
||||
|
||||
export type SandboxProjectArchiveInput = {
|
||||
gitRef?: InputMaybe<Scalars['String']['input']>;
|
||||
url?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type SandboxQuery = {
|
||||
__typename?: 'SandboxQuery';
|
||||
/** Project sandboxes, newest first. An omitted or empty status filter includes all statuses. */
|
||||
byAppIdPaginated: SandboxConnection;
|
||||
byId: Sandbox;
|
||||
};
|
||||
|
||||
|
||||
export type SandboxQuery_ByAppIdPaginatedArgs = {
|
||||
after?: InputMaybe<Scalars['String']['input']>;
|
||||
appId: Scalars['ID']['input'];
|
||||
before?: InputMaybe<Scalars['String']['input']>;
|
||||
filter?: InputMaybe<SandboxFilterInput>;
|
||||
first?: InputMaybe<Scalars['Int']['input']>;
|
||||
last?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type SandboxQuery_ByIdArgs = {
|
||||
sandboxId: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
export enum SandboxResourceClass {
|
||||
Large = 'LARGE',
|
||||
Medium = 'MEDIUM'
|
||||
}
|
||||
|
||||
export enum SandboxStatus {
|
||||
Errored = 'ERRORED',
|
||||
Running = 'RUNNING',
|
||||
@@ -11213,6 +11314,8 @@ export type ServeSimRunSessionRemoteConfig = {
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the preview. Null when the preview runs ungated. */
|
||||
previewToken?: Maybe<Scalars['String']['output']>;
|
||||
previewUrl: Scalars['String']['output'];
|
||||
@@ -12072,7 +12175,7 @@ export type UpdateChannel = {
|
||||
embeddedUpdateCount: Scalars['Int']['output'];
|
||||
id: Scalars['ID']['output'];
|
||||
isPaused: Scalars['Boolean']['output'];
|
||||
/** Only account admins may release to a protected channel. */
|
||||
/** Only allowed publishers (Release Managers, Admins, Owners) may release to a protected channel. */
|
||||
isProtected: Scalars['Boolean']['output'];
|
||||
lastDeletionAttemptTime?: Maybe<Scalars['DateTime']['output']>;
|
||||
latestRuntimes: ChannelRuntimesConnection;
|
||||
@@ -12127,7 +12230,7 @@ export type UpdateChannelMutation = {
|
||||
editUpdateChannel: UpdateChannel;
|
||||
/** Pause updates for an EAS channel. */
|
||||
pauseUpdateChannel: UpdateChannel;
|
||||
/** Protect an EAS channel, so only account admins may release to it. */
|
||||
/** Protect an EAS channel, so only allowed publishers (Release Managers, Admins, Owners) may release to it. */
|
||||
protectUpdateChannel: UpdateChannel;
|
||||
/** Resume updates for an EAS channel. */
|
||||
resumeUpdateChannel: UpdateChannel;
|
||||
@@ -12380,6 +12483,12 @@ export type UpdateGroupsConnection = {
|
||||
pageInfo: PageInfo;
|
||||
};
|
||||
|
||||
export type UpdateIdGroup = {
|
||||
android?: InputMaybe<Scalars['ID']['input']>;
|
||||
ios?: InputMaybe<Scalars['ID']['input']>;
|
||||
web?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
|
||||
export type UpdateInfoGroup = {
|
||||
android?: InputMaybe<PartialManifest>;
|
||||
ios?: InputMaybe<PartialManifest>;
|
||||
@@ -12577,6 +12686,31 @@ export enum UploadSessionType {
|
||||
EasUpdateFingerprint = 'EAS_UPDATE_FINGERPRINT'
|
||||
}
|
||||
|
||||
export type UsageBudget = {
|
||||
__typename?: 'UsageBudget';
|
||||
id: Scalars['ID']['output'];
|
||||
limits: UsageBudgetLimits;
|
||||
};
|
||||
|
||||
export type UsageBudgetLimits = {
|
||||
__typename?: 'UsageBudgetLimits';
|
||||
builds?: Maybe<Scalars['Int']['output']>;
|
||||
};
|
||||
|
||||
export type UsageBudgetLimitsInput = {
|
||||
builds?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
export type UsageBudgetMutation = {
|
||||
__typename?: 'UsageBudgetMutation';
|
||||
createUsageBudget: UsageBudget;
|
||||
};
|
||||
|
||||
|
||||
export type UsageBudgetMutation_CreateUsageBudgetArgs = {
|
||||
limits: UsageBudgetLimitsInput;
|
||||
};
|
||||
|
||||
export type UsageMetricTotal = {
|
||||
__typename?: 'UsageMetricTotal';
|
||||
billingPeriod: BillingPeriod;
|
||||
@@ -13336,6 +13470,8 @@ export type WebPreviewOnlyRunSessionRemoteConfig = {
|
||||
* session does not use local egress.
|
||||
*/
|
||||
egressUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** URL of the preview server, for reaching its API rather than its page. */
|
||||
previewApiUrl?: Maybe<Scalars['String']['output']>;
|
||||
/** Session token gating the web preview. Null when the preview runs ungated. */
|
||||
webPreviewToken?: Maybe<Scalars['String']['output']>;
|
||||
webPreviewUrl: Scalars['String']['output'];
|
||||
@@ -14565,6 +14701,7 @@ export type WorkflowRun = ActivityTimelineProjectActivity & {
|
||||
__typename?: 'WorkflowRun';
|
||||
activityTimestamp: Scalars['DateTime']['output'];
|
||||
actor?: Maybe<Actor>;
|
||||
app: App;
|
||||
/**
|
||||
* Why the server canceled this run. Null for manually canceled runs and for
|
||||
* runs canceled before the reason was recorded.
|
||||
@@ -16386,11 +16523,11 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
|
||||
|
||||
|
||||
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, name?: string | null, tags: Array<string>, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, updatedAt: any, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, artifacts: Array<{ __typename?: 'DeviceRunSessionArtifact', id: string, name: string, filename: string, downloadUrl: string, fileSizeBytes?: number | null, metadata?: any | null, createdAt: any, updatedAt: any }>, remoteConfig?:
|
||||
| { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null, webPreviewToken?: string | null, egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null }
|
||||
| { __typename: 'AppiumRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, appiumUrl: string, capabilities: any, webPreviewUrl?: string | null, webPreviewToken?: string | null }
|
||||
| { __typename: 'ArgentRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null, webPreviewToken?: string | null }
|
||||
| { __typename: 'ServeSimRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null }
|
||||
| { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null }
|
||||
| { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null, egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null }
|
||||
| { __typename: 'AppiumRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, appiumUrl: string, capabilities: any, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null }
|
||||
| { __typename: 'ArgentRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, toolsUrl: string, toolsAuthToken?: string | null, webPreviewUrl?: string | null, webPreviewToken?: string | null, previewApiUrl?: string | null }
|
||||
| { __typename: 'ServeSimRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewUrl: string, previewToken?: string | null, previewApiUrl?: string | null }
|
||||
| { __typename: 'WebPreviewOnlyRunSessionRemoteConfig', egressUrl?: string | null, egressToken?: string | null, egressFingerprint?: string | null, egressPort?: number | null, previewApiUrl?: string | null, previewUrl: string, previewToken?: string | null }
|
||||
| null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
|
||||
|
||||
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
|
||||
@@ -16706,6 +16843,15 @@ export type SupabaseProjectByAppIdQueryVariables = Exact<{
|
||||
|
||||
export type SupabaseProjectByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, supabaseProject?: { __typename?: 'SupabaseProject', id: string, supabaseProjectRef: string, supabaseProjectName: string, supabaseProjectUrl: string, supabaseRegion: string, createdAt: any, updatedAt: any } | null } } };
|
||||
|
||||
export type SupabaseAdvisorLintsByAppIdQueryVariables = Exact<{
|
||||
appId: Scalars['String']['input'];
|
||||
security: Scalars['Boolean']['input'];
|
||||
performance: Scalars['Boolean']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type SupabaseAdvisorLintsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, supabaseProject?: { __typename?: 'SupabaseProject', id: string, supabaseProjectRef: string, supabaseProjectName: string, supabaseProjectUrl: string, supabaseRegion: string, createdAt: any, updatedAt: any, security?: Array<{ __typename?: 'SupabaseAdvisorLint', name: string, title: string, level: SupabaseAdvisorLintLevel, description: string, detail: string, entity?: string | null, remediation?: string | null, cacheKey: string }> | null, performance?: Array<{ __typename?: 'SupabaseAdvisorLint', name: string, title: string, level: SupabaseAdvisorLintLevel, description: string, detail: string, entity?: string | null, remediation?: string | null, cacheKey: string }> | null } | null } } };
|
||||
|
||||
export type ViewUpdateGroupInsightsQueryVariables = Exact<{
|
||||
groupId: Scalars['ID']['input'];
|
||||
timespan: InsightsTimespan;
|
||||
@@ -17091,6 +17237,8 @@ export type SupabaseConnectionFragment = { __typename?: 'SupabaseConnection', id
|
||||
|
||||
export type SupabaseProjectFragment = { __typename?: 'SupabaseProject', id: string, supabaseProjectRef: string, supabaseProjectName: string, supabaseProjectUrl: string, supabaseRegion: string, createdAt: any, updatedAt: any };
|
||||
|
||||
export type SupabaseAdvisorLintFragment = { __typename?: 'SupabaseAdvisorLint', name: string, title: string, level: SupabaseAdvisorLintLevel, description: string, detail: string, entity?: string | null, remediation?: string | null, cacheKey: string };
|
||||
|
||||
export type UpdateFragment = { __typename?: 'Update', id: string, group: string, message?: string | null, createdAt: any, platform: string, manifestFragment: string, isRollBackToEmbedded: boolean, manifestPermalink: string, gitCommitHash?: string | null, isGitWorkingTreeDirty: boolean, environment?: any | null, rolloutPercentage?: number | null, manifestHostOverride?: string | null, assetHostOverride?: string | null, runtime: { __typename?: 'Runtime', id: string, version: string }, actor?:
|
||||
| { __typename: 'PartnerActor', username: string, id: string }
|
||||
| { __typename: 'Robot', firstName?: string | null, id: string }
|
||||
|
||||
@@ -88,6 +88,7 @@ export const DeviceRunSessionQuery = {
|
||||
agentDeviceRemoteSessionToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
previewApiUrl
|
||||
egressUrl
|
||||
egressToken
|
||||
egressFingerprint
|
||||
@@ -102,6 +103,7 @@ export const DeviceRunSessionQuery = {
|
||||
toolsAuthToken
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
previewApiUrl
|
||||
}
|
||||
... on AppiumRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
@@ -112,6 +114,7 @@ export const DeviceRunSessionQuery = {
|
||||
capabilities
|
||||
webPreviewUrl
|
||||
webPreviewToken
|
||||
previewApiUrl
|
||||
}
|
||||
... on ServeSimRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
@@ -120,6 +123,7 @@ export const DeviceRunSessionQuery = {
|
||||
egressPort
|
||||
previewUrl
|
||||
previewToken
|
||||
previewApiUrl
|
||||
}
|
||||
... on WebPreviewOnlyRunSessionRemoteConfig {
|
||||
egressUrl
|
||||
@@ -128,6 +132,7 @@ export const DeviceRunSessionQuery = {
|
||||
egressPort
|
||||
previewUrl: webPreviewUrl
|
||||
previewToken: webPreviewToken
|
||||
previewApiUrl
|
||||
}
|
||||
}
|
||||
turtleJobRun {
|
||||
|
||||
@@ -4,6 +4,13 @@ import gql from 'graphql-tag';
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { withErrorHandlingAsync } from '../client';
|
||||
import {
|
||||
SupabaseAdvisorLintsByAppIdQuery,
|
||||
SupabaseAdvisorLintsByAppIdQueryVariables,
|
||||
} from '../generated';
|
||||
import {
|
||||
SupabaseAdvisorLintFragmentNode,
|
||||
SupabaseAdvisorLintsData,
|
||||
SupabaseAdvisorType,
|
||||
SupabaseConnectionData,
|
||||
SupabaseConnectionFragmentNode,
|
||||
SupabaseProjectData,
|
||||
@@ -94,4 +101,57 @@ export const SupabaseQuery = {
|
||||
);
|
||||
return data.app.byId.supabaseProject ?? null;
|
||||
},
|
||||
|
||||
async getSupabaseAdvisorLintsByAppIdAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
appId: string,
|
||||
types: readonly SupabaseAdvisorType[] = [
|
||||
SupabaseAdvisorType.Security,
|
||||
SupabaseAdvisorType.Performance,
|
||||
]
|
||||
): Promise<SupabaseAdvisorLintsData | null> {
|
||||
const data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<SupabaseAdvisorLintsByAppIdQuery, SupabaseAdvisorLintsByAppIdQueryVariables>(
|
||||
gql`
|
||||
query SupabaseAdvisorLintsByAppId(
|
||||
$appId: String!
|
||||
$security: Boolean!
|
||||
$performance: Boolean!
|
||||
) {
|
||||
app {
|
||||
byId(appId: $appId) {
|
||||
id
|
||||
supabaseProject {
|
||||
id
|
||||
...SupabaseProjectFragment
|
||||
security: advisorLints(type: SECURITY) @include(if: $security) {
|
||||
...SupabaseAdvisorLintFragment
|
||||
}
|
||||
performance: advisorLints(type: PERFORMANCE) @include(if: $performance) {
|
||||
...SupabaseAdvisorLintFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${print(SupabaseProjectFragmentNode)}
|
||||
${print(SupabaseAdvisorLintFragmentNode)}
|
||||
`,
|
||||
{
|
||||
appId,
|
||||
security: types.includes(SupabaseAdvisorType.Security),
|
||||
performance: types.includes(SupabaseAdvisorType.Performance),
|
||||
},
|
||||
{ additionalTypenames: ['App', 'SupabaseProject'], requestPolicy: 'network-only' }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
const project = data.app.byId.supabaseProject;
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
const { security, performance, ...projectData } = project;
|
||||
return { project: projectData, security: security ?? null, performance: performance ?? null };
|
||||
},
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import gql from 'graphql-tag';
|
||||
|
||||
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { GraphqlError, withErrorHandlingAsync } from '../client';
|
||||
import { JobRunStatus, WorkflowJobStatus } from '../generated';
|
||||
import { BuildStatus, JobRunStatus, WorkflowJobStatus } from '../generated';
|
||||
|
||||
const FINAL_WORKFLOW_JOB_STATUSES = new Set<WorkflowJobStatus>([
|
||||
WorkflowJobStatus.Success,
|
||||
@@ -11,6 +11,12 @@ const FINAL_WORKFLOW_JOB_STATUSES = new Set<WorkflowJobStatus>([
|
||||
WorkflowJobStatus.Skipped,
|
||||
]);
|
||||
|
||||
const FINAL_BUILD_STATUSES = new Set<BuildStatus>([
|
||||
BuildStatus.Errored,
|
||||
BuildStatus.Finished,
|
||||
BuildStatus.Canceled,
|
||||
]);
|
||||
|
||||
const FINAL_JOB_RUN_STATUSES = new Set<JobRunStatus>([
|
||||
JobRunStatus.Errored,
|
||||
JobRunStatus.Finished,
|
||||
@@ -53,6 +59,20 @@ type WorkflowJobSshPollQueryVariables = {
|
||||
workflowJobId: string;
|
||||
};
|
||||
|
||||
type BuildSshPollQuery = {
|
||||
builds: {
|
||||
byId: {
|
||||
id: string;
|
||||
status: BuildStatus;
|
||||
sshSession: WorkflowJobSshSession | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type BuildSshPollQueryVariables = {
|
||||
buildId: string;
|
||||
};
|
||||
|
||||
type JobRunSshPollQuery = {
|
||||
jobRun: {
|
||||
byId: {
|
||||
@@ -154,6 +174,52 @@ export const WorkflowJobSshQuery = {
|
||||
return toConnectInfo(data.workflowJobs.byId);
|
||||
},
|
||||
|
||||
async connectInfoForBuildAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
buildId: string
|
||||
): Promise<WorkflowJobSshConnectInfo | null> {
|
||||
let data: BuildSshPollQuery;
|
||||
try {
|
||||
data = await withErrorHandlingAsync(
|
||||
graphqlClient
|
||||
.query<BuildSshPollQuery, BuildSshPollQueryVariables>(
|
||||
gql`
|
||||
query BuildSshPoll($buildId: ID!) {
|
||||
builds {
|
||||
byId(buildId: $buildId) {
|
||||
id
|
||||
status
|
||||
sshSession {
|
||||
id
|
||||
connectionConfig {
|
||||
host
|
||||
secret
|
||||
reconnecting
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ buildId },
|
||||
{ requestPolicy: 'network-only' }
|
||||
)
|
||||
.toPromise()
|
||||
);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const build = data.builds.byId;
|
||||
return {
|
||||
sshRequested: build.sshSession !== null,
|
||||
jobCompleted: FINAL_BUILD_STATUSES.has(build.status),
|
||||
session: build.sshSession,
|
||||
};
|
||||
},
|
||||
|
||||
async connectInfoForJobRunAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
jobRunId: string
|
||||
@@ -207,6 +273,7 @@ export const WorkflowJobSshQuery = {
|
||||
): Promise<WorkflowJobSshConnectInfo | null> {
|
||||
return (
|
||||
(await WorkflowJobSshQuery.connectInfoForWorkflowJobAsync(graphqlClient, resourceId)) ??
|
||||
(await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, resourceId)) ??
|
||||
(await WorkflowJobSshQuery.connectInfoForJobRunAsync(graphqlClient, resourceId))
|
||||
);
|
||||
},
|
||||
|
||||
@@ -98,4 +98,37 @@ describe('SupabaseQuery', () => {
|
||||
expect.objectContaining({ requestPolicy: 'network-only' })
|
||||
);
|
||||
});
|
||||
|
||||
it('getSupabaseAdvisorLintsByAppIdAsync splits the project from the aliased advisor lists', async () => {
|
||||
const lint = {
|
||||
name: 'rls_disabled_in_public',
|
||||
title: 'RLS Disabled in Public',
|
||||
level: 'ERROR',
|
||||
description: 'Detects tables without RLS.',
|
||||
detail: 'Table `public.todos` is public, but RLS has not been enabled.',
|
||||
entity: 'public.todos',
|
||||
remediation: null,
|
||||
cacheKey: 'rls_disabled_in_public_public_todos',
|
||||
};
|
||||
const client = makeQueryClient({
|
||||
app: { byId: { id: 'app-1', supabaseProject: { ...project, security: [lint] } } },
|
||||
});
|
||||
|
||||
await expect(
|
||||
SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync(client, 'app-1')
|
||||
).resolves.toEqual({ project, security: [lint], performance: null });
|
||||
expect(client.query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ appId: 'app-1', security: true, performance: true },
|
||||
expect.objectContaining({ requestPolicy: 'network-only' })
|
||||
);
|
||||
});
|
||||
|
||||
it('getSupabaseAdvisorLintsByAppIdAsync returns null when no project is linked', async () => {
|
||||
const client = makeQueryClient({ app: { byId: { id: 'app-1', supabaseProject: null } } });
|
||||
|
||||
await expect(
|
||||
SupabaseQuery.getSupabaseAdvisorLintsByAppIdAsync(client, 'app-1')
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
|
||||
import { GraphqlError } from '../../client';
|
||||
import { WorkflowJobStatus } from '../../generated';
|
||||
import { BuildStatus, JobRunStatus, WorkflowJobStatus } from '../../generated';
|
||||
import { WorkflowJobSshQuery } from '../WorkflowJobSshQuery';
|
||||
|
||||
describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => {
|
||||
@@ -153,3 +153,207 @@ describe(WorkflowJobSshQuery.connectInfoForWorkflowJobAsync.name, () => {
|
||||
).rejects.toThrow('network down');
|
||||
});
|
||||
});
|
||||
|
||||
describe(WorkflowJobSshQuery.connectInfoForBuildAsync.name, () => {
|
||||
function makeClient(byId: unknown): {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
query: jest.Mock;
|
||||
} {
|
||||
const query = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({ data: { builds: { byId } } }),
|
||||
});
|
||||
return { graphqlClient: { query } as unknown as ExpoGraphqlClient, query };
|
||||
}
|
||||
|
||||
it('maps builds.byId into connect info', async () => {
|
||||
const { graphqlClient, query } = makeClient({
|
||||
id: 'build-1',
|
||||
status: BuildStatus.InProgress,
|
||||
sshSession: {
|
||||
id: 'ts-1',
|
||||
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'build-1')).toEqual({
|
||||
sshRequested: true,
|
||||
jobCompleted: false,
|
||||
session: {
|
||||
id: 'ts-1',
|
||||
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
|
||||
},
|
||||
});
|
||||
expect(query).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ buildId: 'build-1' },
|
||||
{ requestPolicy: 'network-only' }
|
||||
);
|
||||
});
|
||||
|
||||
it('is not ssh-requested when the build has no sshSession', async () => {
|
||||
const { graphqlClient } = makeClient({
|
||||
id: 'build-1',
|
||||
status: BuildStatus.InProgress,
|
||||
sshSession: null,
|
||||
});
|
||||
|
||||
expect(await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'build-1')).toEqual({
|
||||
sshRequested: false,
|
||||
jobCompleted: false,
|
||||
session: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([BuildStatus.Errored, BuildStatus.Finished, BuildStatus.Canceled])(
|
||||
'treats %s as completed',
|
||||
async status => {
|
||||
const { graphqlClient } = makeClient({ id: 'build-1', status, sshSession: null });
|
||||
|
||||
expect(
|
||||
await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'build-1')
|
||||
).toMatchObject({ jobCompleted: true });
|
||||
}
|
||||
);
|
||||
|
||||
it.each([BuildStatus.New, BuildStatus.InQueue, BuildStatus.PendingCancel])(
|
||||
'treats %s as not completed',
|
||||
async status => {
|
||||
const { graphqlClient } = makeClient({ id: 'build-1', status, sshSession: null });
|
||||
|
||||
expect(
|
||||
await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'build-1')
|
||||
).toMatchObject({ jobCompleted: false });
|
||||
}
|
||||
);
|
||||
|
||||
it('returns null when the build is not found', async () => {
|
||||
const query = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({
|
||||
data: undefined,
|
||||
error: new GraphqlError({
|
||||
graphQLErrors: [
|
||||
{ message: 'Entity not found', extensions: { errorCode: 'ENTITY_NOT_FOUND' } },
|
||||
],
|
||||
networkError: undefined,
|
||||
response: undefined,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const graphqlClient = { query } as unknown as ExpoGraphqlClient;
|
||||
expect(await WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('rethrows GraphQL errors that are not not-found', async () => {
|
||||
const error = new GraphqlError({
|
||||
graphQLErrors: [{ message: 'boom', extensions: { errorCode: 'INTERNAL' } }],
|
||||
networkError: undefined,
|
||||
response: undefined,
|
||||
});
|
||||
const query = jest.fn().mockReturnValue({
|
||||
toPromise: async () => ({ data: undefined, error }),
|
||||
});
|
||||
const graphqlClient = { query } as unknown as ExpoGraphqlClient;
|
||||
await expect(
|
||||
WorkflowJobSshQuery.connectInfoForBuildAsync(graphqlClient, 'build-1')
|
||||
).rejects.toBe(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe(WorkflowJobSshQuery.connectInfoForResourceIdAsync.name, () => {
|
||||
const notFound = new GraphqlError({
|
||||
graphQLErrors: [{ message: 'Entity not found', extensions: { errorCode: 'ENTITY_NOT_FOUND' } }],
|
||||
networkError: undefined,
|
||||
response: undefined,
|
||||
});
|
||||
const session = {
|
||||
id: 'ts-1',
|
||||
connectionConfig: { host: 'relay.expo.dev', secret: 'TOKENx', reconnecting: false },
|
||||
};
|
||||
|
||||
function makeClient(found: { workflowJob?: unknown; build?: unknown; jobRun?: unknown }): {
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
query: jest.Mock;
|
||||
} {
|
||||
const query = jest.fn((_document: unknown, variables: Record<string, string>) => ({
|
||||
toPromise: async () => {
|
||||
if ('workflowJobId' in variables) {
|
||||
return found.workflowJob
|
||||
? { data: { workflowJobs: { byId: found.workflowJob } } }
|
||||
: { data: undefined, error: notFound };
|
||||
}
|
||||
if ('buildId' in variables) {
|
||||
return found.build
|
||||
? { data: { builds: { byId: found.build } } }
|
||||
: { data: undefined, error: notFound };
|
||||
}
|
||||
return found.jobRun
|
||||
? { data: { jobRun: { byId: found.jobRun } } }
|
||||
: { data: undefined, error: notFound };
|
||||
},
|
||||
}));
|
||||
return { graphqlClient: { query } as unknown as ExpoGraphqlClient, query };
|
||||
}
|
||||
|
||||
function queriedVariables(query: jest.Mock): unknown[] {
|
||||
return query.mock.calls.map(([, variables]) => variables);
|
||||
}
|
||||
|
||||
it('returns the workflow job without querying builds or job runs', async () => {
|
||||
const { graphqlClient, query } = makeClient({
|
||||
workflowJob: {
|
||||
id: 'id-1',
|
||||
status: WorkflowJobStatus.InProgress,
|
||||
workflowRun: { sshSettings: { idleTimeoutSeconds: 0 } },
|
||||
turtleJobRun: { sshSession: session },
|
||||
turtleBuild: null,
|
||||
},
|
||||
build: { id: 'id-1', status: BuildStatus.Finished, sshSession: null },
|
||||
});
|
||||
|
||||
expect(await WorkflowJobSshQuery.connectInfoForResourceIdAsync(graphqlClient, 'id-1')).toEqual({
|
||||
sshRequested: true,
|
||||
jobCompleted: false,
|
||||
session,
|
||||
});
|
||||
expect(queriedVariables(query)).toEqual([{ workflowJobId: 'id-1' }]);
|
||||
});
|
||||
|
||||
it('falls back to the build when the id is not a workflow job', async () => {
|
||||
const { graphqlClient, query } = makeClient({
|
||||
build: { id: 'id-1', status: BuildStatus.InProgress, sshSession: session },
|
||||
jobRun: { id: 'id-1', status: JobRunStatus.Finished, sshSession: null },
|
||||
});
|
||||
|
||||
expect(await WorkflowJobSshQuery.connectInfoForResourceIdAsync(graphqlClient, 'id-1')).toEqual({
|
||||
sshRequested: true,
|
||||
jobCompleted: false,
|
||||
session,
|
||||
});
|
||||
expect(queriedVariables(query)).toEqual([{ workflowJobId: 'id-1' }, { buildId: 'id-1' }]);
|
||||
});
|
||||
|
||||
it('falls back to the job run when the id is neither a workflow job nor a build', async () => {
|
||||
const { graphqlClient, query } = makeClient({
|
||||
jobRun: { id: 'id-1', status: JobRunStatus.InProgress, sshSession: session },
|
||||
});
|
||||
|
||||
expect(await WorkflowJobSshQuery.connectInfoForResourceIdAsync(graphqlClient, 'id-1')).toEqual({
|
||||
sshRequested: true,
|
||||
jobCompleted: false,
|
||||
session,
|
||||
});
|
||||
expect(queriedVariables(query)).toEqual([
|
||||
{ workflowJobId: 'id-1' },
|
||||
{ buildId: 'id-1' },
|
||||
{ jobRunId: 'id-1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when no resource matches the id', async () => {
|
||||
const { graphqlClient } = makeClient({});
|
||||
|
||||
expect(
|
||||
await WorkflowJobSshQuery.connectInfoForResourceIdAsync(graphqlClient, 'missing')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
import {
|
||||
SupabaseAdvisorLint,
|
||||
SupabaseConnection,
|
||||
SupabaseOAuthStart,
|
||||
SupabaseOrganization,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
} from '../generated';
|
||||
|
||||
export {
|
||||
SupabaseAdvisorLintLevel,
|
||||
SupabaseAdvisorType,
|
||||
BeginSupabaseOAuthInput,
|
||||
LinkSupabaseProjectInput,
|
||||
ProvisionAdditionalSupabaseProjectInput,
|
||||
@@ -35,6 +38,17 @@ export type SupabaseProjectData = Pick<
|
||||
|
||||
export type SupabaseOAuthStartData = Pick<SupabaseOAuthStart, 'url'>;
|
||||
|
||||
export type SupabaseAdvisorLintData = Pick<
|
||||
SupabaseAdvisorLint,
|
||||
'name' | 'title' | 'level' | 'description' | 'detail' | 'entity' | 'remediation' | 'cacheKey'
|
||||
>;
|
||||
|
||||
export type SupabaseAdvisorLintsData = {
|
||||
project: SupabaseProjectData;
|
||||
security: SupabaseAdvisorLintData[] | null;
|
||||
performance: SupabaseAdvisorLintData[] | null;
|
||||
};
|
||||
|
||||
export const SupabaseConnectionFragmentNode = gql`
|
||||
fragment SupabaseConnectionFragment on SupabaseConnection {
|
||||
id
|
||||
@@ -56,3 +70,16 @@ export const SupabaseProjectFragmentNode = gql`
|
||||
updatedAt
|
||||
}
|
||||
`;
|
||||
|
||||
export const SupabaseAdvisorLintFragmentNode = gql`
|
||||
fragment SupabaseAdvisorLintFragment on SupabaseAdvisorLint {
|
||||
name
|
||||
title
|
||||
level
|
||||
description
|
||||
detail
|
||||
entity
|
||||
remediation
|
||||
cacheKey
|
||||
}
|
||||
`;
|
||||
@@ -2,6 +2,7 @@ import { selectAsync } from '../../prompts';
|
||||
import { CredentialsContext } from '../../credentials/context';
|
||||
import {
|
||||
AppStoreApiKeyPurpose,
|
||||
filterOutIndividualAscApiKeys,
|
||||
formatAscApiKey,
|
||||
provideOrGenerateAscApiKeyAsync,
|
||||
sortAscApiKeysByUpdatedAtDesc,
|
||||
@@ -17,7 +18,8 @@ export async function selectOrCreateAscApiKeyIdAsync({
|
||||
existingKeys: AppStoreConnectApiKeyFragment[];
|
||||
ownerAccount: AccountFragment;
|
||||
}): Promise<string> {
|
||||
const sortedKeys = sortAscApiKeysByUpdatedAtDesc(existingKeys);
|
||||
// Individual API keys are not supported for the ASC connection yet.
|
||||
const sortedKeys = sortAscApiKeysByUpdatedAtDesc(filterOutIndividualAscApiKeys(existingKeys));
|
||||
const createKeyOption = {
|
||||
title: '[Create or upload a new API key]',
|
||||
value: '__create_new_key__',
|
||||
|
||||
@@ -70,12 +70,14 @@ export function mergeEnvContent(rawContent: string, newVars: Record<string, stri
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(rawContent)) !== null) {
|
||||
const key = match[1];
|
||||
if (!Object.hasOwn(newVars, key) || !match.indices) {
|
||||
const indices = match.indices;
|
||||
const keyRange = indices?.[1];
|
||||
if (!Object.hasOwn(newVars, key) || !indices || !keyRange) {
|
||||
continue;
|
||||
}
|
||||
// match[0] runs past the value onto a following comment line, so spans come from the groups.
|
||||
const [keyStart, keyEnd] = match.indices[1];
|
||||
const valueRange = match.indices[2];
|
||||
const [keyStart, keyEnd] = keyRange;
|
||||
const valueRange = indices[2];
|
||||
const definitionEnd = valueRange
|
||||
? valueRange[1] - (/\s+$/.exec(match[2])?.[0].length ?? 0)
|
||||
: keyEnd + (/^[ \t]*(?:=[ \t]*|:[ \t]+)/.exec(rawContent.slice(keyEnd))?.[0].length ?? 0);
|
||||
|
||||
@@ -289,6 +289,23 @@ describe('authorizeViaBrowserAsync / loadOrganizationsBestEffortAsync / pollForC
|
||||
await expect(loadOrganizationsBestEffortAsync(client, 'acct-1')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('waits for the existing connection to change during reauthorization', async () => {
|
||||
jest.mocked(openBrowserAsync).mockResolvedValue(true as never);
|
||||
const updated = { ...connection, updatedAt: '2024-01-02T00:00:00.000Z' };
|
||||
jest
|
||||
.mocked(SupabaseMutation.beginSupabaseOAuthAsync)
|
||||
.mockResolvedValue({ url: 'https://supabase.com/oauth' });
|
||||
jest
|
||||
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
|
||||
.mockResolvedValueOnce(connection)
|
||||
.mockResolvedValueOnce(updated);
|
||||
jest.mocked(SupabaseMutation.listSupabaseOrganizationsAsync).mockResolvedValue([]);
|
||||
const promise = authorizeViaBrowserAsync(client, account, false, connection.updatedAt);
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
await expect(promise).resolves.toEqual(updated);
|
||||
expect(SupabaseMutation.disconnectSupabaseAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pollForConnectionAsync retries after retriable query errors', async () => {
|
||||
jest
|
||||
.mocked(SupabaseQuery.getSupabaseConnectionByAccountIdAsync)
|
||||
|
||||
@@ -132,7 +132,8 @@ export async function pollProvisionReceiptAsync(
|
||||
export async function authorizeViaBrowserAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
account: { id: string; name: string },
|
||||
nonInteractive: boolean
|
||||
nonInteractive: boolean,
|
||||
previousUpdatedAt?: string
|
||||
): Promise<SupabaseConnectionData> {
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
@@ -154,7 +155,7 @@ export async function authorizeViaBrowserAsync(
|
||||
'Waiting for you to authorize in Supabase (up to 15 minutes; press Ctrl-C to cancel)'
|
||||
).start();
|
||||
try {
|
||||
const connection = await pollForConnectionAsync(graphqlClient, account.id);
|
||||
const connection = await pollForConnectionAsync(graphqlClient, account.id, previousUpdatedAt);
|
||||
const organizations = await loadOrganizationsBestEffortAsync(graphqlClient, account.id);
|
||||
spinner.succeed(
|
||||
`Connected Supabase organization ${chalk.bold(
|
||||
@@ -181,7 +182,8 @@ export async function loadOrganizationsBestEffortAsync(
|
||||
|
||||
export async function pollForConnectionAsync(
|
||||
graphqlClient: ExpoGraphqlClient,
|
||||
accountId: string
|
||||
accountId: string,
|
||||
previousUpdatedAt?: string
|
||||
): Promise<SupabaseConnectionData> {
|
||||
const deadline = Date.now() + CONNECTION_POLL_TIMEOUT_MS;
|
||||
let consecutiveErrors = 0;
|
||||
@@ -207,7 +209,11 @@ export async function pollForConnectionAsync(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (connection) {
|
||||
// Reauthorization updates the existing connection; its presence alone is not completion.
|
||||
if (
|
||||
connection &&
|
||||
(previousUpdatedAt === undefined || connection.updatedAt !== previousUpdatedAt)
|
||||
) {
|
||||
return connection;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
|
||||
@@ -130,6 +130,29 @@ describe(getAppStoreAuthAsync, () => {
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('uses individual API key from submit profile when ascApiKeyIssuerId is absent', async () => {
|
||||
const profile = {
|
||||
bundleIdentifier: 'com.example.app',
|
||||
ascApiKeyPath: '/path/to/key.p8',
|
||||
ascApiKeyId: 'KEY123',
|
||||
} as any;
|
||||
const args = createBaseArgs({ profile });
|
||||
|
||||
const result = await getAppStoreAuthAsync(args);
|
||||
|
||||
expect(args.credentialsCtx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: AuthenticationMode.API_KEY,
|
||||
ascApiKey: {
|
||||
keyP8: 'mock-key-p8-content',
|
||||
keyId: 'KEY123',
|
||||
issuerId: undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(result.app).toBe(mockApp);
|
||||
});
|
||||
|
||||
it('uses API key from EAS credentials service when available', async () => {
|
||||
(getAscApiKeyForAppSubmissionsAsync as jest.Mock).mockResolvedValue({
|
||||
id: 'asc-key-id',
|
||||
|
||||
@@ -68,9 +68,9 @@ async function tryResolveAscApiKeyAsync({
|
||||
bundleId: string;
|
||||
}): Promise<ResolvedAscApiKey | null> {
|
||||
// 1. Check submit profile for ASC API key fields
|
||||
if ('ascApiKeyPath' in profile && 'ascApiKeyIssuerId' in profile && 'ascApiKeyId' in profile) {
|
||||
if ('ascApiKeyPath' in profile && 'ascApiKeyId' in profile) {
|
||||
const { ascApiKeyPath, ascApiKeyIssuerId, ascApiKeyId } = profile;
|
||||
if (ascApiKeyPath && ascApiKeyIssuerId && ascApiKeyId) {
|
||||
if (ascApiKeyPath && ascApiKeyId) {
|
||||
const keyP8 = await fs.promises.readFile(ascApiKeyPath, 'utf-8');
|
||||
// Also try to get teamId from the profile if available
|
||||
const teamId = 'appleTeamId' in profile ? (profile as any).appleTeamId : undefined;
|
||||
@@ -186,8 +186,8 @@ export async function getAppStoreAuthAsync({
|
||||
if (nonInteractive) {
|
||||
throw new Error(
|
||||
'No App Store Connect API Key found. In non-interactive mode, provide one via:\n' +
|
||||
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' +
|
||||
' - eas.json submit profile: ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId\n' +
|
||||
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID (omit the issuer ID for individual API keys)\n' +
|
||||
' - eas.json submit profile: ascApiKeyPath, ascApiKeyId, ascApiKeyIssuerId (omit the issuer ID for individual API keys)\n' +
|
||||
' - EAS credentials service: run `eas credentials` to set up an API key'
|
||||
);
|
||||
}
|
||||
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user